[object Object]

← back to Interiordesignershowroom

Add CJ Affiliate GraphQL product-feed adapter (TK-10112)

020e430a07e6825a29b833b21cf200632d0c7ae1 · 2026-08-01 11:13:41 -0700 · Steve Abrams

Files touched

Diff

commit 020e430a07e6825a29b833b21cf200632d0c7ae1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 11:13:41 2026 -0700

    Add CJ Affiliate GraphQL product-feed adapter (TK-10112)
---
 lib/adapters/cj.js | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 188 insertions(+)

diff --git a/lib/adapters/cj.js b/lib/adapters/cj.js
new file mode 100644
index 0000000..4d6c672
--- /dev/null
+++ b/lib/adapters/cj.js
@@ -0,0 +1,188 @@
+// ---------------------------------------------------------------------------
+// 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,
+    // CJ returns the tracked deep link as `link` (aka clickUrl). REQUIRED.
+    affiliate_url: node.link || node.clickUrl || null,
+    // CJ exposes stock as `availability` (e.g. "in stock"); default to true.
+    in_stock:
+      node.availability == null
+        ? true
+        : /in.?stock|available|true/i.test(String(node.availability)),
+  };
+}
+
+// --- 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 = env.CJ_KEYWORDS || DEFAULT_KEYWORDS;
+
+  // GraphQL products query. companyId + keywords + limit are well-documented;
+  // the publisher website/PID arg name is the least-documented bit.
+  // TODO(verify): confirm the exact PID arg name (`partnerIds` vs `websiteId`)
+  // and whether it takes a list — CJ docs render behind auth.
+  const query = `
+    query Products($companyId: ID!, $partnerIds: [ID!], $keywords: [String!], $limit: Int) {
+      products(companyId: $companyId, partnerIds: $partnerIds, keywords: $keywords, limit: $limit) {
+        totalCount
+        resultList {
+          id
+          title
+          description
+          brand
+          advertiserId
+          advertiserName
+          price { amount currency }
+          salePrice { amount currency }
+          imageLink
+          link
+          availability
+        }
+      }
+    }`;
+
+  const variables = {
+    companyId: String(companyId),
+    partnerIds: [String(websiteId)],
+    keywords: [keywords],
+    limit,
+  };
+
+  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));
+  })();
+}

← 7f2672c Scaffold interiordesignershowroom: multi-network affiliate s  ·  back to Interiordesignershowroom  ·  Add 4 credential-gated feed adapters (CJ, Amazon PA-API v5, 619c451 →