← 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 →