← back to Interiordesignershowroom

lib/adapters/shareasale.js

236 lines

// 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);
  });
}