← back to Letsbegin

nina_crop.js

448 lines

#!/usr/bin/env node
/**
 * Nina Campbell Image Cropper
 *
 * Fetches all Nina Campbell products from Shopify, downloads each product's
 * first image, crops it to a square (width x width from top), uploads the
 * cropped image, reorders it to position 1, and deletes the old image.
 *
 * Skips images that are already square (aspect ratio 0.9 - 1.1).
 */

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

const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
if (!TOKEN) {
  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
}
const API_VER = '2024-10';
const LOG_FILE = '/tmp/nina_crop.log';
const TMP_DIR = '/tmp/nina_crop_tmp';

// Ensure tmp dir exists
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });

// Logging
function log(msg) {
  const line = `[${new Date().toISOString()}] ${msg}`;
  console.log(line);
  fs.appendFileSync(LOG_FILE, line + '\n');
}

// GraphQL helper using native https
function gql(query, variables) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({ query, variables });
    const req = https.request({
      hostname: STORE,
      path: `/admin/api/${API_VER}/graphql.json`,
      method: 'POST',
      headers: {
        'X-Shopify-Access-Token': TOKEN,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(body),
      },
    }, (res) => {
      let data = '';
      res.on('data', (chunk) => data += chunk);
      res.on('end', () => {
        try {
          resolve(JSON.parse(data));
        } catch (e) {
          reject(new Error(`JSON parse error: ${data.slice(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

// Download file following redirects
function download(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();
        fs.unlinkSync(dest);
        return download(res.headers.location, dest).then(resolve).catch(reject);
      }
      if (res.statusCode !== 200) {
        file.close();
        return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
      }
      res.pipe(file);
      file.on('finish', () => { file.close(); resolve(); });
    }).on('error', (e) => { file.close(); reject(e); });
  });
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Fetch ALL Nina Campbell products with pagination
async function fetchAllProducts() {
  const products = [];
  let cursor = null;
  let page = 0;

  while (true) {
    page++;
    const afterClause = cursor ? `, after: "${cursor}"` : '';
    const query = `{
      products(first: 50, query: "vendor:'Nina Campbell'"${afterClause}) {
        edges {
          cursor
          node {
            id
            title
            media(first: 10) {
              edges {
                node {
                  ... on MediaImage {
                    id
                    image {
                      url
                      width
                      height
                    }
                  }
                }
              }
            }
          }
        }
        pageInfo {
          hasNextPage
        }
      }
    }`;

    const res = await gql(query);
    if (res.errors) {
      log(`GraphQL error on page ${page}: ${JSON.stringify(res.errors)}`);
      break;
    }

    const edges = res?.data?.products?.edges || [];
    for (const edge of edges) {
      products.push(edge.node);
      cursor = edge.cursor;
    }

    log(`Fetched page ${page}: ${edges.length} products (total: ${products.length})`);

    if (!res?.data?.products?.pageInfo?.hasNextPage) break;
    await sleep(300);
  }

  return products;
}

// Get image dimensions using ImageMagick identify
function getImageDimensions(filePath) {
  const output = execFileSync('identify', ['-format', '%wx%h', filePath], {
    timeout: 10000,
    encoding: 'utf8',
  }).trim();
  // Handle multi-frame images (animated gifs etc) - take first frame
  const firstFrame = output.split('\n')[0].split(/\s/)[0];
  const [w, h] = firstFrame.split('x').map(Number);
  return { width: w, height: h };
}

// Crop image to square from top
function cropToSquare(inputPath, outputPath, width) {
  execFileSync('convert', [
    inputPath,
    '-crop', `${width}x${width}+0+0`,
    '+repage',
    '-quality', '93',
    outputPath,
  ], { timeout: 30000 });
}

// Staged upload for a file
async function stagedUpload(filePath, filename) {
  const fileSize = fs.statSync(filePath).size;
  const mimeType = filename.endsWith('.png') ? 'image/png' : 'image/jpeg';

  const mutation = `mutation {
    stagedUploadsCreate(input: [{
      resource: IMAGE,
      filename: "${filename}",
      mimeType: "${mimeType}",
      httpMethod: POST,
      fileSize: "${fileSize}"
    }]) {
      stagedTargets {
        url
        parameters { name value }
        resourceUrl
      }
      userErrors { message }
    }
  }`;

  const res = await gql(mutation);
  const target = res?.data?.stagedUploadsCreate?.stagedTargets?.[0];
  if (!target) {
    throw new Error(`Staged upload failed: ${JSON.stringify(res?.data?.stagedUploadsCreate?.userErrors || res?.errors)}`);
  }

  // Upload via curl execFileSync
  const curlArgs = ['-s', '-X', 'POST', target.url];
  for (const param of target.parameters) {
    curlArgs.push('-F', `${param.name}=${param.value}`);
  }
  curlArgs.push('-F', `file=@${filePath}`);
  execFileSync('curl', curlArgs, { timeout: 60000 });

  return target.resourceUrl;
}

// Create media on product
async function createMedia(productGid, resourceUrl, altText) {
  const mutation = `mutation {
    productCreateMedia(productId: "${productGid}", media: [{
      originalSource: "${resourceUrl}",
      mediaContentType: IMAGE,
      alt: "${altText.replace(/"/g, '\\"')}"
    }]) {
      media {
        id
      }
      mediaUserErrors {
        message
      }
    }
  }`;

  const res = await gql(mutation);
  const media = res?.data?.productCreateMedia?.media;
  const errors = res?.data?.productCreateMedia?.mediaUserErrors;
  if (errors && errors.length > 0) {
    throw new Error(`Create media error: ${errors.map(e => e.message).join(', ')}`);
  }
  if (!media || media.length === 0) {
    throw new Error(`No media returned: ${JSON.stringify(res)}`);
  }
  return media[0].id;
}

// Wait for media to be READY (poll)
async function waitForMediaReady(productGid, mediaGid, maxWait = 30000) {
  const start = Date.now();
  while (Date.now() - start < maxWait) {
    const query = `{
      product(id: "${productGid}") {
        media(first: 20) {
          edges {
            node {
              ... on MediaImage {
                id
                status
              }
            }
          }
        }
      }
    }`;
    const res = await gql(query);
    const edges = res?.data?.product?.media?.edges || [];
    for (const edge of edges) {
      if (edge.node.id === mediaGid) {
        if (edge.node.status === 'READY') return true;
        if (edge.node.status === 'FAILED') return false;
      }
    }
    await sleep(2000);
  }
  return false; // timeout
}

// Reorder media so new image is first
async function reorderMedia(productGid, allMediaIds) {
  // allMediaIds should be in desired order (new image first)
  const moves = allMediaIds.map((id, idx) => `{id: "${id}", newPosition: "${idx}"}`);
  const mutation = `mutation {
    productReorderMedia(id: "${productGid}", moves: [${moves.join(',')}]) {
      mediaUserErrors {
        message
      }
    }
  }`;

  const res = await gql(mutation);
  const errors = res?.data?.productReorderMedia?.mediaUserErrors;
  if (errors && errors.length > 0) {
    throw new Error(`Reorder error: ${errors.map(e => e.message).join(', ')}`);
  }
}

// Delete media from product
async function deleteMedia(productGid, mediaIds) {
  const idsStr = mediaIds.map(id => `"${id}"`).join(',');
  const mutation = `mutation {
    productDeleteMedia(productId: "${productGid}", mediaIds: [${idsStr}]) {
      deletedMediaIds
      mediaUserErrors {
        message
      }
    }
  }`;

  const res = await gql(mutation);
  const errors = res?.data?.productDeleteMedia?.mediaUserErrors;
  if (errors && errors.length > 0) {
    throw new Error(`Delete media error: ${errors.map(e => e.message).join(', ')}`);
  }
  return res?.data?.productDeleteMedia?.deletedMediaIds || [];
}

async function main() {
  log('=== Nina Campbell Image Cropper ===');
  log('Fetching all Nina Campbell products...');

  const products = await fetchAllProducts();
  log(`Total products found: ${products.length}`);

  let cropped = 0, skippedSquare = 0, skippedNoImage = 0, errors = 0;

  for (let i = 0; i < products.length; i++) {
    const product = products[i];
    const shortTitle = product.title.slice(0, 50);

    // Get the first MediaImage
    const mediaEdges = product.media?.edges || [];
    const firstImage = mediaEdges.find(e => e.node.image);
    if (!firstImage) {
      log(`  [${i + 1}/${products.length}] ${shortTitle} — NO IMAGE, skip`);
      skippedNoImage++;
      continue;
    }

    const imgNode = firstImage.node;
    const imgUrl = imgNode.image.url;
    const origW = imgNode.image.width;
    const origH = imgNode.image.height;
    const oldMediaId = imgNode.id;

    // Check if already square
    const ratio = origW / origH;
    if (ratio >= 0.9 && ratio <= 1.1) {
      log(`  [${i + 1}/${products.length}] ${shortTitle} — already square (${origW}x${origH}), skip`);
      skippedSquare++;
      continue;
    }

    // Check it's taller than wide (expected ~0.66 ratio)
    if (origW >= origH) {
      log(`  [${i + 1}/${products.length}] ${shortTitle} — wider than tall (${origW}x${origH}), skip`);
      skippedSquare++;
      continue;
    }

    const inputFile = path.join(TMP_DIR, `input_${i}.jpg`);
    const outputFile = path.join(TMP_DIR, `output_${i}.jpg`);

    try {
      // 1. Download
      await download(imgUrl, inputFile);

      // 2. Verify dimensions with ImageMagick
      const dims = getImageDimensions(inputFile);
      const localRatio = dims.width / dims.height;
      if (localRatio >= 0.9 && localRatio <= 1.1) {
        log(`  [${i + 1}/${products.length}] ${shortTitle} — measured square (${dims.width}x${dims.height}), skip`);
        skippedSquare++;
        continue;
      }

      // 3. Crop to square from top
      cropToSquare(inputFile, outputFile, dims.width);
      log(`  [${i + 1}/${products.length}] ${shortTitle} — cropped ${dims.width}x${dims.height} → ${dims.width}x${dims.width}`);

      // 4. Staged upload
      const ext = imgUrl.includes('.png') ? '.png' : '.jpg';
      const filename = `nina_crop_${product.id.split('/').pop()}${ext}`;
      const resourceUrl = await stagedUpload(outputFile, filename);
      await sleep(500);

      // 5. Create media on product
      const altText = product.title.split('|')[0].trim();
      const newMediaId = await createMedia(product.id, resourceUrl, altText);
      log(`    Created new media: ${newMediaId}`);

      // 6. Wait for media to be READY
      const ready = await waitForMediaReady(product.id, newMediaId, 45000);
      if (!ready) {
        log(`    WARNING: Media not ready after 45s, proceeding anyway`);
      }

      // 7. Fetch current media list to build reorder array
      const mediaQuery = `{
        product(id: "${product.id}") {
          media(first: 20) {
            edges {
              node {
                ... on MediaImage { id }
              }
            }
          }
        }
      }`;
      const mediaRes = await gql(mediaQuery);
      const currentMedia = (mediaRes?.data?.product?.media?.edges || [])
        .map(e => e.node.id)
        .filter(id => id); // filter out nulls from non-MediaImage types

      // Put new image first, then all others except old image
      const reordered = [newMediaId, ...currentMedia.filter(id => id !== newMediaId && id !== oldMediaId)];
      await reorderMedia(product.id, reordered);
      log(`    Reordered: new image is now position 1`);

      // 8. Delete old uncropped image
      const deleted = await deleteMedia(product.id, [oldMediaId]);
      log(`    Deleted old image: ${deleted.join(', ')}`);

      cropped++;

      // Cleanup tmp files
      if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
      if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);

    } catch (err) {
      log(`  [${i + 1}/${products.length}] ${shortTitle} — ERROR: ${err.message}`);
      errors++;
      // Cleanup on error
      if (fs.existsSync(inputFile)) try { fs.unlinkSync(inputFile); } catch (_) {}
      if (fs.existsSync(outputFile)) try { fs.unlinkSync(outputFile); } catch (_) {}
    }

    // Rate limit: ~1.5 req/s average (each product does ~6 API calls)
    await sleep(600);
  }

  log('');
  log('=== SUMMARY ===');
  log(`Total products: ${products.length}`);
  log(`Cropped & updated: ${cropped}`);
  log(`Skipped (already square): ${skippedSquare}`);
  log(`Skipped (no image): ${skippedNoImage}`);
  log(`Errors: ${errors}`);
}

main().catch((e) => {
  log(`FATAL: ${e.message}`);
  console.error(e);
  process.exit(1);
});