feat(amazon-shopping): scaffold amazon product search skill
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user