← back to Affiliate Feeds Mcp

adapters/amazon.cjs

260 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 = [];
  const kwList = (opts && opts.keywords) ? [String(opts.keywords)] : KEYWORDS;
  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 = kwList[keywordIdx % kwList.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 >= kwList.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}`);
    }
  })();
}