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