← back to Interiordesignershowroom
lib/adapters/amazon.js
259 lines
// ---------------------------------------------------------------------------
// Amazon Product Advertising API v5 (PA-API 5) adapter.
//
// WHAT PA-API IS: Amazon's official API for Amazon Associates (affiliates). It
// lets an approved associate search Amazon's catalog and read product data
// (title, brand, price, images) plus a DetailPageURL that already carries the
// associate's partner/tracking tag, so clicks are attributable and earn
// commission. Requests are signed with AWS Signature Version 4 (SigV4) and
// POSTed to the regional PA-API endpoint.
//
// REQUIRED ENV VARS:
// AMAZON_ACCESS_KEY - PA-API access key (AWS-style access key id)
// AMAZON_SECRET_KEY - PA-API secret key (used only for SigV4 signing)
// AMAZON_PARTNER_TAG - your Associates tracking id / store id (e.g. mytag-20)
// AMAZON_HOST - optional, default 'webservices.amazon.com' (US)
// AMAZON_REGION - optional, default 'us-east-1'
//
// GOTCHA (READ THIS): Amazon Associates is not a data API you can just sign up
// for. To KEEP PA-API access you must (a) have a live, real content site, and
// (b) drive >= 3 QUALIFYING SALES within 180 days of being accepted. Miss that
// window and Amazon closes the account and revokes the keys. So `enabled()`
// returning true only proves keys are configured, not that the account is in
// good standing -- PA-API can start returning 4xx the moment the account lapses.
//
// This adapter is pure Node: global fetch (Node 20+) + built-in crypto only.
// SigV4 is implemented by hand -- no aws-sdk, no paapi5 SDK, no npm deps.
// ---------------------------------------------------------------------------
const crypto = require('crypto');
const SERVICE = 'ProductAdvertisingAPI';
const OPERATION = 'SearchItems';
const PATH = '/paapi5/searchitems';
const TARGET = 'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.SearchItems';
const CONTENT_ENCODING = 'amz-1.0';
// Search terms we rotate through so the affiliate feed isn't a single keyword.
const KEYWORDS = ['furniture', 'home decor', 'lighting', 'rug', 'wall art'];
const SEARCH_INDEX = 'HomeAndKitchen';
const RESOURCES = [
'ItemInfo.Title',
'ItemInfo.ByLineInfo',
'Offers.Listings.Price',
'Images.Primary.Large',
];
const PAGE_SIZE = 10; // PA-API returns at most 10 items per page.
const INTER_PAGE_DELAY_MS = 1100; // PA-API throttles hard (~1 req/sec).
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- SigV4 helpers ---------------------------------------------------------
const sha256Hex = (data) =>
crypto.createHash('sha256').update(data, 'utf8').digest('hex');
const hmac = (key, data) =>
crypto.createHmac('sha256', key).update(data, 'utf8').digest();
// AWS4 signing key = HMAC chain over date -> region -> service -> aws4_request.
function signingKey(secret, dateStamp, region, service) {
const kDate = hmac('AWS4' + secret, dateStamp);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, service);
return hmac(kService, 'aws4_request');
}
// Build the Authorization header for one signed POST.
function buildAuthHeaders(env, host, region, payload) {
const now = new Date();
// amzDate = 20240131T120000Z ; dateStamp = 20240131
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
const dateStamp = amzDate.slice(0, 8);
const headers = {
'content-encoding': CONTENT_ENCODING,
'content-type': 'application/json; charset=utf-8',
host,
'x-amz-date': amzDate,
'x-amz-target': TARGET,
};
// Canonical request: sorted lowercase header names, ';'-joined.
const signedHeaders = Object.keys(headers).sort().join(';');
const canonicalHeaders =
Object.keys(headers)
.sort()
.map((k) => `${k}:${headers[k]}\n`)
.join('');
const payloadHash = sha256Hex(payload);
const canonicalRequest = [
'POST',
PATH,
'', // canonical query string (none)
canonicalHeaders,
signedHeaders,
payloadHash,
].join('\n');
// String to sign.
const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
sha256Hex(canonicalRequest),
].join('\n');
// Signature + Authorization header.
const key = signingKey(env.AMAZON_SECRET_KEY, dateStamp, region, SERVICE);
const signature = crypto.createHmac('sha256', key).update(stringToSign, 'utf8').digest('hex');
const authorization =
`AWS4-HMAC-SHA256 Credential=${env.AMAZON_ACCESS_KEY}/${credentialScope}, ` +
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
return { ...headers, Authorization: authorization };
}
// --- mapping ---------------------------------------------------------------
// Map one PA-API Item into our rawProduct shape. Returns null if it lacks a
// required field (external_id / title / affiliate_url).
function mapItem(item) {
const asin = item && item.ASIN;
const info = (item && item.ItemInfo) || {};
const title = info.Title && info.Title.DisplayValue;
const affiliateUrl = item && item.DetailPageURL; // already carries partner tag
if (!asin || !title || !affiliateUrl) return null;
const brand =
info.ByLineInfo && info.ByLineInfo.Brand && info.ByLineInfo.Brand.DisplayValue;
const listing =
item.Offers && Array.isArray(item.Offers.Listings) ? item.Offers.Listings[0] : null;
const price = listing && listing.Price ? listing.Price.Amount : null;
const currency = listing && listing.Price ? listing.Price.Currency : 'USD';
const imageUrl =
item.Images && item.Images.Primary && item.Images.Primary.Large
? item.Images.Primary.Large.URL
: null;
return {
advertiser: 'Amazon',
external_id: String(asin),
title,
description: null,
brand: brand || null,
category: SEARCH_INDEX,
price: typeof price === 'number' ? price : null,
sale_price: null,
currency: currency || 'USD',
image_url: imageUrl,
affiliate_url: affiliateUrl,
in_stock: !!listing, // an offer exists => treat as in stock
};
}
// --- one signed page -------------------------------------------------------
async function fetchPage(env, host, region, keywords, itemPage) {
const body = {
Keywords: keywords,
SearchIndex: SEARCH_INDEX,
ItemCount: PAGE_SIZE,
ItemPage: itemPage,
PartnerTag: env.AMAZON_PARTNER_TAG,
PartnerType: 'Associates',
Marketplace: `www.${host.replace(/^webservices\./, '')}`,
Resources: RESOURCES,
};
const payload = JSON.stringify(body);
const headers = buildAuthHeaders(env, host, region, payload);
const res = await fetch(`https://${host}${PATH}`, { method: 'POST', headers, body: payload });
if (!res.ok) {
// Throttling (429) or account issues (4xx) -- surface nothing, don't crash
// the whole ingest. Caller keeps whatever earlier pages returned.
const txt = await res.text().catch(() => '');
console.error(`[amazon] page ${itemPage} HTTP ${res.status}: ${txt.slice(0, 300)}`);
return [];
}
const json = await res.json().catch(() => ({}));
const items = (json.SearchResult && json.SearchResult.Items) || [];
return items;
}
// --- contract --------------------------------------------------------------
const network = 'amazon';
function enabled(env) {
env = env || {};
return Boolean(env.AMAZON_ACCESS_KEY && env.AMAZON_SECRET_KEY && env.AMAZON_PARTNER_TAG);
}
async function fetch_(env, opts) {
env = env || {};
if (!enabled(env)) return []; // never throw on missing creds
const limit = Math.max(1, (opts && opts.limit) || PAGE_SIZE);
const host = env.AMAZON_HOST || 'webservices.amazon.com';
const region = env.AMAZON_REGION || 'us-east-1';
const out = [];
let keywordIdx = 0;
let itemPage = 1;
// Page until we hit `limit`, cycling keywords so we don't just re-page one
// term. PA-API caps ItemPage at 10 per keyword; we bump keyword when needed.
while (out.length < limit) {
const keywords = KEYWORDS[keywordIdx % KEYWORDS.length];
let items = [];
try {
items = await fetchPage(env, host, region, keywords, itemPage);
} catch (err) {
console.error(`[amazon] fetchPage error: ${err && err.message}`);
items = [];
}
for (const it of items) {
const raw = mapItem(it);
if (raw) out.push(raw);
if (out.length >= limit) break;
}
// Advance paging. Roll to next keyword after the page cap or an empty page.
if (items.length === 0 || itemPage >= 10) {
keywordIdx += 1;
itemPage = 1;
if (keywordIdx >= KEYWORDS.length) break; // exhausted our keyword set
} else {
itemPage += 1;
}
if (out.length < limit) await sleep(INTER_PAGE_DELAY_MS); // respect ~1 req/sec
}
return out.slice(0, limit);
}
module.exports = { network, enabled, fetch: fetch_ };
// --- self-test -------------------------------------------------------------
if (require.main === module) {
(async () => {
console.log(`[amazon] enabled=${enabled(process.env)}`);
try {
const products = await fetch_(process.env, { limit: 3 });
console.log(`[amazon] fetched ${products.length} product(s)`);
if (products[0]) console.log(JSON.stringify(products[0], null, 2));
} catch (err) {
console.error(`[amazon] self-test error: ${err && err.message}`);
}
})();
}