← back to Yolo Agent

scripts/collection-descriptions.js

449 lines

#!/usr/bin/env node
const path = require('path');
const fs = require('fs');

function loadLocalEnv(file) {
  if (!fs.existsSync(file)) return;
  for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
    const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/);
    if (!match || process.env[match[1]] !== undefined) continue;
    let value = match[2].trim();
    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
      value = value.slice(1, -1);
    }
    process.env[match[1]] = value;
  }
}

loadLocalEnv(path.join(__dirname, '..', '.env'));
/**
 * Collection Descriptions Writer
 * Writes 3-sentence interior-designer descriptions for top 100 collections with empty body_html.
 *
 * Uses Gemini 2.0 Flash for description generation.
 */

const https = require('https');
const http = require('http');

const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API_VERSION = '2024-07';
const GEMINI_KEY = process.env.GEMINI_API_KEY;
const COLLECTIONS_DASHBOARD_AUTH = process.env.COLLECTIONS_DASHBOARD_AUTH;
const GEMINI_MODEL = 'gemini-2.0-flash';
if (!SHOPIFY_TOKEN || !GEMINI_KEY || !COLLECTIONS_DASHBOARD_AUTH) {
  console.error('Missing SHOPIFY_ADMIN_TOKEN, GEMINI_API_KEY, or COLLECTIONS_DASHBOARD_AUTH.');
  process.exit(1);
}

const RATE_LIMIT_MS = 600; // 0.6s between Shopify calls

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function shopifyGet(path) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: SHOPIFY_STORE,
      path: `/admin/api/${API_VERSION}${path}`,
      method: 'GET',
      headers: {
        'X-Shopify-Access-Token': SHOPIFY_TOKEN,
        'Content-Type': 'application/json'
      }
    };
    const req = https.request(options, (res) => {
      let body = '';
      res.on('data', chunk => body += chunk);
      res.on('end', () => {
        try {
          resolve({ status: res.statusCode, data: JSON.parse(body), headers: res.headers });
        } catch (e) {
          reject(new Error(`Parse error: ${body.substring(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

function shopifyPut(path, data) {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify(data);
    const options = {
      hostname: SHOPIFY_STORE,
      path: `/admin/api/${API_VERSION}${path}`,
      method: 'PUT',
      headers: {
        'X-Shopify-Access-Token': SHOPIFY_TOKEN,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(payload)
      }
    };
    const req = https.request(options, (res) => {
      let body = '';
      res.on('data', chunk => body += chunk);
      res.on('end', () => {
        try {
          resolve({ status: res.statusCode, data: JSON.parse(body), headers: res.headers });
        } catch (e) {
          reject(new Error(`Parse error: ${body.substring(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

function geminiGenerate(prompt) {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify({
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: {
        temperature: 0.7,
        maxOutputTokens: 300,
      }
    });
    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`);
    const options = {
      hostname: url.hostname,
      path: url.pathname + url.search,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(payload)
      }
    };
    const req = https.request(options, (res) => {
      let body = '';
      res.on('data', chunk => body += chunk);
      res.on('end', () => {
        try {
          const data = JSON.parse(body);
          if (data.candidates && data.candidates[0] && data.candidates[0].content) {
            resolve(data.candidates[0].content.parts[0].text.trim());
          } else {
            reject(new Error(`Gemini error: ${JSON.stringify(data).substring(0, 300)}`));
          }
        } catch (e) {
          reject(new Error(`Gemini parse error: ${body.substring(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

function fetchCollections() {
  return new Promise((resolve, reject) => {
    const auth = Buffer.from(COLLECTIONS_DASHBOARD_AUTH).toString('base64');
    const options = {
      hostname: 'localhost',
      port: 9501,
      path: '/api/collections',
      method: 'GET',
      headers: {
        'Authorization': `Basic ${auth}`
      }
    };
    const req = http.request(options, (res) => {
      let body = '';
      res.on('data', chunk => body += chunk);
      res.on('end', () => {
        try {
          resolve(JSON.parse(body));
        } catch (e) {
          reject(new Error(`Parse error: ${body.substring(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

function categorizeCollection(title) {
  const t = title.toLowerCase();

  // Vendor collections
  const vendors = [
    'kravet', 'thibaut', 'scalamandre', 'phillip jeffries', 'phillipe romano',
    'brunschwig', 'lee jofa', 'clarke', 'fentucci', 'jeffrey stevens',
    'bespoke', 'hollywood', 'graham & brown', 'cole & son', 'osborne',
    'schumacher', 'ralph lauren', 'arte', 'elitis', 'york', 'brewster',
    'dw bespoke'
  ];
  for (const v of vendors) {
    if (t.includes(v)) return 'vendor';
  }

  // Color collections
  const colors = [
    'blue', 'green', 'red', 'grey', 'gray', 'brown', 'black', 'white',
    'pink', 'yellow', 'orange', 'beige', 'cream', 'coral', 'chocolate',
    'metallic', 'multi'
  ];
  for (const c of colors) {
    if (t.includes(c)) return 'color';
  }

  // Material collections
  const materials = [
    'grasscloth', 'sisal', 'burlap', 'cotton', 'vinyl', 'linen', 'silk',
    'leather', 'type ii', 'type 2', 'faux', 'natural', 'fabric', 'paint',
    'paper backed'
  ];
  for (const m of materials) {
    if (t.includes(m)) return 'material';
  }

  // Pattern collections
  const patterns = [
    'stripe', 'plaid', 'geometric', 'floral', 'damask', 'chinoiserie',
    'tropical', 'animal', 'birds', 'scenic', 'mural', 'botanical',
    'novelty', 'abstract', 'trees', 'frames', 'pattern'
  ];
  for (const p of patterns) {
    if (t.includes(p)) return 'pattern';
  }

  // Style collections
  const styles = [
    'contemporary', 'modern', 'traditional', 'rustic', 'european',
    'transitional', 'hamptons', 'commercial', 'durable', 'best seller',
    'new arrival', 'exclusive', 'featured', 'imported', 'made in italy',
    'budget'
  ];
  for (const s of styles) {
    if (t.includes(s)) return 'style';
  }

  // Functional
  if (t.includes('upholstery') || t.includes('kids') || t.includes('on demand')) return 'functional';

  // Alphabetical index
  if (/^[a-z]-[a-z]$/.test(t.trim()) || t.includes('a-c') || t.includes('d-l') || t.includes('m-o') || t.includes('p-z')) return 'index';

  return 'general';
}

function buildPrompt(title, productCount, category) {
  const baseInstruction = `You are a senior interior designer writing catalog copy for a luxury wallcovering trade showroom. Write EXACTLY 3 sentences. Be authoritative, concise, and trade-professional.

VOCABULARY to use naturally: ground, colorway, substrate, hand, repeat, half-drop, selvage, bolt, mill, specification-grade, contract-grade, railroaded, Type II

NEVER use these words: elevate, transform, stunning, beautiful, perfect for, curated

The description will be wrapped in a <p> tag. Do NOT include any HTML tags in your response. Just plain text, 3 sentences.`;

  const categoryGuide = {
    vendor: `This is a vendor/brand collection. Mention the brand's heritage, what they're known for in the trade, and what substrates or specialties define their line.`,
    color: `This is a color-themed collection. Describe the palette range (don't just name the color), which rooms and light conditions it suits, and what hardware/millwork it coordinates with.`,
    material: `This is a material-focused collection. Describe the tactile quality and hand of the material, its durability classification and application, and installation considerations.`,
    pattern: `This is a pattern-focused collection. Describe the design language, the repeat and scale range, and what architectural contexts suit the pattern.`,
    style: `This is a style/era collection. Reference the design period or aesthetic movement, the architectural context, and the client profile it serves.`,
    functional: `This is a functional/application collection. Describe the performance characteristics, specification context, and end-use applications.`,
    index: `This is an alphabetical index collection for browsing. Describe it as a curated alphabetical selection spanning multiple vendors and styles, noting the breadth of the offering.`,
    general: `Write a general trade description noting the breadth of the collection and its place in the catalog.`
  };

  return `${baseInstruction}

${categoryGuide[category] || categoryGuide.general}

Collection: "${title}"
Products in collection: ${productCount}

Write 3 sentences. No preamble, no quotes, no HTML. Just the description text.`;
}

async function main() {
  console.log('=== Collection Descriptions Writer ===');
  console.log(`Started: ${new Date().toISOString()}\n`);

  // Step 1: Fetch all collections
  console.log('Fetching collections from collections-agent...');
  const collData = await fetchCollections();
  const collections = collData.collections;
  console.log(`Total collections: ${collections.length}`);

  // Sort by product count DESC, take top 100
  collections.sort((a, b) => (b.productCount || 0) - (a.productCount || 0));
  const top100 = collections.slice(0, 100);
  console.log(`Top 100 range: ${top100[0].productCount} to ${top100[top100.length - 1].productCount} products\n`);

  // Step 2: Check each collection's body_html
  const needsDescription = [];
  let checked = 0;
  let alreadyHasBody = 0;
  let errors = 0;

  for (const coll of top100) {
    const numericId = coll.id.split('/').pop();
    const isSmart = coll.isSmartCollection;
    const endpoint = isSmart
      ? `/smart_collections/${numericId}.json`
      : `/custom_collections/${numericId}.json`;

    try {
      const result = await shopifyGet(endpoint);
      checked++;

      if (result.status === 429) {
        console.log(`  Rate limited at ${checked}, waiting 2s...`);
        await sleep(2000);
        // Retry
        const retry = await shopifyGet(endpoint);
        if (retry.status === 429) {
          console.log(`  Still rate limited, waiting 5s...`);
          await sleep(5000);
          const retry2 = await shopifyGet(endpoint);
          if (retry2.status !== 200) {
            errors++;
            continue;
          }
          const key2 = isSmart ? 'smart_collection' : 'custom_collection';
          const bodyHtml2 = retry2.data[key2]?.body_html || '';
          if (!bodyHtml2 || bodyHtml2.trim() === '' || bodyHtml2.trim() === '<p></p>') {
            needsDescription.push({ ...coll, numericId });
          } else {
            alreadyHasBody++;
          }
          continue;
        }
        const key = isSmart ? 'smart_collection' : 'custom_collection';
        const bodyHtml = retry.data[key]?.body_html || '';
        if (!bodyHtml || bodyHtml.trim() === '' || bodyHtml.trim() === '<p></p>') {
          needsDescription.push({ ...coll, numericId });
        } else {
          alreadyHasBody++;
        }
        continue;
      }

      if (result.status !== 200) {
        console.log(`  Error ${result.status} for ${coll.title} (${numericId})`);
        errors++;
        await sleep(RATE_LIMIT_MS);
        continue;
      }

      const key = isSmart ? 'smart_collection' : 'custom_collection';
      const bodyHtml = result.data[key]?.body_html || '';

      if (!bodyHtml || bodyHtml.trim() === '' || bodyHtml.trim() === '<p></p>') {
        needsDescription.push({ ...coll, numericId });
        console.log(`  [${checked}/100] EMPTY: ${coll.title} (${coll.productCount} products)`);
      } else {
        alreadyHasBody++;
        console.log(`  [${checked}/100] HAS BODY: ${coll.title}`);
      }
    } catch (err) {
      console.log(`  Error checking ${coll.title}: ${err.message}`);
      errors++;
    }

    await sleep(RATE_LIMIT_MS);
  }

  console.log(`\n--- Check Summary ---`);
  console.log(`Checked: ${checked}`);
  console.log(`Already have body_html: ${alreadyHasBody}`);
  console.log(`Need descriptions: ${needsDescription.length}`);
  console.log(`Errors: ${errors}\n`);

  if (needsDescription.length === 0) {
    console.log('All top 100 collections already have descriptions. Done!');
    return;
  }

  // Step 3: Generate and write descriptions
  let written = 0;
  let genErrors = 0;
  const samples = [];

  for (const coll of needsDescription) {
    const category = categorizeCollection(coll.title);
    const prompt = buildPrompt(coll.title, coll.productCount, category);

    try {
      // Generate with Gemini
      const description = await geminiGenerate(prompt);

      // Clean up — remove any quotes or HTML that Gemini might add
      let cleaned = description
        .replace(/^["']|["']$/g, '')
        .replace(/<[^>]+>/g, '')
        .trim();

      // Ensure max 3 sentences
      const sentences = cleaned.match(/[^.!?]+[.!?]+/g) || [cleaned];
      if (sentences.length > 3) {
        cleaned = sentences.slice(0, 3).join(' ').trim();
      }

      const bodyHtml = `<p>${cleaned}</p>`;

      // Update via Shopify API
      const isSmart = coll.isSmartCollection;
      const endpoint = isSmart
        ? `/smart_collections/${coll.numericId}.json`
        : `/custom_collections/${coll.numericId}.json`;
      const key = isSmart ? 'smart_collection' : 'custom_collection';
      const payload = { [key]: { id: parseInt(coll.numericId), body_html: bodyHtml } };

      const result = await shopifyPut(endpoint, payload);

      if (result.status === 429) {
        console.log(`  Rate limited writing ${coll.title}, waiting 3s and retrying...`);
        await sleep(3000);
        const retry = await shopifyPut(endpoint, payload);
        if (retry.status === 200) {
          written++;
          console.log(`  [${written}] WROTE (retry): ${coll.title}`);
          if (samples.length < 10) samples.push({ title: coll.title, productCount: coll.productCount, category, description: cleaned });
        } else {
          console.log(`  FAILED (retry) ${coll.title}: ${retry.status}`);
          genErrors++;
        }
      } else if (result.status === 200) {
        written++;
        console.log(`  [${written}] WROTE: ${coll.title} [${category}]`);
        if (samples.length < 10) samples.push({ title: coll.title, productCount: coll.productCount, category, description: cleaned });
      } else {
        console.log(`  FAILED ${coll.title}: ${result.status} - ${JSON.stringify(result.data).substring(0, 200)}`);
        genErrors++;
      }
    } catch (err) {
      console.log(`  GEN ERROR ${coll.title}: ${err.message}`);
      genErrors++;
    }

    await sleep(RATE_LIMIT_MS);
  }

  console.log(`\n=== FINAL REPORT ===`);
  console.log(`Collections checked: ${checked}`);
  console.log(`Already had descriptions: ${alreadyHasBody}`);
  console.log(`Descriptions written: ${written}`);
  console.log(`Errors: ${errors + genErrors}`);
  console.log(`\n--- Sample Descriptions ---`);
  for (const s of samples) {
    console.log(`\n[${s.category.toUpperCase()}] ${s.title} (${s.productCount} products):`);
    console.log(`  "${s.description}"`);
  }
  console.log(`\nCompleted: ${new Date().toISOString()}`);
}

main().catch(err => {
  console.error('Fatal error:', err);
  process.exit(1);
});