← back to Letsbegin

fix_rew_products.js

219 lines

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 (!TOKEN || !GEMINI_KEY) {
  throw new Error('SHOPIFY_PRODUCT_TOKEN and GEMINI_API_KEY environment variables are required');
}

function gql(body) {
  return new Promise((res, rej) => {
    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) }
    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>res(JSON.parse(c))); });
    req.on('error', rej); req.setTimeout(30000, ()=>{req.destroy();rej(new Error('timeout'))}); req.write(data); req.end();
  });
}
function rest(method, path, body) {
  return new Promise((res, rej) => {
    const data = body ? JSON.stringify(body) : null;
    const req = https.request({ hostname: STORE, path: '/admin/api/2024-10' + path, method,
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(data && { 'Content-Length': Buffer.byteLength(data) }) }
    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(c)} }); });
    req.on('error', rej); if(data) req.write(data); req.end();
  });
}
function download(url, dest) {
  return new Promise((res, rej) => {
    const mod = url.startsWith('https') ? https : http;
    const file = fs.createWriteStream(dest);
    mod.get(url, {headers:{'User-Agent':'Mozilla/5.0'}}, r => {
      if(r.statusCode>=300&&r.headers.location){file.close();return download(r.headers.location,dest).then(res).catch(rej)}
      r.pipe(file); file.on('finish',()=>{file.close();res()});
    }).on('error',e=>{file.close();rej(e)});
  });
}
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function main() {
  console.log('=== REW- Product Fixer: Title + Price + Image Crop ===\n');

  let cursor = null;
  const products = [];
  while (true) {
    const after = cursor ? `, after: "${cursor}"` : '';
    const r = await gql({ query: `{ products(first: 50, query: "sku:REW-* AND status:active"${after}) { edges { cursor node { id title vendor images(first:1) { edges { node { id url width height } } } variants(first:3) { edges { node { id sku title price } } } } } pageInfo { hasNextPage } } }` });
    for (const e of r.data.products.edges) { products.push(e.node); cursor = e.cursor; }
    if (!r.data.products.pageInfo.hasNextPage) break;
    await sleep(500);
  }
  console.log(`${products.length} REW products\n`);

  let titles = 0, prices = 0, crops = 0, errors = 0;

  for (let i = 0; i < products.length; i++) {
    const p = products[i];
    const title = p.title;

    // 1. FIX TITLE: add "Wallcovering | Hollywood Wallcoverings" if missing
    let newTitle = title;
    if (!title.includes('| Hollywood')) {
      // Strip existing vendor suffix if any
      const base = title.split('|')[0].trim();
      // Add Wallcovering if not already there
      const withWC = base.includes('Wallcovering') ? base : base + ' Wallcovering';
      newTitle = `${withWC} | Hollywood Wallcoverings`;
    }

    if (newTitle !== title) {
      await gql({
        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
        variables: { i: { id: p.id, title: newTitle } }
      });
      titles++;
    }

    // 2. FIX PRICE: yard variants to $66.95
    for (const v of p.variants.edges) {
      const variant = v.node;
      if (!variant.sku.includes('sample') && variant.price !== '66.95') {
        const vid = variant.id.split('/').pop();
        await rest('PUT', `/variants/${vid}.json`, { variant: { id: parseInt(vid), price: '66.95' } });
        prices++;
        await sleep(500);
      }
    }

    // 3. CROP IMAGE: remove bottom logo footer (~15% of height)
    const img = p.images?.edges?.[0]?.node;
    if (img && img.height > 500 && img.width < img.height) {
      // Image is portrait with logo at bottom — crop bottom ~15%
      const cropHeight = Math.round(img.height * 0.85); // keep top 85%

      try {
        const tmpOrig = '/tmp/rew_orig.jpg';
        const tmpCrop = '/tmp/rew_crop.jpg';
        await download(img.url, tmpOrig);

        // Verify it has a logo footer (bottom section is different)
        execFileSync('convert', [tmpOrig, '-crop', `${img.width}x${cropHeight}+0+0`, '+repage', '-quality', '93', tmpCrop]);

        const cropSize = fs.statSync(tmpCrop).size;

        // Upload cropped version
        const stage = await gql({
          query: `mutation { stagedUploadsCreate(input: [{resource: IMAGE, filename: "cropped.jpg", mimeType: "image/jpeg", httpMethod: POST, fileSize: "${cropSize}"}]) { stagedTargets { url parameters { name value } resourceUrl } } }`
        });
        const target = stage?.data?.stagedUploadsCreate?.stagedTargets?.[0];
        if (target) {
          const curlArgs = ['-s', '-X', 'POST', target.url];
          for (const pm of target.parameters) curlArgs.push('-F', `${pm.name}=${pm.value}`);
          curlArgs.push('-F', `file=@${tmpCrop}`);
          execFileSync('curl', curlArgs, { timeout: 30000 });

          // Add cropped image
          const pid = p.id.split('/').pop();
          await gql({
            query: `mutation { productCreateMedia(productId: "gid://shopify/Product/${pid}", media: [{originalSource: "${target.resourceUrl}", mediaContentType: IMAGE, alt: "${newTitle.split('|')[0].trim()}"}]) { media { id } mediaUserErrors { message } } }`
          });

          // Delete old image
          await gql({
            query: `mutation { productDeleteMedia(productId: "gid://shopify/Product/${pid}", mediaIds: ["${img.id}"]) { deletedMediaIds mediaUserErrors { message } } }`
          });

          crops++;
        }
      } catch (e) { errors++; }
      await sleep(800);
    }

    // 4. AI COLOR ANALYSIS + SPECS (full monty minus room settings)
    if (img) {
      try {
        const tmpImg = fs.existsSync('/tmp/rew_crop.jpg') ? '/tmp/rew_crop.jpg' : '/tmp/rew_orig.jpg';
        const b64 = fs.readFileSync(tmpImg).toString('base64');

        // Gemini color analysis
        const cr = await new Promise((res) => {
          const data = JSON.stringify({
            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: b64 } }
            ]}],
            generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
          });
          const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash: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) }
          }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(null)} }); });
          req.on('error', ()=>res(null)); req.write(data); req.end();
        });

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

        // Set color + spec metafields
        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' },
            { ownerId: p.id, namespace: 'custom', key: 'width', value: '54 Inches', type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'material', value: 'Type II Vinyl Wallcovering', type: 'multi_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'fire_rating', value: 'ASTM E-84 Class A', type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'brand', value: 'Hollywood Wallcoverings', type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'unit_of_measure', value: 'Sold Per Yard', type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'packaging', value: 'Full Roll', type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'cost', value: '36.99', type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'designer_net', value: '56.90', type: 'single_line_text_field' },
            { ownerId: p.id, namespace: 'custom', key: 'dw_price', value: '66.95', type: 'single_line_text_field' },
          ]}
        });

        // AI body description
        const dr = await new Promise((res) => {
          const data = JSON.stringify({
            contents: [{ parts: [
              { text: `Describe this vinyl wallcovering called "${newTitle.split('|')[0].trim()}" in one sentence for a design-trade buyer. Mention the texture and recommended use. Do NOT say "wallpaper".` },
              { inlineData: { mimeType: 'image/jpeg', data: b64 } }
            ]}],
            generationConfig: { temperature: 0.3, maxOutputTokens: 100 }
          });
          const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash: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) }
          }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(null)} }); });
          req.on('error', ()=>res(null)); req.write(data); req.end();
        });

        const aiDesc = (dr?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
        const baseName = newTitle.split('|')[0].trim();
        const body = `<p>${baseName} is a premium vinyl wallcovering by Hollywood Wallcoverings. This commercial-grade design delivers sophisticated texture and durability for high-traffic environments.</p>\n<p>${aiDesc || 'Ideal for hospitality, commercial, and upscale residential interiors.'}</p>`;

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

        console.log(`  Color: ${cd.dominantColor} | Specs+Body set`);
      } catch (e) { errors++; }
      await sleep(1500);
    }

    if ((i+1) % 10 === 0) console.log(`[${i+1}/${products.length}] titles:${titles} prices:${prices} crops:${crops} errors:${errors}`);
  }

  console.log(`\n=== COMPLETE ===`);
  console.log(`Titles fixed: ${titles} | Prices fixed: ${prices} | Images cropped: ${crops} | Errors: ${errors}`);
}
main().catch(e => { console.error('Fatal:', e); process.exit(1); });