← back to Letsbegin

sequin_full_monty.js

202 lines

#!/usr/bin/env node
/**
 * Sequin Products Full Monty (63 Eel Skin + Sting Ray)
 * 1. AI color analysis via Gemini → custom.color_hex, color_details, background_color, color
 * 2. Body rewrite to 2 paragraphs (editorial + AI)
 * 3. 2 residential room settings per product
 */

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

function geminiCall(reqBody, model) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(reqBody);
    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${model}: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(data) }
    }, res => {
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolve(null); } });
    });
    req.on('error', () => resolve(null));
    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
    req.write(data); req.end();
  });
}

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

  const stage = await gql({
    query: `mutation { stagedUploadsCreate(input: [{resource: IMAGE, filename: "room.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 r = await gql({
    query: `mutation { productCreateMedia(productId: "${pid}", media: [{originalSource: "${target.resourceUrl}", mediaContentType: IMAGE, alt: "${alt}"}]) { media { id } mediaUserErrors { message } } }`
  });
  return r?.data?.productCreateMedia?.media?.[0]?.id || null;
}

async function main() {
  console.log('=== Sequin Full Monty (63 products) ===\n');

  const r = await gql({ query: '{ eel: products(first: 50, query: "eel skin sequin AND status:active") { edges { node { id title bodyHtml images(first:1) { edges { node { url } } } metafields(first: 5, keys: ["custom.color_details","custom.color_hex"]) { edges { node { key value } } } } } } sting: products(first: 50, query: "sting ray sequin AND status:active") { edges { node { id title bodyHtml images(first:1) { edges { node { url } } } metafields(first: 5, keys: ["custom.color_details","custom.color_hex"]) { edges { node { key value } } } } } } }' });

  const products = [...r.data.eel.edges.map(e => e.node), ...r.data.sting.edges.map(e => e.node)];
  console.log(`${products.length} products\n`);

  let colors = 0, bodies = 0, rooms = 0, errors = 0;

  for (let i = 0; i < products.length; i++) {
    const p = products[i];
    const imgUrl = p.images?.edges?.[0]?.node?.url;
    const hasColors = p.metafields?.edges?.some(m => m.node.key === 'color_details' && m.node.value);

    console.log(`[${i+1}/${products.length}] ${p.title}`);

    if (!imgUrl) { console.log('  No image — skip'); continue; }
    const tmpImg = '/tmp/seq_img.jpg';
    await downloadImage(imgUrl, tmpImg);

    // 1. AI COLOR ANALYSIS
    if (!hasColors) {
      const imgB64 = fs.readFileSync(tmpImg).toString('base64');
      const colorResult = await geminiCall({
        contents: [{ parts: [
          { text: 'Analyze this wallcovering. Return ONLY valid JSON: {"dominantColor":"name","dominantHex":"#XXXXXX","backgroundColor":"name","backgroundHex":"#XXXXXX","colors":[{"name":"Color","hex":"#XXXXXX","percentage":40}]}. 3-6 colors summing to 100%. Title Case.' },
          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
        ]}],
        generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
      }, 'gemini-2.0-flash');

      try {
        const text = colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '';
        const clean = text.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
        const cd = JSON.parse(clean);

        await gql({
          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
          variables: { m: [
            { ownerId: p.id, namespace: 'custom', key: 'color_hex', value: cd.dominantHex, type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'background_color', value: cd.backgroundColor, type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'color_details', value: JSON.stringify(cd.colors), type: 'json' },
            { ownerId: p.id, namespace: 'custom', key: 'color_name', value: cd.dominantColor, type: 'single_line_text_field' },
          ]}
        });
        colors++;
        console.log(`  Color: ${cd.dominantColor} (${cd.dominantHex})`);
      } catch { console.log('  Color: parse error'); errors++; }
      await sleep(1200);
    } else {
      console.log('  Color: already done');
      colors++;
    }

    // 2. BODY: 2 paragraphs with AI description
    const imgB642 = fs.readFileSync(tmpImg).toString('base64');
    const descResult = await geminiCall({
      contents: [{ parts: [
        { text: `Describe this sequin wallcovering called "${p.title}" by Glitter Walls in 2 sentences for a design-trade buyer. Mention the texture, shimmer, and recommended commercial/residential use. Do NOT use "wallpaper" — say "wallcovering".` },
        { inlineData: { mimeType: 'image/jpeg', data: imgB642 } }
      ]}],
      generationConfig: { temperature: 0.3, maxOutputTokens: 200 }
    }, 'gemini-2.0-flash');

    const aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
    const firstPara = `${p.title} is a luxurious sequin wallcovering by Glitter Walls. This eye-catching design adds dramatic shimmer and texture to any interior space.`;
    const newBody = `<p>${firstPara}</p>\n<p>${aiDesc || 'A stunning decorative wallcovering perfect for feature walls in hospitality, retail, and residential settings.'}</p>`;

    await gql({
      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
      variables: { i: { id: p.id, bodyHtml: newBody } }
    });
    bodies++;
    await sleep(1000);

    // 3. ROOM SETTINGS: 2 residential
    const imgB64Wall = fs.readFileSync(tmpImg).toString('base64');
    const roomPrompts = [
      { name: 'Bedroom', prompt: 'This image shows a sequin/glitter wallcovering texture. Use it EXACTLY as the background wall in a luxurious modern bedroom. Place a king bed with velvet bedding, mirrored nightstands with crystal lamps, soft carpet floor. Evening ambient lighting with warm glow. Photorealistic glamorous interior.' },
      { name: 'Living Room', prompt: 'This image shows a sequin/glitter wallcovering texture. Use it EXACTLY as a feature wall behind a modern living room. Place a contemporary sofa, glass coffee table, statement floor lamp, and abstract art on adjacent wall. Soft evening lighting highlighting the shimmer. Photorealistic luxury interior.' },
    ];

    for (const room of roomPrompts) {
      console.log(`  Room: ${room.name}...`);
      const imgResult = await geminiCall({
        contents: [{ parts: [
          { text: room.prompt },
          { inlineData: { mimeType: 'image/jpeg', data: imgB64Wall } }
        ]}],
        generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
      }, 'gemini-2.5-flash-image');

      for (const part of imgResult?.candidates?.[0]?.content?.parts || []) {
        if (part.inlineData) {
          const buf = Buffer.from(part.inlineData.data, 'base64');
          const mid = await uploadImage(p.id, buf, `${p.title} - ${room.name}`);
          if (mid) { rooms++; console.log(`  Room: ${room.name} uploaded (${(buf.length/1024).toFixed(0)}KB)`); }
          break;
        }
      }
      await sleep(2000);
    }
  }

  console.log('\n=== COMPLETE ===');
  console.log(`Colors: ${colors} | Bodies: ${bodies} | Rooms: ${rooms} | Errors: ${errors}`);
}

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