← back to Letsbegin

ani_full_monty.js

324 lines

#!/usr/bin/env node
/**
 * ANI- Series Full Monty
 * 1. Migrate global.* → custom.* specs
 * 2. AI color analysis via Gemini
 * 3. Update body to 2 paragraphs (editorial + AI)
 * 4. Generate 2 residential room settings per product (bedroom + living room)
 * 5. Upload room settings to Shopify
 */

const https = require('https');
const http = require('http');
const fs = require('fs');
const { execFileSync } = require('child_process');

const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
const GEMINI_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_KEY) {
  throw new Error('GEMINI_API_KEY environment variable is required');
}
if (!TOKEN) {
  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
}

function gql(body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({
      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0,300) }); } }); });
    req.on('error', reject);
    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
    req.write(data); req.end();
  });
}

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

function downloadImage(url, dest) {
  return new Promise((resolve, reject) => {
    const mod = url.startsWith('https') ? https : http;
    const file = fs.createWriteStream(dest);
    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        file.close(); return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
      }
      res.pipe(file);
      file.on('finish', () => { file.close(); resolve(); });
    }).on('error', e => { file.close(); reject(e); });
  });
}

async function geminiAnalyze(imgPath, prompt) {
  const imgB64 = fs.readFileSync(imgPath).toString('base64');
  const req = {
    contents: [{ parts: [
      { text: prompt },
      { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
    ]}],
    generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
  };
  fs.writeFileSync('/tmp/ani_gemini_req.json', JSON.stringify(req));

  const result = execFileSync('curl', ['-s',
    `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`,
    '-H', 'Content-Type: application/json',
    '-d', `@/tmp/ani_gemini_req.json`
  ], { timeout: 30000 }).toString();

  const parsed = JSON.parse(result);
  return parsed?.candidates?.[0]?.content?.parts?.[0]?.text || '';
}

function geminiGenerateImage(imgPath, roomPrompt) {
  return new Promise((resolve, reject) => {
    const imgB64 = fs.readFileSync(imgPath).toString('base64');
    const reqBody = JSON.stringify({
      contents: [{ parts: [
        { text: roomPrompt },
        { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
      ]}],
      generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
    });

    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`);
    const req = https.request({
      hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(reqBody) }
    }, res => {
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => {
        try {
          const parsed = JSON.parse(Buffer.concat(chunks).toString());
          for (const part of parsed?.candidates?.[0]?.content?.parts || []) {
            if (part.inlineData) {
              return resolve(Buffer.from(part.inlineData.data, 'base64'));
            }
          }
          resolve(null);
        } catch (e) { resolve(null); }
      });
    });
    req.on('error', () => resolve(null));
    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
    req.write(reqBody);
    req.end();
  });
}

async function uploadImageToShopify(productId, imgBuffer, alt) {
  const tmpFile = '/tmp/ani_room_upload.png';
  fs.writeFileSync(tmpFile, imgBuffer);
  const fileSize = fs.statSync(tmpFile).size;

  const stage = await gql({
    query: `mutation { stagedUploadsCreate(input: [{resource: IMAGE, filename: "room-setting.png", mimeType: "image/png", httpMethod: POST, fileSize: "${fileSize}"}]) { stagedTargets { url parameters { name value } resourceUrl } userErrors { message } } }`
  });

  const target = stage?.data?.stagedUploadsCreate?.stagedTargets?.[0];
  if (!target) return null;

  const curlArgs = ['-s', '-X', 'POST', target.url];
  for (const p of target.parameters) curlArgs.push('-F', `${p.name}=${p.value}`);
  curlArgs.push('-F', `file=@${tmpFile}`);
  execFileSync('curl', curlArgs, { timeout: 30000 });

  const pid = productId.includes('/') ? productId : `gid://shopify/Product/${productId}`;
  const mediaResult = await gql({
    query: `mutation { productCreateMedia(productId: "${pid}", media: [{originalSource: "${target.resourceUrl}", mediaContentType: IMAGE, alt: "${alt}"}]) { media { id } mediaUserErrors { message } } }`
  });

  return mediaResult?.data?.productCreateMedia?.media?.[0]?.id || null;
}

function cleanWidth(v) { const m = (v||'').match(/([\d.]+)/); return m ? `${m[1]} Inches` : null; }
function cleanRepeat(v) {
  if (!v || v === 'N/A') return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}
function cleanFire(v) {
  if (!v) return null;
  let f = v.replace(/["']/g, '').trim();
  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
  return f;
}
function cleanMaterial(v) { return v ? v.replace(/wallpaper/gi, 'Wallcovering') : null; }
function toTitleCase(s) {
  if (!s) return '';
  return s.replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.substr(1).toLowerCase());
}

async function main() {
  console.log('=== ANI- Series Full Monty ===\n');

  // Fetch all 16 ANI products with ALL metafields
  const r = await gql({
    query: `{ products(first: 20, query: "sku:ANI-* AND status:active") {
      edges { node { id title bodyHtml vendor images(first:1) { edges { node { url width height } } }
        metafields(first: 30) { edges { node { namespace key value } } }
      } }
    } }`
  });

  const products = r?.data?.products?.edges?.map(e => e.node) || [];
  console.log(`Found ${products.length} ANI products\n`);

  let specsOk = 0, colorsOk = 0, bodiesOk = 0, roomsOk = 0, errors = 0;

  for (let i = 0; i < products.length; i++) {
    const p = products[i];
    const pid = p.id;
    console.log(`\n[${i+1}/${products.length}] ${p.title}`);

    // Collect existing metafields
    const globals = {};
    const customs = {};
    for (const m of p.metafields.edges) {
      if (m.node.namespace === 'global') globals[m.node.key] = m.node.value;
      if (m.node.namespace === 'custom') customs[m.node.key] = m.node.value;
    }

    // === 1. SPECS: Migrate global → custom ===
    const mf = [];
    const add = (k, v, t = 'single_line_text_field') => {
      if (v && v.toString().trim() && !customs[k]) {
        mf.push({ ownerId: pid, namespace: 'custom', key: k, value: v.toString().trim(), type: t });
      }
    };

    add('width', cleanWidth(globals['width']));
    add('pattern_repeat', cleanRepeat(globals['repeat']));
    add('fire_rating', cleanFire(globals['fire_rating']));
    add('material', cleanMaterial(globals['Construction'] || globals['Contents']), 'multi_line_text_field');
    add('collection_name', globals['Collection']);
    add('match_type', globals['MATCH'] || globals['Match']);
    add('brand', globals['Brand'] || p.vendor);
    add('packaging', globals['packaged']);
    add('unit_of_measure', globals['unit_of_measure']);
    add('origin', globals['Country'] || globals['Country-of-Origin']);
    add('pattern_name', customs['pattern_name'] || p.title);
    add('color', globals['color'] || globals['Color-Way']);

    if (mf.length > 0) {
      try {
        await gql({ query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }', variables: { m: mf } });
        specsOk++;
        console.log(`  Specs: ${mf.length} fields set`);
      } catch { errors++; }
    } else {
      console.log('  Specs: already complete');
      specsOk++;
    }

    // === 2. AI COLOR ANALYSIS ===
    const imgUrl = p.images?.edges?.[0]?.node?.url;
    let aiDesc = '';
    if (imgUrl && !customs['color_details']) {
      try {
        const tmpImg = '/tmp/ani_img.jpg';
        await downloadImage(imgUrl, tmpImg);

        const colorJson = await geminiAnalyze(tmpImg,
          'Analyze this wallcovering. Return ONLY valid JSON: {"dominantColor":"name","dominantHex":"#XXXXXX","backgroundColor":"name","backgroundHex":"#XXXXXX","colors":[{"name":"Color","hex":"#XXXXXX","percentage":40}]}. Use 3-6 colors summing to 100%. Title Case names.'
        );

        const colorText = colorJson.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
        const colorData = JSON.parse(colorText);

        // Also get AI description
        aiDesc = await geminiAnalyze(tmpImg,
          `Describe this wallcovering called "${p.title}" in 2 sentences for a commercial/architectural buyer. Mention the pattern, colors, and recommended use. Do NOT use the word "wallpaper" — use "wallcovering".`
        );

        // Set color metafields
        const colorMf = [
          { ownerId: pid, namespace: 'custom', key: 'color_hex', value: colorData.dominantHex, type: 'single_line_text_field' },
          { ownerId: pid, namespace: 'custom', key: 'background_color', value: colorData.backgroundColor, type: 'single_line_text_field' },
          { ownerId: pid, namespace: 'custom', key: 'color_details', value: JSON.stringify(colorData.colors), type: 'json' },
        ];
        await gql({ query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }', variables: { m: colorMf } });
        colorsOk++;
        console.log(`  Colors: ${colorData.dominantColor} (${colorData.dominantHex})`);
      } catch (e) {
        console.log(`  Colors: ERROR ${e.message?.slice(0,50)}`);
        errors++;
      }
    } else {
      console.log('  Colors: already done or no image');
      colorsOk++;
    }

    // === 3. BODY: 2 paragraphs ===
    const existingBody = (p.bodyHtml || '').replace(/<[^>]+>/g, '').trim();
    let firstPara = existingBody.length > 20 ? existingBody : `${p.title} is a charming wallcovering by ${p.vendor}. This design brings whimsical character to any interior space.`;
    let secondPara = aiDesc ? aiDesc.replace(/wallpaper/gi, 'wallcovering').trim() : `A delightful decorative wallcovering perfect for bedrooms, nurseries, and living spaces.`;
    const newBody = `<p>${firstPara}</p>\n<p>${secondPara}</p>`;

    try {
      await gql({
        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
        variables: { i: { id: pid, bodyHtml: newBody } }
      });
      bodiesOk++;
      console.log(`  Body: 2 paragraphs set`);
    } catch { errors++; }

    // === 4. ROOM SETTINGS: 2 residential (bedroom + living room) ===
    if (imgUrl) {
      const tmpImg = '/tmp/ani_img.jpg';
      if (!fs.existsSync(tmpImg) || fs.statSync(tmpImg).size < 1000) {
        await downloadImage(imgUrl, tmpImg);
      }

      // Build tiled wall from the product image
      try {
        const dims = execFileSync('identify', ['-format', '%wx%h', tmpImg]).toString().trim();
        const [imgW, imgH] = dims.split('x').map(Number);

        // Tile horizontally (3 strips), crop to wall proportions
        execFileSync('convert', [tmpImg, tmpImg, tmpImg, '+append', '-gravity', 'center', '-crop', `${imgW*3}x${Math.min(imgH, imgW*2)}+0+0`, '+repage', '-resize', '2000x1200', '-quality', '90', '/tmp/ani_wall.jpg']);

        const rooms = [
          { name: 'Bedroom', prompt: `This image is a wallcovering pattern tiled on a wall. Use it EXACTLY as the background wall. Place in front: a modern queen bed with white and sage green bedding, two wood nightstands with ceramic lamps, light oak hardwood floor. Warm morning light from right window. Photorealistic interior design magazine style.` },
          { name: 'Living Room', prompt: `This image is a wallcovering pattern tiled on a wall. Use it EXACTLY as the background wall. Place in front: a mid-century modern sofa in cream linen, a round walnut coffee table, a potted fiddle leaf fig plant, and a soft area rug. Natural afternoon light. Photorealistic interior design photography.` },
        ];

        for (const room of rooms) {
          console.log(`  Room: generating ${room.name}...`);
          const imgBuf = await geminiGenerateImage('/tmp/ani_wall.jpg', room.prompt);
          if (imgBuf) {
            const mediaId = await uploadImageToShopify(pid, imgBuf, `${p.title} - ${room.name} room setting`);
            if (mediaId) {
              roomsOk++;
              console.log(`  Room: ${room.name} uploaded (${(imgBuf.length/1024).toFixed(0)}KB)`);
            }
          } else {
            console.log(`  Room: ${room.name} — Gemini returned no image`);
          }
          await sleep(2000); // Rate limit between image generations
        }
      } catch (e) {
        console.log(`  Rooms: ERROR ${e.message?.slice(0,60)}`);
        errors++;
      }
    }

    await sleep(1000);
  }

  console.log('\n=== COMPLETE ===');
  console.log(`Products: ${products.length}`);
  console.log(`Specs migrated: ${specsOk}`);
  console.log(`Colors analyzed: ${colorsOk}`);
  console.log(`Bodies rewritten: ${bodiesOk}`);
  console.log(`Room settings generated: ${roomsOk}`);
  console.log(`Errors: ${errors}`);
}

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