[object Object]

← back to Interiordesignershowroom

Add 4 credential-gated feed adapters (CJ, Amazon PA-API v5, Rakuten, ShareASale) + ingest orchestrator

619c45171ebc3e90d736bd30ac3efd0ab30ab2ae · 2026-08-01 11:15:06 -0700 · Steve Abrams

- Each adapter implements the shared {network, enabled(env), fetch(env,{limit})} contract,
  maps its network's API into the unified rawProduct shape, uses only node built-ins
  (global fetch + crypto for SigV4/HMAC), and returns [] when creds absent (never throws)
- scripts/ingest.js runs enabled adapters -> normalize -> UPSERT; idempotent, cron-safe
- .env.example documents every network's required credentials
- Verified: registry loads all 4, ingest skips all gracefully with no creds

Files touched

Diff

commit 619c45171ebc3e90d736bd30ac3efd0ab30ab2ae
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 11:15:06 2026 -0700

    Add 4 credential-gated feed adapters (CJ, Amazon PA-API v5, Rakuten, ShareASale) + ingest orchestrator
    
    - Each adapter implements the shared {network, enabled(env), fetch(env,{limit})} contract,
      maps its network's API into the unified rawProduct shape, uses only node built-ins
      (global fetch + crypto for SigV4/HMAC), and returns [] when creds absent (never throws)
    - scripts/ingest.js runs enabled adapters -> normalize -> UPSERT; idempotent, cron-safe
    - .env.example documents every network's required credentials
    - Verified: registry loads all 4, ingest skips all gracefully with no creds
---
 lib/adapters/amazon.js     | 258 ++++++++++++++++++++++++++++++++++++++++
 lib/adapters/index.js      |  22 ++++
 lib/adapters/rakuten.js    | 291 +++++++++++++++++++++++++++++++++++++++++++++
 lib/adapters/shareasale.js | 235 ++++++++++++++++++++++++++++++++++++
 scripts/ingest.js          |  54 +++++++++
 5 files changed, 860 insertions(+)

diff --git a/lib/adapters/amazon.js b/lib/adapters/amazon.js
new file mode 100644
index 0000000..af2a585
--- /dev/null
+++ b/lib/adapters/amazon.js
@@ -0,0 +1,258 @@
+// ---------------------------------------------------------------------------
+// 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}`);
+    }
+  })();
+}
diff --git a/lib/adapters/index.js b/lib/adapters/index.js
new file mode 100644
index 0000000..85bd5a8
--- /dev/null
+++ b/lib/adapters/index.js
@@ -0,0 +1,22 @@
+// Adapter registry. Each adapter module implements the SAME contract:
+//
+//   module.exports = {
+//     network: 'cj',                       // one of normalize.VALID_NETWORKS
+//     enabled(env) -> boolean,             // true only when required creds are present
+//     async fetch(env, { limit }) -> rawProduct[]   // pre-normalize objects
+//   }
+//
+// A rawProduct is whatever fields lib/normalize.normalizeProduct() reads:
+//   { advertiser, external_id, title, description, brand, category,
+//     price, sale_price, currency, image_url, affiliate_url, in_stock }
+// The adapter's ONLY job is to talk to its network and map that network's
+// response into this shape. normalize.js does taxonomy + validation downstream.
+//
+// Adapters must NEVER throw on missing creds — return [] (and enabled()=false).
+
+module.exports = [
+  require('./cj'),
+  require('./amazon'),
+  require('./rakuten'),
+  require('./shareasale'),
+];
diff --git a/lib/adapters/rakuten.js b/lib/adapters/rakuten.js
new file mode 100644
index 0000000..31afc38
--- /dev/null
+++ b/lib/adapters/rakuten.js
@@ -0,0 +1,291 @@
+// ---------------------------------------------------------------------------
+// Rakuten Advertising (LinkSynergy) — Product Search adapter
+// ---------------------------------------------------------------------------
+//
+// WHAT THIS IS
+//   Rakuten Advertising (formerly LinkShare / LinkSynergy) is an affiliate
+//   marketing network. Publishers (that's us) join the network, get approved by
+//   individual advertisers/merchants, and earn commission on tracked sales.
+//   The Product Search API lets an approved publisher query the product catalogs
+//   of the merchants they've been approved for and get back tracked deep links
+//   (the "linkurl") that carry our publisher SID so commissions are attributed.
+//
+// APPROVAL REQUIRED
+//   Two layers of approval gate real data:
+//     1. Publisher approval — you must have an active Rakuten Advertising
+//        publisher account and a developer application (Client ID / Secret).
+//     2. Per-advertiser approval — the Product Search API only returns products
+//        from merchants who have approved YOUR publisher account. An unapproved
+//        or brand-new account will authenticate fine but return zero <item>s.
+//
+// REQUIRED ENV VARS
+//   RAKUTEN_CLIENT_ID      — developer application Client ID
+//   RAKUTEN_CLIENT_SECRET  — developer application Client Secret
+//   RAKUTEN_SCOPE          — your unique publisher Site ID (SID), used as the
+//                            OAuth "scope" when minting the access token
+//
+// AUTH FLOW (client_credentials)
+//   POST https://api.linksynergy.com/token
+//     Header: Authorization: Bearer base64(clientId:clientSecret)
+//     Body:   scope=<SID>&grant_type=client_credentials
+//   -> { access_token, token_type, expires_in }  (access token valid ~60 min)
+//   Then GET the Product Search API with `Authorization: Bearer <access_token>`.
+//   The Product Search response is XML (<item> elements).
+//
+// CONTRACT
+//   module.exports = { network, enabled, fetch }
+//   - never throws on missing creds (enabled()=false, fetch()=[])
+//   - loads via require() with zero env set (no top-level throws)
+// ---------------------------------------------------------------------------
+
+'use strict';
+
+const NETWORK = 'rakuten';
+
+// Endpoints (verified against developers.rakutenadvertising.com, 2026-08).
+const TOKEN_URL = 'https://api.linksynergy.com/token';
+const PRODUCT_SEARCH_URL = 'https://api.linksynergy.com/productsearch/1.0';
+
+// Default catalog query. This is a home/interior-design affiliate site, so a
+// generic "furniture" keyword gives a reasonable starter set; downstream
+// normalize.js handles taxonomy. // TODO(verify): tune keyword per site niche.
+const DEFAULT_KEYWORD = 'furniture';
+const DEFAULT_LIMIT = 20;
+
+// Module-level OAuth token cache so repeated fetch() calls in one process don't
+// re-auth on every call. Shape: { token: string, expiresAt: number(ms) }.
+let _tokenCache = null;
+// Refresh a little early so we never send an already-expired token.
+const TOKEN_SKEW_MS = 60 * 1000;
+
+// ---------------------------------------------------------------------------
+// Tiny, dependency-free XML helpers. Rakuten's Product Search response is XML;
+// we only need a handful of fields, so a defensive regex extractor is plenty —
+// no XML npm library. These are intentionally forgiving: missing tags -> null.
+// ---------------------------------------------------------------------------
+
+// Split the document into the raw inner text of every <item>...</item> block.
+function extractItems(xml) {
+  if (typeof xml !== 'string' || !xml) return [];
+  const items = [];
+  const re = /<item\b[^>]*>([\s\S]*?)<\/item>/gi;
+  let m;
+  while ((m = re.exec(xml)) !== null) {
+    items.push(m[1]);
+    if (items.length > 5000) break; // safety valve
+  }
+  return items;
+}
+
+// Decode the handful of XML/HTML entities that show up in product text.
+function decodeEntities(s) {
+  if (typeof s !== 'string') return s;
+  return s
+    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"')
+    .replace(/&#0?39;/g, "'")
+    .replace(/&apos;/g, "'")
+    .replace(/&amp;/g, '&')
+    .trim();
+}
+
+// Pull the inner text of the FIRST <tag>...</tag> inside an item block.
+// Returns null when the tag is absent or empty (defensive on purpose).
+function tagText(itemXml, tag) {
+  if (typeof itemXml !== 'string') return null;
+  const re = new RegExp('<' + tag + '\\b[^>]*>([\\s\\S]*?)<\\/' + tag + '>', 'i');
+  const m = re.exec(itemXml);
+  if (!m) return null;
+  const val = decodeEntities(m[1]);
+  return val === '' ? null : val;
+}
+
+// Parse a price-ish string into a Number, or null if not parseable.
+// Handles a currency attribute-free value like "129.99" or "$1,299.00".
+function toNumber(val) {
+  if (val == null) return null;
+  const cleaned = String(val).replace(/[^0-9.]/g, '');
+  if (cleaned === '' || cleaned === '.') return null;
+  const n = Number(cleaned);
+  return Number.isFinite(n) ? n : null;
+}
+
+// Rakuten emits <price currency="USD">129.99</price>; grab the currency attr.
+function tagAttr(itemXml, tag, attr) {
+  if (typeof itemXml !== 'string') return null;
+  const re = new RegExp('<' + tag + '\\b[^>]*\\b' + attr + '="([^"]*)"', 'i');
+  const m = re.exec(itemXml);
+  return m ? m[1] : null;
+}
+
+// ---------------------------------------------------------------------------
+// Contract methods
+// ---------------------------------------------------------------------------
+
+// True ONLY when all required Rakuten creds are present + non-empty.
+function enabled(env) {
+  const e = env || {};
+  return Boolean(e.RAKUTEN_CLIENT_ID && e.RAKUTEN_CLIENT_SECRET && e.RAKUTEN_SCOPE);
+}
+
+// Mint (or reuse a cached) OAuth access token. Returns null on any failure so
+// callers can degrade to [] instead of throwing.
+async function getAccessToken(env) {
+  const now = Date.now();
+  if (_tokenCache && _tokenCache.token && _tokenCache.expiresAt - TOKEN_SKEW_MS > now) {
+    return _tokenCache.token;
+  }
+
+  const basic = Buffer.from(
+    `${env.RAKUTEN_CLIENT_ID}:${env.RAKUTEN_CLIENT_SECRET}`
+  ).toString('base64');
+
+  const body = new URLSearchParams({
+    scope: String(env.RAKUTEN_SCOPE),
+    grant_type: 'client_credentials',
+  }).toString();
+
+  let res;
+  try {
+    res = await fetch(TOKEN_URL, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${basic}`,
+        'Content-Type': 'application/x-www-form-urlencoded',
+        Accept: 'application/json',
+      },
+      body,
+    });
+  } catch (err) {
+    console.error(`[rakuten] token request failed: ${err && err.message}`);
+    return null;
+  }
+
+  if (!res.ok) {
+    console.error(`[rakuten] token endpoint returned HTTP ${res.status}`);
+    return null;
+  }
+
+  let json;
+  try {
+    json = await res.json();
+  } catch (err) {
+    console.error(`[rakuten] could not parse token JSON: ${err && err.message}`);
+    return null;
+  }
+
+  const token = json && json.access_token;
+  if (!token) {
+    console.error('[rakuten] token response missing access_token');
+    return null;
+  }
+
+  // expires_in is seconds; default to 60 min if the field is absent.
+  const ttlSec = Number(json.expires_in) > 0 ? Number(json.expires_in) : 3600;
+  _tokenCache = { token, expiresAt: now + ttlSec * 1000 };
+  return token;
+}
+
+// Fetch + map products. Returns [] on ANY failure (missing creds, network,
+// auth, empty catalog) — never throws.
+async function fetchProducts(env, opts) {
+  const e = env || {};
+  if (!enabled(e)) return [];
+
+  const limit = (opts && Number(opts.limit) > 0) ? Math.floor(Number(opts.limit)) : DEFAULT_LIMIT;
+
+  const token = await getAccessToken(e);
+  if (!token) return [];
+
+  const url =
+    `${PRODUCT_SEARCH_URL}?keyword=${encodeURIComponent(DEFAULT_KEYWORD)}` +
+    `&max=${encodeURIComponent(limit)}`;
+
+  let res;
+  try {
+    res = await fetch(url, {
+      method: 'GET',
+      headers: { Authorization: `Bearer ${token}`, Accept: 'application/xml' },
+    });
+  } catch (err) {
+    console.error(`[rakuten] product search request failed: ${err && err.message}`);
+    return [];
+  }
+
+  if (!res.ok) {
+    // A 401 here usually means the cached token went stale between calls; drop
+    // the cache so the next fetch() re-auths cleanly.
+    if (res.status === 401) _tokenCache = null;
+    console.error(`[rakuten] product search returned HTTP ${res.status}`);
+    return [];
+  }
+
+  let xml;
+  try {
+    xml = await res.text();
+  } catch (err) {
+    console.error(`[rakuten] could not read product search body: ${err && err.message}`);
+    return [];
+  }
+
+  const items = extractItems(xml);
+  const out = [];
+
+  for (const item of items) {
+    // Rakuten field names -> our rawProduct shape.
+    const external_id = tagText(item, 'sku') || tagText(item, 'linkid') || null;
+    const title = tagText(item, 'productname');
+    const affiliate_url = tagText(item, 'linkurl');
+
+    // Required fields — skip malformed items rather than emit junk.
+    if (!external_id || !title || !affiliate_url) continue;
+
+    const priceCurrency = tagAttr(item, 'price', 'currency');
+    const saleRaw = tagText(item, 'saleprice');
+
+    out.push({
+      advertiser: tagText(item, 'merchantname'),
+      external_id: String(external_id),
+      title,
+      description: tagText(item, 'description') || null,
+      brand: tagText(item, 'brand') || null,
+      // Rakuten category is a delimited path e.g. "Home~~Furniture"; keep raw.
+      category: tagText(item, 'category') || null,
+      price: toNumber(tagText(item, 'price')),
+      sale_price: saleRaw != null ? toNumber(saleRaw) : null,
+      currency: priceCurrency || null, // TODO(verify): some feeds omit currency attr
+      image_url: tagText(item, 'imageurl') || null,
+      affiliate_url,
+      // Product Search has no reliable in-stock flag; assume orderable = true.
+      in_stock: true,
+    });
+  }
+
+  return out;
+}
+
+module.exports = {
+  network: NETWORK,
+  enabled,
+  fetch: fetchProducts,
+};
+
+// ---------------------------------------------------------------------------
+// Self-test: `node lib/adapters/rakuten.js` — runs a real fetch with whatever
+// creds are in the environment and logs the count. Safe with no creds (0).
+// ---------------------------------------------------------------------------
+if (require.main === module) {
+  (async () => {
+    const on = enabled(process.env);
+    console.log(`[rakuten] enabled(process.env) = ${on}`);
+    try {
+      const rows = await fetchProducts(process.env, { limit: 3 });
+      console.log(`[rakuten] fetch returned ${rows.length} product(s)`);
+      if (rows.length) console.log(JSON.stringify(rows[0], null, 2));
+    } catch (err) {
+      console.error(`[rakuten] self-test error: ${err && err.message}`);
+    }
+  })();
+}
diff --git a/lib/adapters/shareasale.js b/lib/adapters/shareasale.js
new file mode 100644
index 0000000..c00b126
--- /dev/null
+++ b/lib/adapters/shareasale.js
@@ -0,0 +1,235 @@
+// ShareASale product-search adapter (ShareASale / Awin / Impact family).
+//
+// WHAT SHAREASALE IS
+//   ShareASale is an affiliate network (now owned by Awin) that connects
+//   publishers ("affiliates") with merchants. A publisher earns commission by
+//   driving tracked traffic/sales to a merchant. ShareASale exposes an HTTP
+//   API at https://api.shareasale.com/w.cfm for affiliates to pull merchant
+//   catalogs, run product searches, fetch tracked deep-links, etc.
+//
+// REQUIRED ENV VARS (all three must be present for this adapter to run):
+//   SHAREASALE_AFFILIATE_ID   your ShareASale affiliate account id (numeric)
+//   SHAREASALE_API_TOKEN      the API "token" issued in the ShareASale UI
+//   SHAREASALE_API_SECRET     the API "secret key" paired with that token
+//
+// APPROVAL NOTE
+//   Using this adapter requires (1) an approved ShareASale PUBLISHER account
+//   with API access enabled, AND (2) per-merchant approval for each merchant
+//   whose products you surface + link to. An affiliate link only pays / is
+//   permitted for merchants that have accepted you into their program. This
+//   adapter reads the catalog; honoring per-merchant approval + FTC disclosure
+//   is the caller's responsibility.
+//
+// AUTH MECHANICS (verified: token + sha256 signature header, pipe-delimited
+//   response). Signature = sha256Hex(token + ':' + timestamp + ':' + action +
+//   ':' + secretKey); sent alongside the timestamp in two request headers:
+//     x-ShareASale-Date: <timestamp>
+//     x-ShareASale-Authentication: <sig>
+//
+// CONTRACT: module.exports = { network, enabled, fetch }
+//   fetch(env, { limit }) -> array of rawProduct objects (see lib/adapters/index.js)
+//
+// Pure Node: global fetch (Node 20+) + built-in crypto. No npm deps.
+// Never throws on missing creds — enabled() is false and fetch() returns [].
+
+'use strict';
+
+const crypto = require('crypto');
+
+const NETWORK = 'shareasale';
+const API_URL = 'https://api.shareasale.com/w.cfm';
+const API_VERSION = '3.0'; // ShareASale API version string. TODO(verify): confirm current version.
+const ACTION = 'productSearch';
+const DEFAULT_KEYWORD = 'furniture';
+const DEFAULT_LIMIT = 50;
+
+// --- helpers ---------------------------------------------------------------
+
+function sha256Hex(str) {
+  return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
+}
+
+// ShareASale expects a UTC date string as the timestamp. Their examples use a
+// full UTC datetime; new Date().toUTCString() yields e.g.
+// "Sat, 01 Aug 2026 18:04:11 GMT", which the signature and header must match
+// exactly (same value used in both places).
+function utcTimestamp() {
+  return new Date().toUTCString();
+}
+
+function signature(token, timestamp, action, secret) {
+  return sha256Hex(`${token}:${timestamp}:${action}:${secret}`);
+}
+
+function toNumber(v) {
+  if (v == null || v === '') return undefined;
+  const n = Number(String(v).replace(/[^0-9.\-]/g, ''));
+  return Number.isFinite(n) ? n : undefined;
+}
+
+function toBool(v) {
+  if (v == null || v === '') return undefined;
+  const s = String(v).trim().toLowerCase();
+  if (['1', 'true', 'yes', 'y', 'instock', 'in stock', 'in-stock'].includes(s)) return true;
+  if (['0', 'false', 'no', 'n', 'outofstock', 'out of stock'].includes(s)) return false;
+  return undefined;
+}
+
+// Parse ShareASale's delimited response defensively. The productSearch action
+// returns a header row + data rows. The delimiter is typically a pipe "|" but
+// can be a tab; sniff whichever the header row contains. Columns are mapped by
+// lowercased header name so we don't depend on positional order.
+function parseDelimited(text) {
+  const lines = String(text)
+    .split(/\r?\n/)
+    .map((l) => l.trim())
+    .filter((l) => l.length > 0);
+  if (lines.length < 2) return [];
+
+  const header = lines[0];
+  // Sniff delimiter: prefer pipe, else tab, else comma.
+  let delim = '|';
+  if (!header.includes('|')) {
+    if (header.includes('\t')) delim = '\t';
+    else if (header.includes(',')) delim = ',';
+  }
+
+  const cols = header.split(delim).map((c) => c.trim().toLowerCase());
+  const rows = [];
+  for (let i = 1; i < lines.length; i++) {
+    const parts = lines[i].split(delim);
+    // Skip API error lines (ShareASale prefixes errors with a code word/number).
+    if (parts.length < 2) continue;
+    const row = {};
+    for (let c = 0; c < cols.length; c++) {
+      row[cols[c]] = parts[c] != null ? parts[c].trim() : '';
+    }
+    rows.push(row);
+  }
+  return rows;
+}
+
+// Column-name resolver — ShareASale column labels vary by feed; try the common
+// aliases for each field. TODO(verify): confirm exact productSearch column names.
+function pick(row, names) {
+  for (const n of names) {
+    if (row[n] != null && row[n] !== '') return row[n];
+  }
+  return undefined;
+}
+
+function mapRow(row) {
+  const external_id = pick(row, ['productid', 'product_id', 'sku', 'id']);
+  const title = pick(row, ['name', 'productname', 'title']);
+  const affiliate_url = pick(row, ['buyurl', 'affiliateurl', 'affiliate_url', 'link', 'url']);
+  const price = toNumber(pick(row, ['price', 'retailprice']));
+  const sale_price = toNumber(pick(row, ['saleprice', 'sale_price']));
+
+  return {
+    advertiser: pick(row, ['merchantname', 'merchant', 'advertiser', 'brandname']),
+    external_id: external_id != null ? String(external_id) : undefined,
+    title,
+    description: pick(row, ['description', 'shortdescription', 'longdescription']),
+    brand: pick(row, ['brand', 'brandname', 'manufacturer']),
+    category: pick(row, ['category', 'merchantcategory', 'primarycategory']),
+    price,
+    sale_price,
+    currency: pick(row, ['currency', 'currencycode']) || 'USD',
+    image_url: pick(row, ['imageurl', 'image', 'thumburl', 'thumbnail']),
+    affiliate_url,
+    in_stock: toBool(pick(row, ['instock', 'in_stock', 'stockstatus', 'status'])),
+  };
+}
+
+// --- contract --------------------------------------------------------------
+
+function enabled(env) {
+  const e = env || {};
+  return Boolean(
+    e.SHAREASALE_AFFILIATE_ID &&
+      e.SHAREASALE_API_TOKEN &&
+      e.SHAREASALE_API_SECRET
+  );
+}
+
+async function fetch_(env, opts) {
+  const e = env || {};
+  if (!enabled(e)) return []; // never throw on missing creds
+
+  const limit = Math.max(1, Number((opts && opts.limit) || DEFAULT_LIMIT));
+
+  const affiliateId = e.SHAREASALE_AFFILIATE_ID;
+  const token = e.SHAREASALE_API_TOKEN;
+  const secret = e.SHAREASALE_API_SECRET;
+  const keyword = e.SHAREASALE_KEYWORD || DEFAULT_KEYWORD;
+
+  const timestamp = utcTimestamp();
+  const sig = signature(token, timestamp, ACTION, secret);
+
+  const params = new URLSearchParams({
+    version: API_VERSION,
+    action: ACTION,
+    affiliateId: String(affiliateId),
+    token: String(token),
+    keyword,
+    // Paging: request enough rows to satisfy limit. TODO(verify): exact paging
+    // param names for productSearch (records/page vs XMLOut/recordcount).
+    records: String(limit),
+    page: '1',
+  });
+
+  const url = `${API_URL}?${params.toString()}`;
+
+  let text;
+  try {
+    const res = await fetch(url, {
+      method: 'GET',
+      headers: {
+        'x-ShareASale-Date': timestamp,
+        'x-ShareASale-Authentication': sig,
+        Accept: 'text/plain',
+      },
+    });
+    if (!res.ok) return []; // bad creds / rate-limit / error -> empty, don't throw
+    text = await res.text();
+  } catch (_err) {
+    return []; // network failure -> empty
+  }
+
+  let rows;
+  try {
+    rows = parseDelimited(text);
+  } catch (_err) {
+    return [];
+  }
+
+  const products = [];
+  for (const row of rows) {
+    const p = mapRow(row);
+    // Enforce the required fields of a rawProduct.
+    if (!p.external_id || !p.title || !p.affiliate_url) continue;
+    products.push(p);
+    if (products.length >= limit) break;
+  }
+  return products;
+}
+
+module.exports = {
+  network: NETWORK,
+  enabled,
+  fetch: fetch_,
+};
+
+// --- self-test -------------------------------------------------------------
+if (require.main === module) {
+  (async () => {
+    const on = enabled(process.env);
+    console.log(`[shareasale] enabled=${on}`);
+    const out = await fetch_(process.env, { limit: 3 });
+    console.log(`[shareasale] fetched ${out.length} product(s)`);
+    if (out.length) console.log(JSON.stringify(out[0], null, 2));
+  })().catch((err) => {
+    console.error('[shareasale] self-test error:', err && err.message);
+    process.exit(1);
+  });
+}
diff --git a/scripts/ingest.js b/scripts/ingest.js
new file mode 100644
index 0000000..2dd4a91
--- /dev/null
+++ b/scripts/ingest.js
@@ -0,0 +1,54 @@
+// Runs every enabled adapter, normalizes results, and UPSERTs into products.
+// Safe to run repeatedly (cron): the (network, external_id) key means re-ingest
+// updates prices/stock in place instead of duplicating.
+//
+// Usage:  node scripts/ingest.js [--limit=500] [--only=cj,amazon]
+// Env is read from the process (pm2 ecosystem / shell). dotenv is optional:
+try { require('dotenv').config(); } catch (_) { /* dotenv not installed — env comes from the shell */ }
+const db = require('../lib/db');
+const adapters = require('../lib/adapters');
+const { normalizeProduct, UPSERT_SQL, upsertParams } = require('../lib/normalize');
+
+function arg(name, def) {
+  const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
+  return hit ? hit.split('=')[1] : def;
+}
+
+async function main() {
+  const env = process.env;
+  const limit = parseInt(arg('limit', '500'), 10);
+  const only = (arg('only', '') || '').split(',').filter(Boolean);
+
+  await db.query('SELECT 1');
+  const summary = [];
+
+  for (const a of adapters) {
+    if (only.length && !only.includes(a.network)) continue;
+    if (!a.enabled(env)) { summary.push(`${a.network}: skipped (no creds)`); continue; }
+
+    let raws = [];
+    try {
+      raws = await a.fetch(env, { limit });
+    } catch (e) {
+      summary.push(`${a.network}: ERROR ${e.message}`);
+      continue;
+    }
+
+    let ok = 0, skip = 0;
+    for (const raw of raws) {
+      const norm = normalizeProduct(raw, a.network);
+      if (!norm) { skip++; continue; }
+      try {
+        await db.query(UPSERT_SQL, upsertParams(norm));
+        ok++;
+      } catch (e) { skip++; }
+    }
+    summary.push(`${a.network}: ${ok} upserted, ${skip} skipped (of ${raws.length})`);
+  }
+
+  console.log('--- ingest summary ---');
+  summary.forEach((s) => console.log('  ' + s));
+  await db.pool.end();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });

← 020e430 Add CJ Affiliate GraphQL product-feed adapter (TK-10112)  ·  back to Interiordesignershowroom  ·  Add 3 editorial buying guides (small-space sectionals, light 7c4cdd2 →