← back to Affiliate Feeds Mcp
adapters/rakuten.cjs
297 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(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/�?39;/g, "'")
.replace(/'/g, "'")
.replace(/&/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 keyword = (opts && opts.keywords) ? String(opts.keywords) : (e.RAKUTEN_KEYWORD || DEFAULT_KEYWORD);
const url =
`${PRODUCT_SEARCH_URL}?keyword=${encodeURIComponent(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}`);
}
})();
}