← back to Interiordesignershowroom

lib/adapters/rakuten.js

296 lines

// ---------------------------------------------------------------------------
// 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: {
        // OAuth2 RFC 6749 §2.3.1 + Rakuten token endpoint: client credentials
        // are sent as HTTP Basic auth (scheme "Basic"), NOT "Bearer".
        // Using "Bearer" here would cause a 401 on every token request, silently
        // making getAccessToken() always return null and fetch() always return [].
        Authorization: `Basic ${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}`);
    }
  })();
}