fix(amazon-shopping): verify prime and delivery filters
This commit is contained in:
@@ -21,13 +21,17 @@ node "$HOME/.openclaw/workspace/skills/web-automation/scripts/check-install.js"
|
||||
Run the helper from the installed skill directory:
|
||||
|
||||
```bash
|
||||
"$HOME/.openclaw/workspace/skills/amazon-shopping/scripts/search-products" '<user request>' --json
|
||||
"$HOME/.openclaw/workspace/skills/amazon-shopping/scripts/search-products" '<user request>' --json --markdown
|
||||
```
|
||||
|
||||
Use single quotes around product requests that contain dollar amounts so the shell does not expand `$4` or similar text. Use `--limit N`; `--max N` is accepted as a compatibility alias. If your execution tool supports a timeout option, set it to at least 600 seconds for live runs with detail enrichment. Use `--skip-details` only for a quick preview or when the user does not need specifications and delivery details.
|
||||
|
||||
Default to at most 15 products unless the user asks for a different count. For requested counts above 30, ask before continuing or split the request into batches. Always include source URLs, report missing fields explicitly, and do not claim review histogram data unless it was visible and extracted.
|
||||
|
||||
For user-facing answers, use the generated markdown table as the presentation template. Keep the columns `#`, `Product`, `Price`, `Rating`, `Reviews`, `Width`, `Prime`, `Delivery`, and `Link` when those fields are relevant. Do not rewrite Prime or delivery status as verified unless the helper marks it verified.
|
||||
|
||||
Supported filters include minimum rating, minimum reviews, maximum price, maximum unit price, minimum width in inches, Prime delivery, delivery by today/tomorrow/overnight, and sort by price. Natural language such as `77 inches or wider`, `shipped with Prime`, `delivery by tomorrow`, `overnight shipping`, and `top 10 by price` is parsed automatically. CLI flags are also available: `--min-width`, `--require-prime`, `--delivery-by`, and `--sort-by price`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
This skill is for operator-directed, read-only product research. Before live scraping, the helper checks Amazon robots directives for planned paths. Do not automate sign-in, cart, purchase, wishlist, review submission, review-page crawling, CAPTCHA bypass, or blocked-page bypass. If Amazon returns a challenge or block page, stop and report that status.
|
||||
|
||||
@@ -16,6 +16,7 @@ Search result cards should be treated as candidates, not final truth. Prefer car
|
||||
| `rating` | `aria-label` or visible text like `4.6 out of 5 stars` | Optional. |
|
||||
| `reviewCount` | text near rating like `1,234 ratings` | Optional. |
|
||||
| `delivery.display` | visible delivery promise text | Optional and ZIP/session dependent. |
|
||||
| `delivery.prime` | visible Prime badge, Prime icon class, `aria-label`, `alt`, or delivery text | Optional and ZIP/session dependent. Preserve a true search-card Prime signal when detail text omits the literal word Prime. |
|
||||
| `isSponsored` | visible sponsored marker | Sponsored results may be included but must be labeled. |
|
||||
|
||||
## Detail Page Fields
|
||||
@@ -40,6 +41,10 @@ Open only normalized product detail URLs under `/dp/<ASIN>` or `/gp/product/<ASI
|
||||
- `more than 4.5 stars` means `rating > 4.5`.
|
||||
- `4.5 stars or better` means `rating >= 4.5`.
|
||||
- `less than $4 each` means visible unit price first, then high-confidence unit-count inference. Unknown unit prices do not pass strict unit-price filters.
|
||||
- `77 inches or wider` means the overall product width must be `>= 77` inches. Prefer product/item dimensions with an explicit `W` component; ignore seat, arm, door, package, and cushion widths.
|
||||
- `shipped with Prime` / `Prime shipping` means a visible Prime signal must be detected on the search card or detail page.
|
||||
- `delivery by tomorrow` and `overnight shipping` require visible delivery text that indicates tomorrow, overnight, next-day, or one-day delivery.
|
||||
- `top 10 by price` sorts passing products by displayed product price ascending.
|
||||
- Missing fields must be represented as `null` or noted in `missingFields` / `extractionNotes`; never fabricate values.
|
||||
|
||||
## Official Alternatives
|
||||
|
||||
@@ -5,13 +5,13 @@ Use these patterns when debugging or extending the `amazon-shopping` browser wor
|
||||
## Search Page
|
||||
|
||||
```text
|
||||
Use the installed web-automation skill. Open https://www.amazon.com/s?k=<encoded query>. Wait for rendered search results. If a CAPTCHA, bot check, sign-in wall, or access denied page appears, stop and return a challenge status. Otherwise extract visible product cards with ASIN, title, product URL, displayed price, rating text, review count text, delivery text, sponsor marker, and image URL. Do not open cart, sign-in, wishlist, or review-listing pages.
|
||||
Use the installed web-automation skill. Open https://www.amazon.com/s?k=<encoded query>. Wait for rendered search results. If a CAPTCHA, bot check, sign-in wall, or access denied page appears, stop and return a challenge status. Otherwise extract visible product cards with ASIN, title, product URL, displayed price, rating text, review count text, delivery text, Prime badge/icon/aria/alt signal, sponsor marker, and image URL. Do not open cart, sign-in, wishlist, or review-listing pages.
|
||||
```
|
||||
|
||||
## Detail Page
|
||||
|
||||
```text
|
||||
For each candidate product detail URL under /dp/<ASIN> or /gp/product/<ASIN>, open the page slowly one at a time. Extract title, canonical link, visible buy-box/current price, delivery summary, availability, seller when visible, feature bullets, product details/specification table rows, rating score, review count, and visible customer-review histogram percentages if present on the detail page. Do not navigate to /product-reviews or reviewer profile pages.
|
||||
For each candidate product detail URL under /dp/<ASIN> or /gp/product/<ASIN>, open the page slowly one at a time. Extract title, canonical link, visible buy-box/current price, delivery summary, Prime badge/icon/aria/alt signal, availability, seller when visible, feature bullets, product details/specification table rows, rating score, review count, and visible customer-review histogram percentages if present on the detail page. Do not navigate to /product-reviews or reviewer profile pages.
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
@@ -31,6 +31,9 @@ Options:
|
||||
--min-reviews N Minimum review count
|
||||
--max-price N Maximum displayed product price
|
||||
--max-unit-price N Maximum price per unit
|
||||
--min-width N Minimum product width in inches
|
||||
--require-prime Require Prime delivery verification
|
||||
--delivery-by VALUE Require delivery timing, e.g. today, tomorrow, overnight
|
||||
--max-search-pages N Search result pages to scan, 1-5 (default: 2)
|
||||
--skip-details Do not open product detail pages
|
||||
--dry-run Parse and print the planned request without Amazon network access
|
||||
@@ -66,7 +69,7 @@ export function buildSearchUrl(query: string): string {
|
||||
|
||||
export function parseCliRequest(argv: string[]): SearchProductsRequest {
|
||||
const args = minimist(argv, {
|
||||
boolean: ["help", "json", "markdown", "allow-large-limit", "dry-run", "skip-details"],
|
||||
boolean: ["help", "json", "markdown", "allow-large-limit", "dry-run", "skip-details", "require-prime"],
|
||||
string: [
|
||||
"query",
|
||||
"limit",
|
||||
@@ -75,6 +78,9 @@ export function parseCliRequest(argv: string[]): SearchProductsRequest {
|
||||
"min-reviews",
|
||||
"max-price",
|
||||
"max-unit-price",
|
||||
"min-width",
|
||||
"delivery-by",
|
||||
"sort-by",
|
||||
"max-search-pages"
|
||||
],
|
||||
alias: { h: "help", max: "limit" }
|
||||
@@ -101,10 +107,24 @@ export function parseCliRequest(argv: string[]): SearchProductsRequest {
|
||||
const minReviews = parsePositiveInteger(args["min-reviews"], "min-reviews");
|
||||
const maxPrice = parseNumber(args["max-price"], "max-price");
|
||||
const maxUnitPrice = parseNumber(args["max-unit-price"], "max-unit-price");
|
||||
const minWidth = parseNumber(args["min-width"], "min-width");
|
||||
if (minRating !== undefined) filters.minRating = minRating;
|
||||
if (minReviews !== undefined) filters.minReviews = minReviews;
|
||||
if (maxPrice !== undefined) filters.maxPrice = maxPrice;
|
||||
if (maxUnitPrice !== undefined) filters.maxUnitPrice = maxUnitPrice;
|
||||
if (minWidth !== undefined) {
|
||||
filters.minWidthInches = minWidth;
|
||||
filters.widthComparison = "gte";
|
||||
}
|
||||
if (args["require-prime"]) filters.requirePrime = true;
|
||||
if (args["delivery-by"]) filters.deliveryBy = String(args["delivery-by"]);
|
||||
if (args["sort-by"]) {
|
||||
const sortBy = String(args["sort-by"]);
|
||||
if (sortBy !== "price" && sortBy !== "relevance") {
|
||||
throw new Error("sort-by must be either price or relevance");
|
||||
}
|
||||
filters.sortBy = sortBy;
|
||||
}
|
||||
|
||||
const json = Boolean(args.json);
|
||||
const markdown = Boolean(args.markdown);
|
||||
|
||||
@@ -76,15 +76,42 @@ function extractHistogramText(root: HTMLElement): string {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function deliveryFromText(text: string): DeliverySummary | undefined {
|
||||
function deliveryFromText(text: string, primeSignal = false): DeliverySummary | undefined {
|
||||
const display = text.replace(/\s+/g, " ").trim();
|
||||
if (!display) {
|
||||
return undefined;
|
||||
return primeSignal ? { display: "Prime delivery available", prime: true } : undefined;
|
||||
}
|
||||
return {
|
||||
display,
|
||||
free: /\bfree\b/i.test(display),
|
||||
prime: /\bprime\b/i.test(display)
|
||||
prime: primeSignal || /\bprime\b/i.test(display)
|
||||
};
|
||||
}
|
||||
|
||||
function hasPrimeSignal(root: HTMLElement): boolean {
|
||||
const attributeText = root.querySelectorAll("[id], [class], [aria-label], img[alt]")
|
||||
.map((node) => [
|
||||
attrOf(node, "id"),
|
||||
attrOf(node, "class"),
|
||||
attrOf(node, "aria-label"),
|
||||
attrOf(node, "alt")
|
||||
].join(" "))
|
||||
.join(" ");
|
||||
return /a-icon-prime|prime-logo|primeExclusive|primePopover|amazon\s+prime|\bprime\b/i.test(attributeText);
|
||||
}
|
||||
|
||||
function mergeDelivery(detail: DeliverySummary | undefined, base: DeliverySummary | undefined): DeliverySummary | undefined {
|
||||
if (!detail) {
|
||||
return base;
|
||||
}
|
||||
if (!base) {
|
||||
return detail;
|
||||
}
|
||||
return {
|
||||
display: detail.display || base.display,
|
||||
free: Boolean(detail.free || base.free),
|
||||
prime: Boolean(detail.prime || base.prime),
|
||||
fastestDate: detail.fastestDate ?? base.fastestDate
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +140,7 @@ export function extractDetailPage(html: string, base: ProductSearchResult): Prod
|
||||
price: parseMoney(priceText) ?? base.price,
|
||||
rating: parseRating(ratingText) ?? base.rating,
|
||||
reviewCount: parseReviewCount(reviewText) ?? base.reviewCount,
|
||||
delivery: deliveryFromText(deliveryText) ?? base.delivery,
|
||||
delivery: mergeDelivery(deliveryFromText(deliveryText, hasPrimeSignal(root)), base.delivery),
|
||||
availability: availability || base.availability,
|
||||
seller: seller || base.seller,
|
||||
bullets: extractBullets(root),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FilteredProducts, ProductFilters, ProductSearchResult } from "./types.js";
|
||||
import { extractWidthInches } from "./product-metrics.js";
|
||||
|
||||
function passesMin(value: number | undefined, threshold: number, comparison: "gt" | "gte" | undefined): boolean {
|
||||
if (value === undefined) {
|
||||
@@ -29,16 +30,48 @@ function filterReasons(product: ProductSearchResult, filters: ProductFilters): s
|
||||
reasons.push(`unit price ${product.unitPrice.display} above filter`);
|
||||
}
|
||||
}
|
||||
if (filters.minWidthInches !== undefined) {
|
||||
const width = extractWidthInches(product);
|
||||
if (width === undefined) {
|
||||
reasons.push("width unknown");
|
||||
} else if (!passesMin(width, filters.minWidthInches, filters.widthComparison)) {
|
||||
reasons.push(`width ${width} inches below filter`);
|
||||
}
|
||||
}
|
||||
if (filters.requirePrime && !product.delivery?.prime) {
|
||||
reasons.push("Prime delivery not verified");
|
||||
}
|
||||
if (filters.requireFreeDelivery && !product.delivery?.free) {
|
||||
reasons.push("free delivery not verified");
|
||||
}
|
||||
if (filters.deliveryBy && !deliveryMatches(product.delivery?.display, filters.deliveryBy)) {
|
||||
reasons.push(`${filters.deliveryBy} delivery not verified`);
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function rankProducts(a: ProductSearchResult, b: ProductSearchResult): number {
|
||||
function deliveryMatches(display: string | undefined, deliveryBy: string): boolean {
|
||||
if (!display) {
|
||||
return false;
|
||||
}
|
||||
const normalized = display.toLowerCase();
|
||||
if (deliveryBy === "today") {
|
||||
return /\btoday\b|same[- ]day/.test(normalized);
|
||||
}
|
||||
if (deliveryBy === "tomorrow" || deliveryBy === "overnight") {
|
||||
return /\btomorrow\b|overnight|next[- ]day|one[- ]day/.test(normalized);
|
||||
}
|
||||
return normalized.includes(deliveryBy.toLowerCase());
|
||||
}
|
||||
|
||||
function comparisonSymbol(comparison: "gt" | "gte" | undefined): string {
|
||||
return comparison === "gt" ? ">" : ">=";
|
||||
}
|
||||
|
||||
function rankProducts(a: ProductSearchResult, b: ProductSearchResult, filters: ProductFilters): number {
|
||||
if (filters.sortBy === "price") {
|
||||
return (a.price?.amount ?? Number.POSITIVE_INFINITY) - (b.price?.amount ?? Number.POSITIVE_INFINITY);
|
||||
}
|
||||
const ratingDiff = (b.rating ?? -1) - (a.rating ?? -1);
|
||||
if (ratingDiff !== 0) return ratingDiff;
|
||||
const reviewDiff = (b.reviewCount ?? -1) - (a.reviewCount ?? -1);
|
||||
@@ -73,13 +106,17 @@ export function applyFiltersAndLimit(
|
||||
...(filters.minRating !== undefined ? [`rating ${filters.ratingComparison ?? "gte"} ${filters.minRating}`] : []),
|
||||
...(filters.minReviews !== undefined ? [`reviews ${filters.reviewCountComparison ?? "gte"} ${filters.minReviews}`] : []),
|
||||
...(filters.maxPrice !== undefined ? [`price <= ${filters.maxPrice}`] : []),
|
||||
...(filters.maxUnitPrice !== undefined ? [`unit price <= ${filters.maxUnitPrice}`] : [])
|
||||
...(filters.maxUnitPrice !== undefined ? [`unit price <= ${filters.maxUnitPrice}`] : []),
|
||||
...(filters.minWidthInches !== undefined ? [`width ${comparisonSymbol(filters.widthComparison)} ${filters.minWidthInches} inches`] : []),
|
||||
...(filters.requirePrime ? ["Prime delivery"] : []),
|
||||
...(filters.requireFreeDelivery ? ["free delivery"] : []),
|
||||
...(filters.deliveryBy ? [`delivery by ${filters.deliveryBy}`] : [])
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: passing.sort(rankProducts).slice(0, limit),
|
||||
results: passing.sort((a, b) => rankProducts(a, b, filters)).slice(0, limit),
|
||||
filteredOutCount: uniqueProducts.size - passing.length,
|
||||
filteredOutReasons
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ProductSearchResult, ProductSpec } from "./types.js";
|
||||
|
||||
function parseDimensionNumber(text: string): number | undefined {
|
||||
const match = text.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function isOverallWidthSpec(spec: ProductSpec): boolean {
|
||||
const name = spec.name.toLowerCase();
|
||||
if (/seat|arm|door|package|box|back|cushion/.test(name)) {
|
||||
return false;
|
||||
}
|
||||
return /width|dimensions?/.test(name);
|
||||
}
|
||||
|
||||
function widthFromSpec(spec: ProductSpec): number | undefined {
|
||||
if (!isOverallWidthSpec(spec)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = spec.name.toLowerCase();
|
||||
const value = spec.value;
|
||||
const labeledWidth = value.match(/([0-9]+(?:\.[0-9]+)?)\s*(?:"|in(?:ches?)?)?\s*W\b/i);
|
||||
if (labeledWidth) {
|
||||
return Number(labeledWidth[1]);
|
||||
}
|
||||
|
||||
if (/width/.test(name)) {
|
||||
return parseDimensionNumber(value);
|
||||
}
|
||||
|
||||
const orderMatch = name.match(/\b([dwh])\s*x\s*([dwh])(?:\s*x\s*([dwh]))?\b/i);
|
||||
if (orderMatch) {
|
||||
const order = orderMatch.slice(1).filter(Boolean).map((part) => part.toLowerCase());
|
||||
const widthIndex = order.indexOf("w");
|
||||
const values = value.match(/[0-9]+(?:\.[0-9]+)?/g)?.map(Number) ?? [];
|
||||
if (widthIndex >= 0 && values[widthIndex] !== undefined) {
|
||||
return values[widthIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractWidthInches(product: ProductSearchResult): number | undefined {
|
||||
for (const spec of product.specs) {
|
||||
const width = widthFromSpec(spec);
|
||||
if (width !== undefined) {
|
||||
return width;
|
||||
}
|
||||
}
|
||||
|
||||
const titleMatch = product.title.match(/\b([0-9]+(?:\.[0-9]+)?)\s*(?:["”]|in(?:ch(?:es)?)?)\b/i);
|
||||
return titleMatch ? Number(titleMatch[1]) : undefined;
|
||||
}
|
||||
|
||||
export function formatWidthInches(width: number | undefined): string {
|
||||
if (width === undefined) {
|
||||
return "unknown";
|
||||
}
|
||||
return `${Number.isInteger(width) ? width.toFixed(0) : width.toFixed(1)}"`;
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import type { ParsedNaturalLanguageRequest, ProductFilters } from "./types.js";
|
||||
|
||||
function cleanQuery(text: string): string {
|
||||
return text
|
||||
.replace(/\breview score of\b/gi, " ")
|
||||
.replace(/\brating of\b/gi, " ")
|
||||
.replace(/\bof\s+in\s+width\b/gi, " ")
|
||||
.replace(/\bin\s+width\b/gi, " ")
|
||||
.replace(/\b(?:that|and|with|have)\b/gi, " ")
|
||||
.replace(/[,\s]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s+(and|or|a)$/i, "")
|
||||
.trim();
|
||||
@@ -29,6 +34,38 @@ export function parseNaturalLanguageRequest(input: string): ParsedNaturalLanguag
|
||||
remaining = removeMatched(remaining, limitMatch);
|
||||
}
|
||||
|
||||
const sortByPriceMatch = remaining.match(/\b(?:by price|sort(?:ed)? by price|lowest price|cheapest|least expensive)\b/i);
|
||||
if (sortByPriceMatch) {
|
||||
filters.sortBy = "price";
|
||||
remaining = removeMatched(remaining, sortByPriceMatch);
|
||||
}
|
||||
|
||||
const deliveryTomorrowMatch = remaining.match(/\b(?:delivery|delivered|arrives?|shipping|ships?)\s+(?:by\s+)?tomorrow\b/i);
|
||||
const deliveryTodayMatch = remaining.match(/\b(?:delivery|delivered|arrives?|shipping|ships?)\s+(?:by\s+)?today\b/i)
|
||||
?? remaining.match(/\bsame[- ]day\s+(?:delivery|shipping)\b/i);
|
||||
const overnightMatch = remaining.match(/\bovernight\s+(?:delivery|shipping)\b/i)
|
||||
?? remaining.match(/\bnext[- ]day\s+(?:delivery|shipping)\b/i);
|
||||
const deliveryMatch = overnightMatch ?? deliveryTomorrowMatch ?? deliveryTodayMatch;
|
||||
if (deliveryMatch) {
|
||||
filters.deliveryBy = overnightMatch ? "overnight" : deliveryTomorrowMatch ? "tomorrow" : "today";
|
||||
remaining = removeMatched(remaining, deliveryMatch);
|
||||
}
|
||||
|
||||
const primeMatch = remaining.match(/\b(?:(?:shipped|ships|shipping|delivery|delivered)\s+(?:with|by|from)\s+)?prime\b/i);
|
||||
if (primeMatch) {
|
||||
filters.requirePrime = true;
|
||||
remaining = removeMatched(remaining, primeMatch);
|
||||
}
|
||||
|
||||
const widthMatch = remaining.match(/\b(?:width\s*(?:of\s*)?)?([0-9]+(?:\.[0-9]+)?)\s*(?:inches|inch|in\.?|")\s*(?:or\s+)?(?:wider|wide|larger|longer)\b/i)
|
||||
?? remaining.match(/\b([0-9]+(?:\.[0-9]+)?)\s*(?:inches|inch|in\.?|")\s*(?:or\s+)?(?:wider|wide|larger|longer)\s+(?:in\s+)?width\b/i)
|
||||
?? remaining.match(/\b(?:at least|minimum|min\.?)\s+([0-9]+(?:\.[0-9]+)?)\s*(?:inches|inch|in\.?|")\s+(?:wide|width)\b/i);
|
||||
if (widthMatch) {
|
||||
filters.minWidthInches = Number(widthMatch[1]);
|
||||
filters.widthComparison = "gte";
|
||||
remaining = removeMatched(remaining, widthMatch);
|
||||
}
|
||||
|
||||
const unitPriceMatch = remaining.match(/\b(?:cost\s+)?(?:less than|under|below)\s+\$([0-9]+(?:\.[0-9]{1,2})?)\s*(?:each|per\b|\/\s*(?:count|unit|item))\b/i);
|
||||
if (unitPriceMatch) {
|
||||
filters.maxUnitPrice = Number(unitPriceMatch[1]);
|
||||
@@ -42,7 +79,8 @@ export function parseNaturalLanguageRequest(input: string): ParsedNaturalLanguag
|
||||
}
|
||||
|
||||
const exclusiveReviews = remaining.match(/\b(?:over|more than|above)\s+([0-9][0-9,]*)\s*(?:reviews?|ratings?)\b/i);
|
||||
const inclusiveReviews = remaining.match(/\b(?:at least|minimum|min\.?)\s+([0-9][0-9,]*)\s*(?:reviews?|ratings?)\b/i);
|
||||
const inclusiveReviews = remaining.match(/\b(?:at least|minimum|min\.?)\s+([0-9][0-9,]*)\s*(?:reviews?|ratings?)\b/i)
|
||||
?? remaining.match(/\b([0-9][0-9,]*)\s*\+\s*(?:reviews?|ratings?)\b/i);
|
||||
const reviewMatch = exclusiveReviews ?? inclusiveReviews;
|
||||
if (reviewMatch) {
|
||||
filters.minReviews = Number(reviewMatch[1].replace(/,/g, ""));
|
||||
@@ -51,7 +89,9 @@ export function parseNaturalLanguageRequest(input: string): ParsedNaturalLanguag
|
||||
}
|
||||
|
||||
const exclusiveRating = remaining.match(/\b(?:a\s+)?(?:(?:review score|rating)\s+of\s+|rating\s+)?(?:more than|over|above|rated above)\s+([0-5](?:\.[0-9])?)\s*(?:stars?)?\b/i);
|
||||
const inclusiveRating = remaining.match(/\b([0-5](?:\.[0-9])?)\s*stars?\s+or\s+better\b/i)
|
||||
const inclusiveRating = remaining.match(/\b(?:review score|rating)\s+of\s+([0-5](?:\.[0-9])?)\s*(?:stars?)?\s+(?:or|and)\s+(?:higher|better)\b/i)
|
||||
?? remaining.match(/\b([0-5](?:\.[0-9])?)\s*stars?\s+or\s+better\b/i)
|
||||
?? remaining.match(/\b([0-5](?:\.[0-9])?)\s*stars?\s+(?:or|and)\s+(?:higher|better)\b/i)
|
||||
?? remaining.match(/\b(?:at least|minimum|min\.?)\s+([0-5](?:\.[0-9])?)\s*(?:stars?|rating)?\b/i);
|
||||
const ratingMatch = exclusiveRating ?? inclusiveRating;
|
||||
if (ratingMatch) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProductFilters, ProductSearchResult, SearchProductsResponse } from "./types.js";
|
||||
import { extractWidthInches, formatWidthInches } from "./product-metrics.js";
|
||||
|
||||
export interface ResponseInput {
|
||||
query: string;
|
||||
@@ -33,25 +34,87 @@ function formatFilters(filters: ProductFilters): string {
|
||||
filters.minRating !== undefined ? `rating ${filters.ratingComparison ?? "gte"} ${filters.minRating}` : "",
|
||||
filters.minReviews !== undefined ? `reviews ${filters.reviewCountComparison ?? "gte"} ${filters.minReviews}` : "",
|
||||
filters.maxPrice !== undefined ? `price <= $${filters.maxPrice}` : "",
|
||||
filters.maxUnitPrice !== undefined ? `unit price <= $${filters.maxUnitPrice}` : ""
|
||||
filters.maxUnitPrice !== undefined ? `unit price <= $${filters.maxUnitPrice}` : "",
|
||||
filters.minWidthInches !== undefined ? `width ${filters.widthComparison ?? "gte"} ${filters.minWidthInches} inches` : "",
|
||||
filters.requirePrime ? "Prime delivery" : "",
|
||||
filters.requireFreeDelivery ? "free delivery" : "",
|
||||
filters.deliveryBy ? `delivery by ${filters.deliveryBy}` : "",
|
||||
filters.sortBy === "price" ? "sort by price" : ""
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(", ") : "none";
|
||||
}
|
||||
|
||||
function formatProduct(product: ProductSearchResult, index: number): string {
|
||||
const specs = product.specs.slice(0, 3).map((spec) => `${spec.name}: ${spec.value}`).join("; ");
|
||||
const lines = [
|
||||
`${index}. ${product.title}`,
|
||||
` Link: ${product.url}`,
|
||||
` Price: ${product.price?.display ?? "unknown"}${product.unitPrice ? ` (${product.unitPrice.display})` : ""}`,
|
||||
` Rating: ${product.rating ?? "unknown"} stars; reviews: ${product.reviewCount ?? "unknown"}`,
|
||||
` Delivery: ${product.delivery?.display ?? "unknown"}`,
|
||||
specs ? ` Specs: ${specs}` : "",
|
||||
product.bullets[0] ? ` Notes: ${product.bullets.slice(0, 2).join(" ")}` : "",
|
||||
product.missingFields.length > 0 ? ` Missing: ${product.missingFields.join(", ")}` : "",
|
||||
product.isSponsored ? " Sponsored: yes" : ""
|
||||
].filter(Boolean);
|
||||
return lines.join("\n");
|
||||
function escapeCell(value: string): string {
|
||||
return value.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function marker(passes: boolean | undefined, enabled: boolean): string {
|
||||
if (!enabled) {
|
||||
return "";
|
||||
}
|
||||
return passes ? " OK" : " NO";
|
||||
}
|
||||
|
||||
function widthCell(product: ProductSearchResult, filters: ProductFilters): string {
|
||||
const width = extractWidthInches(product);
|
||||
const passes = width !== undefined && (filters.widthComparison === "gt" ? width > (filters.minWidthInches ?? 0) : width >= (filters.minWidthInches ?? 0));
|
||||
return `${formatWidthInches(width)}${marker(passes, filters.minWidthInches !== undefined)}`;
|
||||
}
|
||||
|
||||
function primeCell(product: ProductSearchResult, filters: ProductFilters): string {
|
||||
if (product.delivery?.prime) {
|
||||
return `Prime${marker(true, Boolean(filters.requirePrime))}`;
|
||||
}
|
||||
return `not verified${marker(false, Boolean(filters.requirePrime))}`;
|
||||
}
|
||||
|
||||
function deliveryCell(product: ProductSearchResult, filters: ProductFilters): string {
|
||||
const display = product.delivery?.display ?? "unknown";
|
||||
if (!filters.deliveryBy) {
|
||||
return display;
|
||||
}
|
||||
const normalized = display.toLowerCase();
|
||||
const passes = filters.deliveryBy === "today"
|
||||
? /\btoday\b|same[- ]day/.test(normalized)
|
||||
: filters.deliveryBy === "tomorrow" || filters.deliveryBy === "overnight"
|
||||
? /\btomorrow\b|overnight|next[- ]day|one[- ]day/.test(normalized)
|
||||
: normalized.includes(filters.deliveryBy.toLowerCase());
|
||||
return `${display}${marker(passes, true)}`;
|
||||
}
|
||||
|
||||
function resultTable(products: ProductSearchResult[], filters: ProductFilters): string[] {
|
||||
const rows = [
|
||||
"| # | Product | Price | Rating | Reviews | Width | Prime | Delivery | Link |",
|
||||
"|---|---|---:|---:|---:|---:|---|---|---|",
|
||||
...products.map((product, index) => [
|
||||
String(index + 1),
|
||||
escapeCell(product.title),
|
||||
product.price?.display ?? "unknown",
|
||||
`${product.rating ?? "unknown"} stars`,
|
||||
product.reviewCount?.toLocaleString("en-US") ?? "unknown",
|
||||
widthCell(product, filters),
|
||||
primeCell(product, filters),
|
||||
escapeCell(deliveryCell(product, filters)),
|
||||
`[Amazon](${product.url})`
|
||||
].join(" | "))
|
||||
.map((row) => `| ${row} |`)
|
||||
];
|
||||
return rows;
|
||||
}
|
||||
|
||||
function metadataLines(products: ProductSearchResult[]): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const product of products) {
|
||||
const notes = [
|
||||
product.missingFields.length > 0 ? `missing ${product.missingFields.join(", ")}` : "",
|
||||
product.isSponsored ? "sponsored" : "",
|
||||
product.extractionNotes.length > 0 ? product.extractionNotes.join("; ") : ""
|
||||
].filter(Boolean);
|
||||
if (notes.length > 0) {
|
||||
lines.push(`- ${product.title}: ${notes.join("; ")}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function createMarkdownReport(response: SearchProductsResponse): string {
|
||||
@@ -63,7 +126,12 @@ export function createMarkdownReport(response: SearchProductsResponse): string {
|
||||
`Results returned: ${response.results.length} (filtered out: ${response.filteredOutCount})`,
|
||||
response.warnings.length > 0 ? `Warnings: ${response.warnings.join("; ")}` : "",
|
||||
"",
|
||||
...response.results.map((product, index) => formatProduct(product, index + 1))
|
||||
"## Best Matches",
|
||||
"",
|
||||
response.results.length > 0 ? "" : "No products matched all requested filters.",
|
||||
...resultTable(response.results, response.filters),
|
||||
"",
|
||||
...metadataLines(response.results)
|
||||
].filter((line) => line !== "");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
@@ -50,20 +50,31 @@ function detectChallenge(html: string): boolean {
|
||||
return /robot check|enter the characters you see|captcha|automated access|access denied/i.test(html);
|
||||
}
|
||||
|
||||
function deliveryFromText(text: string): DeliverySummary | undefined {
|
||||
function deliveryFromText(text: string, primeSignal = false): DeliverySummary | undefined {
|
||||
const compact = text.replace(/\s+/g, " ").trim();
|
||||
const deliveryMatch = compact.match(/((?:FREE\s+)?delivery[^.]*?(?:Tomorrow|Today|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)?)/i);
|
||||
const deliveryMatch = compact.match(/((?:FREE\s+)?delivery\b[^.]+)/i);
|
||||
if (!deliveryMatch) {
|
||||
return undefined;
|
||||
return primeSignal ? { display: "Prime delivery available", prime: true } : undefined;
|
||||
}
|
||||
const display = deliveryMatch[1].trim();
|
||||
return {
|
||||
display,
|
||||
free: /\bfree\b/i.test(display),
|
||||
prime: /\bprime\b/i.test(compact)
|
||||
prime: primeSignal || /\bprime\b/i.test(display)
|
||||
};
|
||||
}
|
||||
|
||||
function hasPrimeSignal(card: HTMLElement): boolean {
|
||||
const attributeText = card.querySelectorAll("[class], [aria-label], img[alt]")
|
||||
.map((node) => [
|
||||
attrOf(node, "class") ?? "",
|
||||
attrOf(node, "aria-label") ?? "",
|
||||
attrOf(node, "alt") ?? ""
|
||||
].join(" "))
|
||||
.join(" ");
|
||||
return /a-icon-prime|prime-logo|amazon\s+prime|\bprime\b/i.test(attributeText);
|
||||
}
|
||||
|
||||
function firstText(card: HTMLElement, selectors: string[]): string {
|
||||
for (const selector of selectors) {
|
||||
const value = textOf(card.querySelector(selector));
|
||||
@@ -111,7 +122,7 @@ export function extractSearchPage(html: string, currentUrl: string): SearchPageE
|
||||
const ariaText = card.querySelectorAll("[aria-label]")
|
||||
.map((node) => attrOf(node, "aria-label") ?? "")
|
||||
.join(" ");
|
||||
const delivery = deliveryFromText(allText);
|
||||
const delivery = deliveryFromText(allText, hasPrimeSignal(card));
|
||||
const product: ProductSearchResult = {
|
||||
asin,
|
||||
title,
|
||||
|
||||
@@ -15,11 +15,14 @@ export interface ProductFilters {
|
||||
reviewCountComparison?: "gt" | "gte";
|
||||
maxPrice?: number;
|
||||
maxUnitPrice?: number;
|
||||
minWidthInches?: number;
|
||||
widthComparison?: "gt" | "gte";
|
||||
includeKeywords: string[];
|
||||
excludeKeywords: string[];
|
||||
requirePrime?: boolean;
|
||||
requireFreeDelivery?: boolean;
|
||||
deliveryBy?: string;
|
||||
sortBy?: "relevance" | "price";
|
||||
}
|
||||
|
||||
export interface ProductSearchResult {
|
||||
|
||||
@@ -43,6 +43,13 @@ describe("amazon-shopping CLI", () => {
|
||||
"200",
|
||||
"--max-unit-price",
|
||||
"4",
|
||||
"--min-width",
|
||||
"77",
|
||||
"--require-prime",
|
||||
"--delivery-by",
|
||||
"tomorrow",
|
||||
"--sort-by",
|
||||
"price",
|
||||
"--max-search-pages",
|
||||
"3",
|
||||
"--skip-details",
|
||||
@@ -53,6 +60,10 @@ describe("amazon-shopping CLI", () => {
|
||||
assert.equal(request.filters.minRating, 4.5);
|
||||
assert.equal(request.filters.minReviews, 200);
|
||||
assert.equal(request.filters.maxUnitPrice, 4);
|
||||
assert.equal(request.filters.minWidthInches, 77);
|
||||
assert.equal(request.filters.requirePrime, true);
|
||||
assert.equal(request.filters.deliveryBy, "tomorrow");
|
||||
assert.equal(request.filters.sortBy, "price");
|
||||
assert.equal(request.maxSearchPages, 3);
|
||||
assert.equal(request.skipDetails, true);
|
||||
assert.equal(request.dryRun, true);
|
||||
@@ -105,6 +116,13 @@ describe("amazon-shopping CLI", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported sort modes", () => {
|
||||
assert.throws(
|
||||
() => parseCliRequest(["usb c cable", "--sort-by", "rating"]),
|
||||
/sort-by must be either price or relevance/
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the Amazon search URL without live network access", () => {
|
||||
assert.equal(
|
||||
buildSearchUrl("100w led bulbs"),
|
||||
|
||||
@@ -74,4 +74,45 @@ describe("extractDetailPage", () => {
|
||||
assert.equal(details.availability, "In Stock");
|
||||
assert.deepEqual(details.specs, [{ name: "Wattage", value: "15 watts" }]);
|
||||
});
|
||||
|
||||
it("preserves a search-card Prime signal when detail delivery text omits Prime", () => {
|
||||
const details = extractDetailPage(`
|
||||
<h1 id="productTitle">Prime Sofa Bed</h1>
|
||||
<div id="mir-layout-DELIVERY_BLOCK-slot-PRIMARY_DELIVERY_MESSAGE_LARGE">FREE delivery Tomorrow. Details</div>
|
||||
<table>
|
||||
<tr><td>Product Dimensions</td><td>35"D x 83"W x 31"H</td></tr>
|
||||
</table>
|
||||
`, {
|
||||
asin: "B0PRIME123",
|
||||
title: "Prime Sofa Bed",
|
||||
url: "https://www.amazon.com/dp/B0PRIME123",
|
||||
delivery: { display: "FREE delivery Tomorrow", free: true, prime: true },
|
||||
specs: [],
|
||||
bullets: [],
|
||||
matchedFilters: [],
|
||||
missingFields: [],
|
||||
extractionNotes: []
|
||||
});
|
||||
|
||||
assert.equal(details.delivery?.prime, true);
|
||||
assert.equal(details.delivery?.free, true);
|
||||
});
|
||||
|
||||
it("does not treat Prime in a detail title as Prime delivery", () => {
|
||||
const details = extractDetailPage(`
|
||||
<h1 id="productTitle">Prime Sofa Bed</h1>
|
||||
<div id="mir-layout-DELIVERY_BLOCK-slot-PRIMARY_DELIVERY_MESSAGE_LARGE">FREE delivery Tomorrow. Details</div>
|
||||
`, {
|
||||
asin: "B0TITLE123",
|
||||
title: "Prime Sofa Bed",
|
||||
url: "https://www.amazon.com/dp/B0TITLE123",
|
||||
specs: [],
|
||||
bullets: [],
|
||||
matchedFilters: [],
|
||||
missingFields: [],
|
||||
extractionNotes: []
|
||||
});
|
||||
|
||||
assert.equal(details.delivery?.prime, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,4 +72,55 @@ describe("applyFiltersAndLimit", () => {
|
||||
|
||||
assert.deepEqual(result.results.map((item) => item.asin), ["B0DUP0001", "B0UNIQUE1"]);
|
||||
});
|
||||
|
||||
it("applies width, Prime, and delivery-by filters", () => {
|
||||
const result = applyFiltersAndLimit([
|
||||
product({
|
||||
asin: "B0MATCH001",
|
||||
rating: 4.3,
|
||||
reviewCount: 250,
|
||||
price: { amount: 399, currency: "USD", display: "$399.00" },
|
||||
delivery: { display: "FREE delivery Tomorrow", free: true, prime: true },
|
||||
specs: [{ name: "Product Dimensions", value: "35\"D x 83\"W x 31\"H" }]
|
||||
}),
|
||||
product({
|
||||
asin: "B0NOPRIME1",
|
||||
rating: 4.5,
|
||||
reviewCount: 300,
|
||||
delivery: { display: "FREE delivery Tomorrow", free: true, prime: false },
|
||||
specs: [{ name: "Product Dimensions", value: "35\"D x 84\"W x 31\"H" }]
|
||||
}),
|
||||
product({
|
||||
asin: "B0NARROW01",
|
||||
rating: 4.6,
|
||||
reviewCount: 300,
|
||||
delivery: { display: "FREE delivery Tomorrow", free: true, prime: true },
|
||||
specs: [{ name: "Product Dimensions", value: "35\"D x 65\"W x 31\"H" }]
|
||||
})
|
||||
], {
|
||||
includeKeywords: [],
|
||||
excludeKeywords: [],
|
||||
minRating: 4,
|
||||
minReviews: 200,
|
||||
minWidthInches: 77,
|
||||
requirePrime: true,
|
||||
deliveryBy: "tomorrow"
|
||||
}, 10);
|
||||
|
||||
assert.deepEqual(result.results.map((item) => item.asin), ["B0MATCH001"]);
|
||||
assert.match(result.filteredOutReasons["B0NOPRIME1"]?.join(" ") ?? "", /Prime delivery not verified/);
|
||||
assert.match(result.filteredOutReasons["B0NARROW01"]?.join(" ") ?? "", /width 65/);
|
||||
assert.ok(result.results[0]?.matchedFilters.includes("width >= 77 inches"));
|
||||
assert.ok(result.results[0]?.matchedFilters.includes("Prime delivery"));
|
||||
assert.ok(result.results[0]?.matchedFilters.includes("delivery by tomorrow"));
|
||||
});
|
||||
|
||||
it("sorts by price when requested", () => {
|
||||
const result = applyFiltersAndLimit([
|
||||
product({ asin: "B0EXPENSIV", rating: 4.9, reviewCount: 1000, price: { amount: 500, currency: "USD", display: "$500.00" } }),
|
||||
product({ asin: "B0CHEAPER1", rating: 4.1, reviewCount: 300, price: { amount: 200, currency: "USD", display: "$200.00" } })
|
||||
], { includeKeywords: [], excludeKeywords: [], sortBy: "price" }, 10);
|
||||
|
||||
assert.deepEqual(result.results.map((item) => item.asin), ["B0CHEAPER1", "B0EXPENSIV"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { extractWidthInches, formatWidthInches } from "../src/product-metrics.js";
|
||||
import type { ProductSearchResult } from "../src/types.js";
|
||||
|
||||
function product(overrides: Partial<ProductSearchResult>): ProductSearchResult {
|
||||
return {
|
||||
asin: "B0WIDTH001",
|
||||
title: "Base Product",
|
||||
url: "https://www.amazon.com/dp/B0WIDTH001",
|
||||
specs: [],
|
||||
bullets: [],
|
||||
matchedFilters: [],
|
||||
missingFields: [],
|
||||
extractionNotes: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("product metrics", () => {
|
||||
it("extracts explicit W dimensions from overall product specs", () => {
|
||||
const width = extractWidthInches(product({
|
||||
specs: [{ name: "Product Dimensions", value: "35\"D x 83.4\"W x 31\"H" }]
|
||||
}));
|
||||
|
||||
assert.equal(width, 83.4);
|
||||
});
|
||||
|
||||
it("uses dimension order labels when W is not repeated in the value", () => {
|
||||
const width = extractWidthInches(product({
|
||||
specs: [{ name: "Item Dimensions D x W x H", value: "35 x 108 x 31 inches" }]
|
||||
}));
|
||||
|
||||
assert.equal(width, 108);
|
||||
});
|
||||
|
||||
it("ignores non-overall width specs before falling back to title width", () => {
|
||||
const width = extractWidthInches(product({
|
||||
title: "83 Inch Sofa Bed",
|
||||
specs: [
|
||||
{ name: "Seat Interior Width", value: "65 Inches" },
|
||||
{ name: "Arm Width", value: "5 Inches" },
|
||||
{ name: "Minimum Required Door Width", value: "72 Inches" }
|
||||
]
|
||||
}));
|
||||
|
||||
assert.equal(width, 83);
|
||||
});
|
||||
|
||||
it("formats unknown and decimal widths", () => {
|
||||
assert.equal(formatWidthInches(undefined), "unknown");
|
||||
assert.equal(formatWidthInches(83.4), "83.4\"");
|
||||
assert.equal(formatWidthInches(108), "108\"");
|
||||
});
|
||||
});
|
||||
@@ -42,4 +42,27 @@ describe("parseNaturalLanguageRequest", () => {
|
||||
assert.equal(parsed.limit, 5);
|
||||
assert.equal(parsed.filters.maxPrice, 30);
|
||||
});
|
||||
|
||||
it("extracts sofa width, Prime, and delivery urgency filters", () => {
|
||||
const parsed = parseNaturalLanguageRequest(
|
||||
"sofa bed of 77inches or wider in width, review score of 4 stars and higher, 200+ reviews and shipped with prime, color beige if possible, delivery by tomorrow"
|
||||
);
|
||||
|
||||
assert.equal(parsed.query, "sofa bed color beige if possible");
|
||||
assert.equal(parsed.filters.minWidthInches, 77);
|
||||
assert.equal(parsed.filters.minRating, 4);
|
||||
assert.equal(parsed.filters.ratingComparison, "gte");
|
||||
assert.equal(parsed.filters.minReviews, 200);
|
||||
assert.equal(parsed.filters.reviewCountComparison, "gte");
|
||||
assert.equal(parsed.filters.requirePrime, true);
|
||||
assert.equal(parsed.filters.deliveryBy, "tomorrow");
|
||||
});
|
||||
|
||||
it("extracts overnight delivery requests", () => {
|
||||
const parsed = parseNaturalLanguageRequest("queen sleeper sofa with overnight shipping and Prime");
|
||||
|
||||
assert.equal(parsed.query, "queen sleeper sofa");
|
||||
assert.equal(parsed.filters.requirePrime, true);
|
||||
assert.equal(parsed.filters.deliveryBy, "overnight");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,4 +52,46 @@ describe("report", () => {
|
||||
assert.match(markdown, /price missing/);
|
||||
assert.match(markdown, /https:\/\/www\.amazon\.com\/dp\/B0TEST0001/);
|
||||
});
|
||||
|
||||
it("creates a table template with constraint status markers", () => {
|
||||
const markdown = createMarkdownReport(createResponse({
|
||||
query: "sofa bed beige",
|
||||
filters: {
|
||||
includeKeywords: [],
|
||||
excludeKeywords: [],
|
||||
minRating: 4,
|
||||
minReviews: 200,
|
||||
minWidthInches: 77,
|
||||
requirePrime: true,
|
||||
deliveryBy: "tomorrow"
|
||||
},
|
||||
limit: 10,
|
||||
maxSearchPages: 2,
|
||||
filteredOutCount: 3,
|
||||
warnings: [],
|
||||
now: () => new Date("2026-04-15T00:00:00.000Z"),
|
||||
results: [{
|
||||
asin: "B0SOFABED1",
|
||||
title: "HONBAY Modular Sectional Sleeper",
|
||||
url: "https://www.amazon.com/dp/B0SOFABED1",
|
||||
price: { amount: 539.99, currency: "USD", display: "$539.99" },
|
||||
rating: 4.1,
|
||||
reviewCount: 242,
|
||||
delivery: { display: "FREE delivery Tomorrow", free: true, prime: true },
|
||||
specs: [{ name: "Item Dimensions D x W x H", value: "83.4\"D x 83.4\"W x 35\"H" }],
|
||||
bullets: [],
|
||||
matchedFilters: [],
|
||||
missingFields: ["starBreakdown"],
|
||||
extractionNotes: []
|
||||
}]
|
||||
}));
|
||||
|
||||
assert.match(markdown, /## Best Matches/);
|
||||
assert.match(markdown, /\| # \| Product \| Price \| Rating \| Reviews \| Width \| Prime \| Delivery \| Link \|/);
|
||||
assert.match(markdown, /HONBAY Modular Sectional Sleeper/);
|
||||
assert.match(markdown, /83\.4" OK/);
|
||||
assert.match(markdown, /Prime OK/);
|
||||
assert.match(markdown, /Tomorrow OK/);
|
||||
assert.match(markdown, /\[Amazon\]\(https:\/\/www\.amazon\.com\/dp\/B0SOFABED1\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,4 +62,34 @@ describe("extractSearchPage", () => {
|
||||
assert.equal(extracted.products.length, 1);
|
||||
assert.equal(extracted.products[0]?.price, undefined);
|
||||
});
|
||||
|
||||
it("detects Prime badges even when visible delivery text omits the word Prime", () => {
|
||||
const extracted = extractSearchPage(`
|
||||
<div data-asin="B0PRIME123">
|
||||
<h2><a href="/dp/B0PRIME123">Prime Sofa Bed</a></h2>
|
||||
<span class="a-price"><span class="a-offscreen">$299.99</span></span>
|
||||
<span aria-label="4.4 out of 5 stars"></span>
|
||||
<span aria-label="246 ratings"></span>
|
||||
<i class="a-icon a-icon-prime" aria-label="Amazon Prime"></i>
|
||||
<span>FREE delivery Tomorrow</span>
|
||||
</div>
|
||||
`, "https://www.amazon.com/s?k=sofa+bed");
|
||||
|
||||
assert.equal(extracted.products.length, 1);
|
||||
assert.equal(extracted.products[0]?.delivery?.prime, true);
|
||||
assert.equal(extracted.products[0]?.delivery?.free, true);
|
||||
assert.match(extracted.products[0]?.delivery?.display ?? "", /Tomorrow/);
|
||||
});
|
||||
|
||||
it("does not treat Prime in a product title as Prime delivery", () => {
|
||||
const extracted = extractSearchPage(`
|
||||
<div data-asin="B0TITLE123">
|
||||
<h2><a href="/dp/B0TITLE123">Prime Sofa Bed</a></h2>
|
||||
<span>FREE delivery Tomorrow</span>
|
||||
</div>
|
||||
`, "https://www.amazon.com/s?k=sofa+bed");
|
||||
|
||||
assert.equal(extracted.products.length, 1);
|
||||
assert.equal(extracted.products[0]?.delivery?.prime, false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user