feat(amazon-shopping): scaffold amazon product search skill

This commit is contained in:
2026-04-15 18:24:13 -05:00
parent 26a968797c
commit 8ad532545d
14 changed files with 1234 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
import minimist from "minimist";
import { fileURLToPath } from "node:url";
import type { ProductFilters, SearchProductsRequest, SearchProductsResponse } from "./types.js";
export interface CliDeps {
stdout: Pick<NodeJS.WriteStream, "write">;
stderr: Pick<NodeJS.WriteStream, "write">;
now?: () => Date;
searchProducts?: (request: SearchProductsRequest) => Promise<SearchProductsResponse>;
}
export function usage(): string {
return `amazon-shopping
Usage:
scripts/search-products "<product request>" [options]
scripts/search-products --query "<product request>" [options]
Options:
--json Print JSON output
--markdown Print markdown output
--limit N Maximum products to return (default: 15)
--allow-large-limit Permit limits above 30
--min-rating N Minimum rating score
--min-reviews N Minimum review count
--max-price N Maximum displayed product price
--max-unit-price N Maximum price per unit
--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
--help Show this help
`;
}
function parsePositiveInteger(value: unknown, name: string): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${name} must be an integer greater than 0`);
}
return parsed;
}
function parseNumber(value: unknown, name: string): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${name} must be a number`);
}
return parsed;
}
export function buildSearchUrl(query: string): string {
return `https://www.amazon.com/s?k=${encodeURIComponent(query)}`;
}
export function parseCliRequest(argv: string[]): SearchProductsRequest {
const args = minimist(argv, {
boolean: ["help", "json", "markdown", "allow-large-limit", "dry-run", "skip-details"],
string: [
"query",
"limit",
"min-rating",
"min-reviews",
"max-price",
"max-unit-price",
"max-search-pages"
],
alias: { h: "help" }
});
const query = String(args.query ?? args._.join(" ")).trim();
if (!query) {
throw new Error("A product query is required");
}
const limit = parsePositiveInteger(args.limit, "limit") ?? 15;
if (limit > 30 && !args["allow-large-limit"]) {
throw new Error("Requested limits above 30 require --allow-large-limit or a batched run");
}
const maxSearchPages = parsePositiveInteger(args["max-search-pages"], "max-search-pages") ?? 2;
if (maxSearchPages > 5) {
throw new Error("max-search-pages must be 5 or less");
}
const filters: ProductFilters = {
includeKeywords: [],
excludeKeywords: []
};
const minRating = parseNumber(args["min-rating"], "min-rating");
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");
if (minRating !== undefined) filters.minRating = minRating;
if (minReviews !== undefined) filters.minReviews = minReviews;
if (maxPrice !== undefined) filters.maxPrice = maxPrice;
if (maxUnitPrice !== undefined) filters.maxUnitPrice = maxUnitPrice;
const json = Boolean(args.json);
const markdown = Boolean(args.markdown);
return {
query,
filters,
limit,
maxSearchPages,
skipDetails: Boolean(args["skip-details"]),
dryRun: Boolean(args["dry-run"]),
output: json && markdown ? "both" : markdown ? "markdown" : "json"
};
}
function createDryRunResponse(request: SearchProductsRequest, now: () => Date): SearchProductsResponse {
return {
query: request.query,
filters: request.filters,
limit: request.limit,
maxSearchPages: request.maxSearchPages,
results: [],
filteredOutCount: 0,
warnings: [`Dry run only. Planned search URL: ${buildSearchUrl(request.query)}`],
source: {
site: "amazon.com",
scrapedAt: now().toISOString(),
automation: "web-automation/CloakBrowser"
}
};
}
async function defaultSearchProducts(request: SearchProductsRequest, deps: CliDeps): Promise<SearchProductsResponse> {
if (request.dryRun) {
return createDryRunResponse(request, deps.now ?? (() => new Date()));
}
throw new Error("Live Amazon search is not implemented yet. Use --dry-run until browser orchestration is installed.");
}
export async function runCli(
argv: string[],
deps: CliDeps = { stdout: process.stdout, stderr: process.stderr }
): Promise<number> {
const rawArgs = minimist(argv, { boolean: ["help"], alias: { h: "help" } });
if (rawArgs.help || argv.length === 0) {
deps.stdout.write(usage());
return 0;
}
try {
const request = parseCliRequest(argv);
const response = deps.searchProducts
? await deps.searchProducts(request)
: await defaultSearchProducts(request, deps);
deps.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
return 0;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
deps.stderr.write(`${message}\n`);
return 1;
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
runCli(process.argv.slice(2)).then((code) => {
process.exitCode = code;
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}
+84
View File
@@ -0,0 +1,84 @@
export interface SearchProductsRequest {
query: string;
filters: ProductFilters;
limit: number;
maxSearchPages: number;
skipDetails: boolean;
dryRun: boolean;
output: "json" | "markdown" | "both";
}
export interface ProductFilters {
minRating?: number;
minReviews?: number;
maxPrice?: number;
maxUnitPrice?: number;
includeKeywords: string[];
excludeKeywords: string[];
requirePrime?: boolean;
requireFreeDelivery?: boolean;
deliveryBy?: string;
}
export interface ProductSearchResult {
asin: string;
title: string;
url: string;
imageUrl?: string;
price?: MoneyValue;
unitPrice?: MoneyValue;
rating?: number;
reviewCount?: number;
starBreakdown?: StarBreakdown;
delivery?: DeliverySummary;
specs: ProductSpec[];
bullets: string[];
seller?: string;
isSponsored?: boolean;
availability?: string;
matchedFilters: string[];
missingFields: string[];
extractionNotes: string[];
}
export interface MoneyValue {
amount: number;
currency: "USD";
display: string;
}
export interface DeliverySummary {
display: string;
prime?: boolean;
free?: boolean;
fastestDate?: string;
}
export interface StarBreakdown {
five?: number;
four?: number;
three?: number;
two?: number;
one?: number;
basis: "percent" | "count";
}
export interface ProductSpec {
name: string;
value: string;
}
export interface SearchProductsResponse {
query: string;
filters: ProductFilters;
limit: number;
maxSearchPages: number;
results: ProductSearchResult[];
filteredOutCount: number;
warnings: string[];
source: {
site: "amazon.com";
scrapedAt: string;
automation: "web-automation/CloakBrowser";
};
}