diff --git a/README.md b/README.md index b835a2d..695a11d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This repository contains practical OpenClaw skills and companion integrations. I | 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` | | `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` | diff --git a/docs/amazon-shopping.md b/docs/amazon-shopping.md index 7580e62..f1c4f12 100644 --- a/docs/amazon-shopping.md +++ b/docs/amazon-shopping.md @@ -17,6 +17,7 @@ cd ~/.openclaw/workspace/skills/amazon-shopping 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 '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: @@ -73,7 +74,7 @@ scripts/search-products 'USB-C charger under $30' --dry-run --json For a live smoke after install or update: ```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 @@ -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. +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 Supported request filters include: @@ -94,11 +107,29 @@ Supported request filters include: - minimum review count - maximum product price - maximum unit price +- minimum width in inches +- Prime delivery +- delivery by today, tomorrow, or overnight +- sort by price - result limit - 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. +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 This skill is for operator-directed product research, not purchasing automation. diff --git a/skills/amazon-shopping/SKILL.md b/skills/amazon-shopping/SKILL.md index 8b7819b..03f1d62 100644 --- a/skills/amazon-shopping/SKILL.md +++ b/skills/amazon-shopping/SKILL.md @@ -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" '' --json +"$HOME/.openclaw/workspace/skills/amazon-shopping/scripts/search-products" '' --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. diff --git a/skills/amazon-shopping/references/amazon-data-map.md b/skills/amazon-shopping/references/amazon-data-map.md index a10f36a..9ae09cf 100644 --- a/skills/amazon-shopping/references/amazon-data-map.md +++ b/skills/amazon-shopping/references/amazon-data-map.md @@ -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/` or `/gp/product/ 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 diff --git a/skills/amazon-shopping/references/web-automation-prompts.md b/skills/amazon-shopping/references/web-automation-prompts.md index 6b05b1d..771616d 100644 --- a/skills/amazon-shopping/references/web-automation-prompts.md +++ b/skills/amazon-shopping/references/web-automation-prompts.md @@ -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=. 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=. 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/ or /gp/product/, 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/ or /gp/product/, 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 diff --git a/skills/amazon-shopping/src/cli.ts b/skills/amazon-shopping/src/cli.ts index 1cf39c0..45515a1 100644 --- a/skills/amazon-shopping/src/cli.ts +++ b/skills/amazon-shopping/src/cli.ts @@ -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); diff --git a/skills/amazon-shopping/src/detail-page.ts b/skills/amazon-shopping/src/detail-page.ts index bc66726..8cf9cd2 100644 --- a/skills/amazon-shopping/src/detail-page.ts +++ b/skills/amazon-shopping/src/detail-page.ts @@ -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), diff --git a/skills/amazon-shopping/src/filters.ts b/skills/amazon-shopping/src/filters.ts index 00a60bb..56f23ed 100644 --- a/skills/amazon-shopping/src/filters.ts +++ b/skills/amazon-shopping/src/filters.ts @@ -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 }; diff --git a/skills/amazon-shopping/src/product-metrics.ts b/skills/amazon-shopping/src/product-metrics.ts new file mode 100644 index 0000000..403c034 --- /dev/null +++ b/skills/amazon-shopping/src/product-metrics.ts @@ -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)}"`; +} diff --git a/skills/amazon-shopping/src/query-parser.ts b/skills/amazon-shopping/src/query-parser.ts index ed9be69..024bc36 100644 --- a/skills/amazon-shopping/src/query-parser.ts +++ b/skills/amazon-shopping/src/query-parser.ts @@ -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) { diff --git a/skills/amazon-shopping/src/report.ts b/skills/amazon-shopping/src/report.ts index 9c7f8b5..996ded0 100644 --- a/skills/amazon-shopping/src/report.ts +++ b/skills/amazon-shopping/src/report.ts @@ -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`; } diff --git a/skills/amazon-shopping/src/search-page.ts b/skills/amazon-shopping/src/search-page.ts index ee0ece8..04ac04c 100644 --- a/skills/amazon-shopping/src/search-page.ts +++ b/skills/amazon-shopping/src/search-page.ts @@ -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, diff --git a/skills/amazon-shopping/src/types.ts b/skills/amazon-shopping/src/types.ts index 14eff23..4313436 100644 --- a/skills/amazon-shopping/src/types.ts +++ b/skills/amazon-shopping/src/types.ts @@ -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 { diff --git a/skills/amazon-shopping/tests/cli.test.ts b/skills/amazon-shopping/tests/cli.test.ts index c5495bc..da7fc6d 100644 --- a/skills/amazon-shopping/tests/cli.test.ts +++ b/skills/amazon-shopping/tests/cli.test.ts @@ -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"), diff --git a/skills/amazon-shopping/tests/detail-page.test.ts b/skills/amazon-shopping/tests/detail-page.test.ts index 69be12e..a7b9b5d 100644 --- a/skills/amazon-shopping/tests/detail-page.test.ts +++ b/skills/amazon-shopping/tests/detail-page.test.ts @@ -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(` +

Prime Sofa Bed

+
FREE delivery Tomorrow. Details
+ + +
Product Dimensions35"D x 83"W x 31"H
+ `, { + 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(` +

Prime Sofa Bed

+
FREE delivery Tomorrow. Details
+ `, { + asin: "B0TITLE123", + title: "Prime Sofa Bed", + url: "https://www.amazon.com/dp/B0TITLE123", + specs: [], + bullets: [], + matchedFilters: [], + missingFields: [], + extractionNotes: [] + }); + + assert.equal(details.delivery?.prime, false); + }); }); diff --git a/skills/amazon-shopping/tests/filters.test.ts b/skills/amazon-shopping/tests/filters.test.ts index 8d4a97c..a8880b1 100644 --- a/skills/amazon-shopping/tests/filters.test.ts +++ b/skills/amazon-shopping/tests/filters.test.ts @@ -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"]); + }); }); diff --git a/skills/amazon-shopping/tests/product-metrics.test.ts b/skills/amazon-shopping/tests/product-metrics.test.ts new file mode 100644 index 0000000..aca6a17 --- /dev/null +++ b/skills/amazon-shopping/tests/product-metrics.test.ts @@ -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 { + 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\""); + }); +}); diff --git a/skills/amazon-shopping/tests/query-parser.test.ts b/skills/amazon-shopping/tests/query-parser.test.ts index ecf1dc7..a1bb01f 100644 --- a/skills/amazon-shopping/tests/query-parser.test.ts +++ b/skills/amazon-shopping/tests/query-parser.test.ts @@ -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"); + }); }); diff --git a/skills/amazon-shopping/tests/report.test.ts b/skills/amazon-shopping/tests/report.test.ts index 38c8bce..c2ba312 100644 --- a/skills/amazon-shopping/tests/report.test.ts +++ b/skills/amazon-shopping/tests/report.test.ts @@ -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\)/); + }); }); diff --git a/skills/amazon-shopping/tests/search-page.test.ts b/skills/amazon-shopping/tests/search-page.test.ts index 89688cd..cdd681d 100644 --- a/skills/amazon-shopping/tests/search-page.test.ts +++ b/skills/amazon-shopping/tests/search-page.test.ts @@ -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(` +
+

Prime Sofa Bed

+ $299.99 + + + + FREE delivery Tomorrow +
+ `, "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(` +
+

Prime Sofa Bed

+ FREE delivery Tomorrow +
+ `, "https://www.amazon.com/s?k=sofa+bed"); + + assert.equal(extracted.products.length, 1); + assert.equal(extracted.products[0]?.delivery?.prime, false); + }); });