← 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
A lib/adapters/amazon.jsA lib/adapters/index.jsA lib/adapters/rakuten.jsA lib/adapters/shareasale.jsA scripts/ingest.js
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(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/�?39;/g, "'")
+ .replace(/'/g, "'")
+ .replace(/&/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 →