← back to Interiordesignershowroom

lib/adapters/cj.js

196 lines

// ---------------------------------------------------------------------------
// CJ Affiliate (Commission Junction) product-feed adapter.
//
// CJ Affiliate is a large affiliate marketing network (advertisers like
// Wayfair, Overstock, Lulu & Georgia, etc. run their programs through it).
// As an approved PUBLISHER you (a) get approved on the CJ platform, then
// (b) JOIN each advertiser's program individually — CJ's product API only
// returns products from advertisers you have joined.
//
// CJ's modern API is a single GraphQL endpoint: https://ads.api.cj.com/query
// Auth is a Bearer "Personal Access Token" (PAT) minted in the CJ Developer
// Portal. The `products` query is scoped to your company (CID) and website
// (PID), takes a keyword + limit, and returns product records (title, price,
// imageLink, link/clickUrl, advertiserName, ...).
//
// Required env vars (see .env.example emitted with this build):
//   CJ_TOKEN       CJ Developer personal access token -> Authorization: Bearer
//   CJ_COMPANY_ID  the publisher/requestor CID (Account > Account Information,
//                  ~7 digits, e.g. 7957587)
//   CJ_WEBSITE_ID  the publisher PID / website id the links are tracked under
//
// How to get them: sign in at members.cj.com (must be an APPROVED publisher),
// grab the CID under Account > Account Information, the PID/website id under
// Account > Websites, and mint a PAT in the CJ Developer Portal
// (developers.cj.com). Then join each advertiser program you want products from.
// ---------------------------------------------------------------------------

'use strict';

const CJ_GRAPHQL_ENDPOINT = 'https://ads.api.cj.com/query';
const DEFAULT_KEYWORDS = 'furniture home decor'; // interior / home-furnishing default
const MAX_LIMIT = 1000; // CJ caps product page size; keep requests sane

// --- credential helpers -----------------------------------------------------

function creds(env) {
  env = env || {};
  return {
    token: env.CJ_TOKEN || '',
    companyId: env.CJ_COMPANY_ID || '',
    websiteId: env.CJ_WEBSITE_ID || '',
  };
}

// enabled() is the single source of truth for "do we have what we need?".
// It must NEVER throw and must return false when anything is missing.
function enabled(env) {
  const { token, companyId, websiteId } = creds(env);
  return Boolean(token && companyId && websiteId);
}

// --- helpers ----------------------------------------------------------------

// Coerce CJ's price shape into a plain number or null. CJ returns prices as
// { amount, currency } objects; older/feed shapes may give a bare string.
function toPrice(p) {
  if (p == null) return null;
  const raw = typeof p === 'object' ? p.amount : p;
  const n = parseFloat(raw);
  return Number.isFinite(n) ? n : null;
}

function toCurrency(...prices) {
  for (const p of prices) {
    if (p && typeof p === 'object' && p.currency) return p.currency;
  }
  return 'USD';
}

// Map one CJ product record -> our rawProduct shape.
function mapProduct(node) {
  const price = toPrice(node.price);
  const salePrice = toPrice(node.salePrice);
  return {
    advertiser: node.advertiserName || node.brand || null,
    external_id: node.id != null ? String(node.id) : null,
    title: node.title || null,
    description: node.description || null,
    brand: node.brand || node.advertiserName || null,
    category: node.category || null,
    price,
    sale_price: salePrice,
    currency: toCurrency(node.price, node.salePrice),
    image_url: node.imageLink || null,
    // The TRACKED affiliate link is linkCode(pid).clickUrl; fall back to the raw
    // advertiser `link` only if the tracked code is missing. REQUIRED.
    affiliate_url: (node.linkCode && node.linkCode.clickUrl) || node.link || null,
    in_stock: true,
  };
}

// --- fetch ------------------------------------------------------------------

async function fetch_(env, opts) {
  opts = opts || {};
  if (!enabled(env)) return []; // rule 2: never throw on missing creds

  const { token, companyId, websiteId } = creds(env);
  const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), MAX_LIMIT);
  const keywords = (opts && opts.keywords) || env.CJ_KEYWORDS || DEFAULT_KEYWORDS;
  // partnerIds scopes the sweep to specific JOINED advertisers — the only way to
  // pull a niche advertiser's full datafeed when generic keywords are dominated
  // by big unjoined advertisers at the page cap (verified ACCEPTED 2026-08-02).
  const partnerIds = Array.isArray(opts.partnerIds) && opts.partnerIds.length
    ? opts.partnerIds.map(String)
    : null;

  // GraphQL products query — VERIFIED against the live CJ schema (2026-08-01):
  //  - companyId: ID!, keywords: String (single, NOT a list), limit: Int
  //  - the TRACKED affiliate link is linkCode(pid: <PID>) { clickUrl }, where PID
  //    is our website id (CJ_WEBSITE_ID). `link` is the advertiser's raw URL.
  //  - `availability`/`category` are not on the base Product type; dropped.
  const query = `
    query Products($companyId: ID!, $keywords: [String!], $partnerIds: [ID!], $limit: Int, $pid: ID!) {
      products(companyId: $companyId, keywords: $keywords, partnerIds: $partnerIds, limit: $limit) {
        totalCount
        resultList {
          id
          title
          description
          brand
          advertiserId
          advertiserName
          price { amount currency }
          salePrice { amount currency }
          imageLink
          link
          linkCode(pid: $pid) { clickUrl }
        }
      }
    }`;

  const variables = {
    companyId: String(companyId),
    // A partner-scoped sweep wants the WHOLE feed — only send keywords when the
    // caller gave some (or when we're not partner-scoped, to bound the query).
    keywords: partnerIds && !opts.keywords ? null : [String(keywords)],
    partnerIds,
    limit,
    pid: String(websiteId),
  };

  let json;
  try {
    const res = await fetch(CJ_GRAPHQL_ENDPOINT, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({ query, variables }),
    });
    if (!res.ok) {
      console.error(`[cj] HTTP ${res.status} ${res.statusText}`);
      return [];
    }
    json = await res.json();
  } catch (err) {
    // Network / DNS / parse failure — degrade to empty, never throw.
    console.error('[cj] fetch failed:', err && err.message ? err.message : err);
    return [];
  }

  if (json && Array.isArray(json.errors) && json.errors.length) {
    console.error('[cj] GraphQL errors:', json.errors.map((e) => e.message).join('; '));
    return [];
  }

  // TODO(verify): confirm the response envelope path is data.products.resultList.
  const list =
    (json && json.data && json.data.products && json.data.products.resultList) || [];
  if (!Array.isArray(list)) return [];

  return list
    .map(mapProduct)
    // Drop records missing any REQUIRED field per the rawProduct contract.
    .filter((p) => p.external_id && p.title && p.affiliate_url)
    .slice(0, limit);
}

module.exports = {
  network: 'cj',
  enabled,
  fetch: fetch_,
};

// --- tiny self-test (smoke run: `node lib/adapters/cj.js`) ------------------
if (require.main === module) {
  (async () => {
    const rows = await fetch_(process.env, { limit: 3 });
    console.log(`[cj] enabled=${enabled(process.env)} fetched ${rows.length} product(s)`);
    if (rows.length) console.log(JSON.stringify(rows[0], null, 2));
  })();
}