#!/usr/bin/env node import minimist from "minimist"; import { fileURLToPath } from "node:url"; import { searchProducts } from "./browser.js"; import { parseNaturalLanguageRequest } from "./query-parser.js"; import { createMarkdownReport } from "./report.js"; import type { ProductFilters, SearchProductsRequest, SearchProductsResponse } from "./types.js"; export interface CliDeps { stdout: Pick; stderr: Pick; now?: () => Date; searchProducts?: (request: SearchProductsRequest) => Promise; } export function usage(): string { return `amazon-shopping Usage: scripts/search-products "" [options] scripts/search-products --query "" [options] Options: --json Print JSON output --markdown Print markdown output --limit N, --max 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 --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 --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", "require-prime"], string: [ "query", "limit", "max", "min-rating", "min-reviews", "max-price", "max-unit-price", "min-width", "delivery-by", "sort-by", "max-search-pages" ], alias: { h: "help", max: "limit" } }); const rawQuery = String(args.query ?? args._.join(" ")).trim(); if (!rawQuery) { throw new Error("A product query is required"); } const natural = parseNaturalLanguageRequest(rawQuery); const limit = parsePositiveInteger(args.limit, "limit") ?? natural.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 = { ...natural.filters }; 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"); 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); return { query: natural.query || rawQuery, 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 { if (request.dryRun) { return createDryRunResponse(request, deps.now ?? (() => new Date())); } return searchProducts(request, { now: deps.now }); } function writeResponse(response: SearchProductsResponse, output: SearchProductsRequest["output"], deps: CliDeps): void { if (output === "markdown") { deps.stdout.write(createMarkdownReport(response)); return; } if (output === "both") { deps.stdout.write(`${JSON.stringify(response, null, 2)}\n\n${createMarkdownReport(response)}`); return; } deps.stdout.write(`${JSON.stringify(response, null, 2)}\n`); } export async function runCli( argv: string[], deps: CliDeps = { stdout: process.stdout, stderr: process.stderr } ): Promise { 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); writeResponse(response, request.output, deps); 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; }); }