← 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));
})();
}