← back to Norma Platform

agents/instagram-agent/content.js

173 lines

/**
 * content.js — resolve a real DW product into post-ready {image_url, caption}.
 *
 * Pulls from the LIVE public storefront (products/<handle>.json) so there's no
 * catalog DB coupling or credentials. Lets the poster publish actual products
 * across the 35-account fleet instead of hand-pasted image URLs.
 *
 *   node post-to.js <account> --product <handle-or-url> --confirm
 *
 * A caller-supplied --caption always wins over the auto-composed one.
 */

const DEFAULT_STORE = process.env.DW_STORE || 'https://designerwallcoverings.com';

/** Accept a bare handle, a /products/<handle> path, or a full product URL. */
function handleFrom(input) {
  const s = String(input || '').trim();
  const m = s.match(/\/products\/([^/?#]+)/);
  if (m) return { handle: m[1], store: (s.match(/^https?:\/\/[^/]+/) || [DEFAULT_STORE])[0] };
  return { handle: s.replace(/^@/, ''), store: DEFAULT_STORE };
}

// QUALITY + DW SERVICE only. NEVER a price (the $-variant is the memo sample), and
// NEVER the raw vendor (may be a private label that must not be named). Captions vary
// per product so identical text across accounts doesn't trip Instagram's spam filter.
const SERVICE_LINES = [
  'Crafted to an exacting standard and specified for the trade — Designer Wallcoverings offers studio sampling, expert specification, and service designers rely on.',
  'Quality you can feel, made for real interiors. Designer Wallcoverings supports the trade with samples to your studio, specification help, and white-glove service.',
  'Considered materials, faithful color, lasting quality. Designer Wallcoverings — to-the-trade sampling and specification, handled with care.',
  'Designer-grade quality, ready to specify. Samples to your studio and dedicated service from the Designer Wallcoverings trade team.',
  'Beautifully made and built to last. Designer Wallcoverings brings the sampling, specification, and service serious projects demand.',
];

const titleCase = (s) => String(s || '')
  .replace(/["']/g, '')
  .toLowerCase()
  .replace(/\b([a-z])/g, (m) => m.toUpperCase())
  .replace(/\b(And|Of|On|The)\b/g, (m) => m.toLowerCase())
  .replace(/^([a-z])/, (m) => m.toUpperCase())
  .trim();

// The clean product NAME: the descriptive title, minus the vendor segment (private-label
// safe) and minus any embedded mfr SKU digits — color + SKU are shown on their own lines.
function productName(p) {
  return String(p.title || 'Designer Wallcovering')
    .split('|')[0]
    .replace(/\b\d{5,}\b/g, '')       // strip internal mfr SKU numbers (e.g. Koroseal "2308790")
    .replace(/\s{2,}/g, ' ')
    .replace(/\s+-\s*$/, '')
    .trim() || 'Designer Wallcovering';
}

// COLORWAY. Steve's rule: always show the color. Preference order:
//   1) a tag that reads as a pure colorway phrase ("Metallic Gold & Black", "Warm Pewter"),
//   2) the color that follows " - " in the title ("... - Navy Blue & Metallic Gold"),
//   3) up to two atomic color tags joined ("Gold & Black").
const COLOR = /^(white|black|gray|grey|gold|silver|bronze|pewter|charcoal|navy|blue|green|red|pink|purple|mauve|sage|cream|beige|taupe|tan|brown|ivory|olive|teal|aqua|turquoise|copper|brass|platinum|champagne|blush|rose|burgundy|maroon|rust|terracotta|mustard|yellow|orange|coral|lavender|lilac|plum|emerald|sand|stone|slate|smoke|umber|sienna|ochre|oyster|pearl|clay|cinder|salt|baltic|truffle|goldsand|ebony)$/i;
const GENERIC = /^(natural|multi|neutral|assorted|various)$/i;   // valid colors, but only used as a last resort
const CONNECT = /^(and|&|on|light|dark|deep|warm|cool|pale|soft|muted|bright|antique|aged|vintage|metallic|shiny|matte|two|tone)$/i;
// Material / pattern words to strip out of a title-derived colorway so only the color remains.
const NOISE = /\b(wallcovering|wallpaper|fabric|residential|commercial|damask|geometric|texture|textured|type\s*\d|luxury|suede|sueded|silk|vinyl|grasscloth|linen|cork|mylar|leaf|modern|mid-century|transitional|traditional|contemporary|print|mural|weave|woven|faux|solid)\b/ig;
function extractColor(p) {
  const tags = String(p.tags || '').split(',')
    .map((s) => s.trim().replace(/^["'\s]+|["'\s]+$/g, '')).filter(Boolean); // strip stray quotes on tags
  const isColorWord = (w) => COLOR.test(w) || GENERIC.test(w) || CONNECT.test(w);
  const hasRealColor = (s) => s.split(/[\s/&]+/).filter(Boolean).some((w) => COLOR.test(w));

  // 1) TITLE colorway — the authoritative name: text after the last " - ", material words stripped.
  const seg = String(p.title || '').split('|')[0].split(' - ');
  if (seg.length > 1) {
    const c = seg[seg.length - 1].replace(NOISE, '').replace(/\s{2,}/g, ' ').trim();
    if (c && hasRealColor(c)) return titleCase(c);
  }
  // 2) colorway phrase tag — every word a color/connector, at least one REAL (non-generic) color. Longest wins.
  const phrase = tags
    .filter((t) => {
      const words = t.split(/[\s/&]+/).filter(Boolean);
      return words.some((w) => COLOR.test(w)) && words.every(isColorWord);
    })
    .sort((a, b) => b.length - a.length)[0];
  if (phrase) return titleCase(phrase);
  // 3) atomic color tags
  const atoms = tags.filter((t) => COLOR.test(t.replace(/[^a-z]/ig, ''))).slice(0, 2);
  if (atoms.length) return titleCase(atoms.join(' & '));
  // 4) last resort — a generic descriptor tag so the Color line is never empty
  const generic = tags.find((t) => GENERIC.test(t.replace(/[^a-z]/ig, '')));
  return generic ? titleCase(generic) : '';
}

// DW MODEL / SKU. Prefer a non-sample variant SKU; strip the sample/size suffix so the
// customer sees the clean model number (e.g. "GRD-87018-sample" -> "GRD-87018").
function extractSku(p) {
  const skus = (p.variants || []).map((v) => String(v.sku || '').trim()).filter(Boolean);
  let sku = skus.find((s) => !/sample|memo|swatch/i.test(s)) || skus[0] || '';
  // Strip any trailing size/unit/finish descriptor (hyphen OR space separated) and anything after it:
  // "-sample", "-PER YARD", "-SMOOTH REMOVEABLE", " - Roll", etc. -> keep the clean model number.
  sku = sku.replace(/[\s-]+(sample|memo|swatch|per[\s-]*roll|per[\s-]*yard|roll|yard|yd|smooth|remove?able|textured|unpasted|prepasted|peel|stick|matte|satin|gloss)\b.*$/i, '').trim();
  return sku ? sku.toUpperCase() : '';
}

// Compose the caption. NO http link anywhere (Steve, 2026-08-14). Always: Name, Color, SKU.
function autoCaption(p /*, url — intentionally unused: no links in captions */) {
  const name = productName(p);
  const color = extractColor(p);
  const sku = extractSku(p);
  const type = (p.product_type || 'wallcovering').toLowerCase();
  const tag = type.replace(/[^a-z0-9]/g, '');
  const tagLine = tag && tag !== 'wallcovering' ? ` #${tag}` : '';
  const idx = [...name].reduce((a, c) => a + c.charCodeAt(0), 0) % SERVICE_LINES.length;
  const lines = [name];
  if (color) lines.push(`Color: ${color}`);
  if (sku) lines.push(`Model / SKU: ${sku}`);
  lines.push('', SERVICE_LINES[idx], '',
    `#wallpaper #interiordesign #wallcovering${tagLine} #tothetrade #designerwallcoverings`);
  return lines.join('\n');
}

// Hidden upstream-vendor tokens that must NEVER be customer-facing. A public IG image
// URL or vendor field containing any of these leaks a private label (e.g. the Hollywood
// line's images are named `luca-vellum_*` — Luca is a Momentum line). Guard skips them.
const PL_LEAK = [
  'momentum', 'versa', 'luca', 'wallquest', 'chesapeake', 'nextwall', 'seabrook',
  'brewster', 'command54', 'command-54', 'desima', 'carlsten', 'nicolette',
];
function leakToken(s) {
  const t = String(s || '').toLowerCase();
  return PL_LEAK.find((v) => t.includes(v)) || null;
}

/** GET the product JSON, backing off on 429 (storefront rate-limit) up to a few tries. */
async function fetchProduct(url) {
  for (let attempt = 0; attempt < 4; attempt++) {
    const r = await fetch(url);
    if (r.status !== 429) return r;
    await new Promise((res) => setTimeout(res, 1500 * (attempt + 1))); // 1.5s, 3s, 4.5s backoff
  }
  return fetch(url); // final attempt; caller handles a non-ok status
}

// Build the post's image order (Steve, 2026-08-15): ROOM-SETTING photo first, product swatch
// second — as a 2-slide carousel. Only leak-clean images are eligible. Falls back to a single
// product image when no room setting exists.
const ROOM_RE = /room|setting|scene|lifestyle|install|bedroom|living|dining|display|interior/i;
function pickImages(product) {
  const imgs = (product.images || [])
    .map((im) => ({ src: im.src, file: String(im.src || '').split('/').pop().split('?')[0], alt: im.alt || '' }))
    .filter((x) => x.src && !leakToken(x.src));                 // leak-clean only
  if (!imgs.length) return [];
  const isRoom = (x) => ROOM_RE.test(x.file) || ROOM_RE.test(x.alt);
  const room = imgs.find(isRoom);
  const swatch = imgs.find((x) => x !== room && !isRoom(x)) || imgs.find((x) => x !== room) || imgs[0];
  // room first, product swatch second; else just the swatch
  return (room && swatch && room.src !== swatch.src) ? [room.src, swatch.src] : [swatch.src];
}

/** Resolve a product → { image_url, images[], caption, title, url }. Throws if not found/imageless/leaky. */
async function resolveProduct(input) {
  const { handle, store } = handleFrom(input);
  const url = `${store}/products/${handle}.json`;
  const r = await fetchProduct(url);
  if (!r.ok) throw new Error(`product "${handle}" not found (${r.status}) at ${store}`);
  const { product } = await r.json();
  if (!product) throw new Error(`no product payload for "${handle}"`);
  if (leakToken(product.vendor)) throw new Error(`private-label leak (vendor "${leakToken(product.vendor)}") — skipped`);
  const images = pickImages(product);
  if (!images.length) throw new Error(`product "${handle}" has no clean image`);
  const productUrl = `${store}/products/${handle}`;
  // image_url = the PRODUCT swatch (last slide) for single-image callers; images[] = full carousel order.
  return { image_url: images[images.length - 1], images, caption: autoCaption(product, productUrl), title: product.title, url: productUrl };
}

module.exports = { resolveProduct, handleFrom };