fix(amazon-shopping): verify prime and delivery filters
This commit is contained in:
@@ -16,7 +16,7 @@ This repository contains practical OpenClaw skills and companion integrations. I
|
|||||||
|
|
||||||
| Skill | What it does | Path |
|
| Skill | What it does | Path |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `amazon-shopping` | Search Amazon.com product results with bounded web-automation, price/unit-price filters, delivery summaries, specs, ratings, and review metadata. | `skills/amazon-shopping` |
|
| `amazon-shopping` | Search Amazon.com product results with bounded web-automation, price/unit-price/width/Prime/delivery filters, specs, ratings, review metadata, and fixed markdown tables. | `skills/amazon-shopping` |
|
||||||
| `flight-finder` | Collect structured flight-search inputs, run bounded multi-source travel searches, rank results in USD, and gate fixed-template PDF/email delivery through Luke’s sender path. | `skills/flight-finder` |
|
| `flight-finder` | Collect structured flight-search inputs, run bounded multi-source travel searches, rank results in USD, and gate fixed-template PDF/email delivery through Luke’s sender path. | `skills/flight-finder` |
|
||||||
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
||||||
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
|
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
|
||||||
|
|||||||
+32
-1
@@ -17,6 +17,7 @@ cd ~/.openclaw/workspace/skills/amazon-shopping
|
|||||||
scripts/search-products 'USB-C charger under $30' --limit 10 --json
|
scripts/search-products 'USB-C charger under $30' --limit 10 --json
|
||||||
scripts/search-products '100w led bulbs that cost less than $4 each and have over 200 reviews with a review score of more than 4.5 stars' --limit 5 --markdown
|
scripts/search-products '100w led bulbs that cost less than $4 each and have over 200 reviews with a review score of more than 4.5 stars' --limit 5 --markdown
|
||||||
scripts/search-products 'USB-C cable with over 1000 reviews and rating over 4 stars' --limit 3 --json --skip-details
|
scripts/search-products 'USB-C cable with over 1000 reviews and rating over 4 stars' --limit 3 --json --skip-details
|
||||||
|
scripts/search-products 'sofa bed of 77 inches or wider, 4 stars or higher, 200+ reviews, shipped with Prime, delivery by tomorrow, top 10 by price' --limit 10 --json --markdown
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--dry-run` to parse a request and show planned filters without navigating to Amazon:
|
Use `--dry-run` to parse a request and show planned filters without navigating to Amazon:
|
||||||
@@ -73,7 +74,7 @@ scripts/search-products 'USB-C charger under $30' --dry-run --json
|
|||||||
For a live smoke after install or update:
|
For a live smoke after install or update:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/search-products '100w led bulbs that cost less than $4 each and have over 200 reviews with a review score of more than 4.5 stars' --limit 5 --json
|
scripts/search-products '100w led bulbs that cost less than $4 each and have over 200 reviews with a review score of more than 4.5 stars' --limit 5 --json --markdown
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependency
|
## Dependency
|
||||||
@@ -86,6 +87,18 @@ Each result includes the product ASIN, title, source URL, price, unit price when
|
|||||||
|
|
||||||
Unknown or hidden fields stay unknown. The skill does not invent delivery dates, star histograms, prices, or review counts.
|
Unknown or hidden fields stay unknown. The skill does not invent delivery dates, star histograms, prices, or review counts.
|
||||||
|
|
||||||
|
Markdown output uses a fixed table intended for direct user-facing answers:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Best Matches
|
||||||
|
|
||||||
|
| # | Product | Price | Rating | Reviews | Width | Prime | Delivery | Link |
|
||||||
|
|---|---|---:|---:|---:|---:|---|---|---|
|
||||||
|
| 1 | Example Sofa Bed | $399.99 | 4.3 stars | 250 | 83" OK | Prime OK | FREE delivery Tomorrow OK | [Amazon](https://www.amazon.com/dp/ASIN) |
|
||||||
|
```
|
||||||
|
|
||||||
|
The `OK` / `NO` marker is only attached to columns that correspond to requested filters. For example, `Prime OK` means the helper found a Prime signal on the search card or detail page; `not verified NO` means the product did not pass a requested Prime filter.
|
||||||
|
|
||||||
## Filters
|
## Filters
|
||||||
|
|
||||||
Supported request filters include:
|
Supported request filters include:
|
||||||
@@ -94,11 +107,29 @@ Supported request filters include:
|
|||||||
- minimum review count
|
- minimum review count
|
||||||
- maximum product price
|
- maximum product price
|
||||||
- maximum unit price
|
- maximum unit price
|
||||||
|
- minimum width in inches
|
||||||
|
- Prime delivery
|
||||||
|
- delivery by today, tomorrow, or overnight
|
||||||
|
- sort by price
|
||||||
- result limit
|
- result limit
|
||||||
- maximum search pages
|
- maximum search pages
|
||||||
|
|
||||||
`over 200 reviews` and `more than 4.5 stars` are strict comparisons. `at least 200 reviews` and `4.5 stars or better` are inclusive comparisons.
|
`over 200 reviews` and `more than 4.5 stars` are strict comparisons. `at least 200 reviews` and `4.5 stars or better` are inclusive comparisons.
|
||||||
|
|
||||||
|
Examples of supported natural-language filters:
|
||||||
|
|
||||||
|
- `77 inches or wider`
|
||||||
|
- `shipped with Prime`
|
||||||
|
- `delivery by tomorrow`
|
||||||
|
- `overnight shipping`
|
||||||
|
- `top 10 by price`
|
||||||
|
|
||||||
|
Equivalent CLI flags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/search-products 'sofa bed beige' --min-rating 4 --min-reviews 200 --min-width 77 --require-prime --delivery-by tomorrow --sort-by price --limit 10 --markdown
|
||||||
|
```
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
This skill is for operator-directed product research, not purchasing automation.
|
This skill is for operator-directed product research, not purchasing automation.
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ node "$HOME/.openclaw/workspace/skills/web-automation/scripts/check-install.js"
|
|||||||
Run the helper from the installed skill directory:
|
Run the helper from the installed skill directory:
|
||||||
|
|
||||||
```bash
|
```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.
|
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.
|
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
|
## 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.
|
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. |
|
| `rating` | `aria-label` or visible text like `4.6 out of 5 stars` | Optional. |
|
||||||
| `reviewCount` | text near rating like `1,234 ratings` | Optional. |
|
| `reviewCount` | text near rating like `1,234 ratings` | Optional. |
|
||||||
| `delivery.display` | visible delivery promise text | Optional and ZIP/session dependent. |
|
| `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. |
|
| `isSponsored` | visible sponsored marker | Sponsored results may be included but must be labeled. |
|
||||||
|
|
||||||
## Detail Page Fields
|
## 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`.
|
- `more than 4.5 stars` means `rating > 4.5`.
|
||||||
- `4.5 stars or better` 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.
|
- `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.
|
- Missing fields must be represented as `null` or noted in `missingFields` / `extractionNotes`; never fabricate values.
|
||||||
|
|
||||||
## Official Alternatives
|
## Official Alternatives
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ Use these patterns when debugging or extending the `amazon-shopping` browser wor
|
|||||||
## Search Page
|
## Search Page
|
||||||
|
|
||||||
```text
|
```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
|
## Detail Page
|
||||||
|
|
||||||
```text
|
```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
|
## Pagination
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ Options:
|
|||||||
--min-reviews N Minimum review count
|
--min-reviews N Minimum review count
|
||||||
--max-price N Maximum displayed product price
|
--max-price N Maximum displayed product price
|
||||||
--max-unit-price N Maximum price per unit
|
--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)
|
--max-search-pages N Search result pages to scan, 1-5 (default: 2)
|
||||||
--skip-details Do not open product detail pages
|
--skip-details Do not open product detail pages
|
||||||
--dry-run Parse and print the planned request without Amazon network access
|
--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 {
|
export function parseCliRequest(argv: string[]): SearchProductsRequest {
|
||||||
const args = minimist(argv, {
|
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: [
|
string: [
|
||||||
"query",
|
"query",
|
||||||
"limit",
|
"limit",
|
||||||
@@ -75,6 +78,9 @@ export function parseCliRequest(argv: string[]): SearchProductsRequest {
|
|||||||
"min-reviews",
|
"min-reviews",
|
||||||
"max-price",
|
"max-price",
|
||||||
"max-unit-price",
|
"max-unit-price",
|
||||||
|
"min-width",
|
||||||
|
"delivery-by",
|
||||||
|
"sort-by",
|
||||||
"max-search-pages"
|
"max-search-pages"
|
||||||
],
|
],
|
||||||
alias: { h: "help", max: "limit" }
|
alias: { h: "help", max: "limit" }
|
||||||
@@ -101,10 +107,24 @@ export function parseCliRequest(argv: string[]): SearchProductsRequest {
|
|||||||
const minReviews = parsePositiveInteger(args["min-reviews"], "min-reviews");
|
const minReviews = parsePositiveInteger(args["min-reviews"], "min-reviews");
|
||||||
const maxPrice = parseNumber(args["max-price"], "max-price");
|
const maxPrice = parseNumber(args["max-price"], "max-price");
|
||||||
const maxUnitPrice = parseNumber(args["max-unit-price"], "max-unit-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 (minRating !== undefined) filters.minRating = minRating;
|
||||||
if (minReviews !== undefined) filters.minReviews = minReviews;
|
if (minReviews !== undefined) filters.minReviews = minReviews;
|
||||||
if (maxPrice !== undefined) filters.maxPrice = maxPrice;
|
if (maxPrice !== undefined) filters.maxPrice = maxPrice;
|
||||||
if (maxUnitPrice !== undefined) filters.maxUnitPrice = maxUnitPrice;
|
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 json = Boolean(args.json);
|
||||||
const markdown = Boolean(args.markdown);
|
const markdown = Boolean(args.markdown);
|
||||||
|
|||||||
@@ -76,15 +76,42 @@ function extractHistogramText(root: HTMLElement): string {
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function deliveryFromText(text: string): DeliverySummary | undefined {
|
function deliveryFromText(text: string, primeSignal = false): DeliverySummary | undefined {
|
||||||
const display = text.replace(/\s+/g, " ").trim();
|
const display = text.replace(/\s+/g, " ").trim();
|
||||||
if (!display) {
|
if (!display) {
|
||||||
return undefined;
|
return primeSignal ? { display: "Prime delivery available", prime: true } : undefined;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
display,
|
display,
|
||||||
free: /\bfree\b/i.test(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,
|
price: parseMoney(priceText) ?? base.price,
|
||||||
rating: parseRating(ratingText) ?? base.rating,
|
rating: parseRating(ratingText) ?? base.rating,
|
||||||
reviewCount: parseReviewCount(reviewText) ?? base.reviewCount,
|
reviewCount: parseReviewCount(reviewText) ?? base.reviewCount,
|
||||||
delivery: deliveryFromText(deliveryText) ?? base.delivery,
|
delivery: mergeDelivery(deliveryFromText(deliveryText, hasPrimeSignal(root)), base.delivery),
|
||||||
availability: availability || base.availability,
|
availability: availability || base.availability,
|
||||||
seller: seller || base.seller,
|
seller: seller || base.seller,
|
||||||
bullets: extractBullets(root),
|
bullets: extractBullets(root),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FilteredProducts, ProductFilters, ProductSearchResult } from "./types.js";
|
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 {
|
function passesMin(value: number | undefined, threshold: number, comparison: "gt" | "gte" | undefined): boolean {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
@@ -29,16 +30,48 @@ function filterReasons(product: ProductSearchResult, filters: ProductFilters): s
|
|||||||
reasons.push(`unit price ${product.unitPrice.display} above filter`);
|
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) {
|
if (filters.requirePrime && !product.delivery?.prime) {
|
||||||
reasons.push("Prime delivery not verified");
|
reasons.push("Prime delivery not verified");
|
||||||
}
|
}
|
||||||
if (filters.requireFreeDelivery && !product.delivery?.free) {
|
if (filters.requireFreeDelivery && !product.delivery?.free) {
|
||||||
reasons.push("free delivery not verified");
|
reasons.push("free delivery not verified");
|
||||||
}
|
}
|
||||||
|
if (filters.deliveryBy && !deliveryMatches(product.delivery?.display, filters.deliveryBy)) {
|
||||||
|
reasons.push(`${filters.deliveryBy} delivery not verified`);
|
||||||
|
}
|
||||||
return reasons;
|
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);
|
const ratingDiff = (b.rating ?? -1) - (a.rating ?? -1);
|
||||||
if (ratingDiff !== 0) return ratingDiff;
|
if (ratingDiff !== 0) return ratingDiff;
|
||||||
const reviewDiff = (b.reviewCount ?? -1) - (a.reviewCount ?? -1);
|
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.minRating !== undefined ? [`rating ${filters.ratingComparison ?? "gte"} ${filters.minRating}`] : []),
|
||||||
...(filters.minReviews !== undefined ? [`reviews ${filters.reviewCountComparison ?? "gte"} ${filters.minReviews}`] : []),
|
...(filters.minReviews !== undefined ? [`reviews ${filters.reviewCountComparison ?? "gte"} ${filters.minReviews}`] : []),
|
||||||
...(filters.maxPrice !== undefined ? [`price <= ${filters.maxPrice}`] : []),
|
...(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 {
|
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,
|
filteredOutCount: uniqueProducts.size - passing.length,
|
||||||
filteredOutReasons
|
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 {
|
function cleanQuery(text: string): string {
|
||||||
return text
|
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(/\b(?:that|and|with|have)\b/gi, " ")
|
||||||
|
.replace(/[,\s]+/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.replace(/\s+(and|or|a)$/i, "")
|
.replace(/\s+(and|or|a)$/i, "")
|
||||||
.trim();
|
.trim();
|
||||||
@@ -29,6 +34,38 @@ export function parseNaturalLanguageRequest(input: string): ParsedNaturalLanguag
|
|||||||
remaining = removeMatched(remaining, limitMatch);
|
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);
|
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) {
|
if (unitPriceMatch) {
|
||||||
filters.maxUnitPrice = Number(unitPriceMatch[1]);
|
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 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;
|
const reviewMatch = exclusiveReviews ?? inclusiveReviews;
|
||||||
if (reviewMatch) {
|
if (reviewMatch) {
|
||||||
filters.minReviews = Number(reviewMatch[1].replace(/,/g, ""));
|
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 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);
|
?? remaining.match(/\b(?:at least|minimum|min\.?)\s+([0-5](?:\.[0-9])?)\s*(?:stars?|rating)?\b/i);
|
||||||
const ratingMatch = exclusiveRating ?? inclusiveRating;
|
const ratingMatch = exclusiveRating ?? inclusiveRating;
|
||||||
if (ratingMatch) {
|
if (ratingMatch) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProductFilters, ProductSearchResult, SearchProductsResponse } from "./types.js";
|
import type { ProductFilters, ProductSearchResult, SearchProductsResponse } from "./types.js";
|
||||||
|
import { extractWidthInches, formatWidthInches } from "./product-metrics.js";
|
||||||
|
|
||||||
export interface ResponseInput {
|
export interface ResponseInput {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -33,25 +34,87 @@ function formatFilters(filters: ProductFilters): string {
|
|||||||
filters.minRating !== undefined ? `rating ${filters.ratingComparison ?? "gte"} ${filters.minRating}` : "",
|
filters.minRating !== undefined ? `rating ${filters.ratingComparison ?? "gte"} ${filters.minRating}` : "",
|
||||||
filters.minReviews !== undefined ? `reviews ${filters.reviewCountComparison ?? "gte"} ${filters.minReviews}` : "",
|
filters.minReviews !== undefined ? `reviews ${filters.reviewCountComparison ?? "gte"} ${filters.minReviews}` : "",
|
||||||
filters.maxPrice !== undefined ? `price <= $${filters.maxPrice}` : "",
|
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);
|
].filter(Boolean);
|
||||||
return parts.length > 0 ? parts.join(", ") : "none";
|
return parts.length > 0 ? parts.join(", ") : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProduct(product: ProductSearchResult, index: number): string {
|
function escapeCell(value: string): string {
|
||||||
const specs = product.specs.slice(0, 3).map((spec) => `${spec.name}: ${spec.value}`).join("; ");
|
return value.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
||||||
const lines = [
|
}
|
||||||
`${index}. ${product.title}`,
|
|
||||||
` Link: ${product.url}`,
|
function marker(passes: boolean | undefined, enabled: boolean): string {
|
||||||
` Price: ${product.price?.display ?? "unknown"}${product.unitPrice ? ` (${product.unitPrice.display})` : ""}`,
|
if (!enabled) {
|
||||||
` Rating: ${product.rating ?? "unknown"} stars; reviews: ${product.reviewCount ?? "unknown"}`,
|
return "";
|
||||||
` Delivery: ${product.delivery?.display ?? "unknown"}`,
|
}
|
||||||
specs ? ` Specs: ${specs}` : "",
|
return passes ? " OK" : " NO";
|
||||||
product.bullets[0] ? ` Notes: ${product.bullets.slice(0, 2).join(" ")}` : "",
|
}
|
||||||
product.missingFields.length > 0 ? ` Missing: ${product.missingFields.join(", ")}` : "",
|
|
||||||
product.isSponsored ? " Sponsored: yes" : ""
|
function widthCell(product: ProductSearchResult, filters: ProductFilters): string {
|
||||||
].filter(Boolean);
|
const width = extractWidthInches(product);
|
||||||
return lines.join("\n");
|
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 {
|
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})`,
|
`Results returned: ${response.results.length} (filtered out: ${response.filteredOutCount})`,
|
||||||
response.warnings.length > 0 ? `Warnings: ${response.warnings.join("; ")}` : "",
|
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 !== "");
|
].filter((line) => line !== "");
|
||||||
return `${lines.join("\n")}\n`;
|
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);
|
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 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) {
|
if (!deliveryMatch) {
|
||||||
return undefined;
|
return primeSignal ? { display: "Prime delivery available", prime: true } : undefined;
|
||||||
}
|
}
|
||||||
const display = deliveryMatch[1].trim();
|
const display = deliveryMatch[1].trim();
|
||||||
return {
|
return {
|
||||||
display,
|
display,
|
||||||
free: /\bfree\b/i.test(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 {
|
function firstText(card: HTMLElement, selectors: string[]): string {
|
||||||
for (const selector of selectors) {
|
for (const selector of selectors) {
|
||||||
const value = textOf(card.querySelector(selector));
|
const value = textOf(card.querySelector(selector));
|
||||||
@@ -111,7 +122,7 @@ export function extractSearchPage(html: string, currentUrl: string): SearchPageE
|
|||||||
const ariaText = card.querySelectorAll("[aria-label]")
|
const ariaText = card.querySelectorAll("[aria-label]")
|
||||||
.map((node) => attrOf(node, "aria-label") ?? "")
|
.map((node) => attrOf(node, "aria-label") ?? "")
|
||||||
.join(" ");
|
.join(" ");
|
||||||
const delivery = deliveryFromText(allText);
|
const delivery = deliveryFromText(allText, hasPrimeSignal(card));
|
||||||
const product: ProductSearchResult = {
|
const product: ProductSearchResult = {
|
||||||
asin,
|
asin,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ export interface ProductFilters {
|
|||||||
reviewCountComparison?: "gt" | "gte";
|
reviewCountComparison?: "gt" | "gte";
|
||||||
maxPrice?: number;
|
maxPrice?: number;
|
||||||
maxUnitPrice?: number;
|
maxUnitPrice?: number;
|
||||||
|
minWidthInches?: number;
|
||||||
|
widthComparison?: "gt" | "gte";
|
||||||
includeKeywords: string[];
|
includeKeywords: string[];
|
||||||
excludeKeywords: string[];
|
excludeKeywords: string[];
|
||||||
requirePrime?: boolean;
|
requirePrime?: boolean;
|
||||||
requireFreeDelivery?: boolean;
|
requireFreeDelivery?: boolean;
|
||||||
deliveryBy?: string;
|
deliveryBy?: string;
|
||||||
|
sortBy?: "relevance" | "price";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductSearchResult {
|
export interface ProductSearchResult {
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ describe("amazon-shopping CLI", () => {
|
|||||||
"200",
|
"200",
|
||||||
"--max-unit-price",
|
"--max-unit-price",
|
||||||
"4",
|
"4",
|
||||||
|
"--min-width",
|
||||||
|
"77",
|
||||||
|
"--require-prime",
|
||||||
|
"--delivery-by",
|
||||||
|
"tomorrow",
|
||||||
|
"--sort-by",
|
||||||
|
"price",
|
||||||
"--max-search-pages",
|
"--max-search-pages",
|
||||||
"3",
|
"3",
|
||||||
"--skip-details",
|
"--skip-details",
|
||||||
@@ -53,6 +60,10 @@ describe("amazon-shopping CLI", () => {
|
|||||||
assert.equal(request.filters.minRating, 4.5);
|
assert.equal(request.filters.minRating, 4.5);
|
||||||
assert.equal(request.filters.minReviews, 200);
|
assert.equal(request.filters.minReviews, 200);
|
||||||
assert.equal(request.filters.maxUnitPrice, 4);
|
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.maxSearchPages, 3);
|
||||||
assert.equal(request.skipDetails, true);
|
assert.equal(request.skipDetails, true);
|
||||||
assert.equal(request.dryRun, 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", () => {
|
it("builds the Amazon search URL without live network access", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildSearchUrl("100w led bulbs"),
|
buildSearchUrl("100w led bulbs"),
|
||||||
|
|||||||
@@ -74,4 +74,45 @@ describe("extractDetailPage", () => {
|
|||||||
assert.equal(details.availability, "In Stock");
|
assert.equal(details.availability, "In Stock");
|
||||||
assert.deepEqual(details.specs, [{ name: "Wattage", value: "15 watts" }]);
|
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"]);
|
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.limit, 5);
|
||||||
assert.equal(parsed.filters.maxPrice, 30);
|
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, /price missing/);
|
||||||
assert.match(markdown, /https:\/\/www\.amazon\.com\/dp\/B0TEST0001/);
|
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.length, 1);
|
||||||
assert.equal(extracted.products[0]?.price, undefined);
|
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