← back to Yolo Agent
scripts/collection-descriptions.js
449 lines
#!/usr/bin/env node
const path = require('path');
const fs = require('fs');
function loadLocalEnv(file) {
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/);
if (!match || process.env[match[1]] !== undefined) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
loadLocalEnv(path.join(__dirname, '..', '.env'));
/**
* Collection Descriptions Writer
* Writes 3-sentence interior-designer descriptions for top 100 collections with empty body_html.
*
* Uses Gemini 2.0 Flash for description generation.
*/
const https = require('https');
const http = require('http');
const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API_VERSION = '2024-07';
const GEMINI_KEY = process.env.GEMINI_API_KEY;
const COLLECTIONS_DASHBOARD_AUTH = process.env.COLLECTIONS_DASHBOARD_AUTH;
const GEMINI_MODEL = 'gemini-2.0-flash';
if (!SHOPIFY_TOKEN || !GEMINI_KEY || !COLLECTIONS_DASHBOARD_AUTH) {
console.error('Missing SHOPIFY_ADMIN_TOKEN, GEMINI_API_KEY, or COLLECTIONS_DASHBOARD_AUTH.');
process.exit(1);
}
const RATE_LIMIT_MS = 600; // 0.6s between Shopify calls
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function shopifyGet(path) {
return new Promise((resolve, reject) => {
const options = {
hostname: SHOPIFY_STORE,
path: `/admin/api/${API_VERSION}${path}`,
method: 'GET',
headers: {
'X-Shopify-Access-Token': SHOPIFY_TOKEN,
'Content-Type': 'application/json'
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
resolve({ status: res.statusCode, data: JSON.parse(body), headers: res.headers });
} catch (e) {
reject(new Error(`Parse error: ${body.substring(0, 200)}`));
}
});
});
req.on('error', reject);
req.end();
});
}
function shopifyPut(path, data) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(data);
const options = {
hostname: SHOPIFY_STORE,
path: `/admin/api/${API_VERSION}${path}`,
method: 'PUT',
headers: {
'X-Shopify-Access-Token': SHOPIFY_TOKEN,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
resolve({ status: res.statusCode, data: JSON.parse(body), headers: res.headers });
} catch (e) {
reject(new Error(`Parse error: ${body.substring(0, 200)}`));
}
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
function geminiGenerate(prompt) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.7,
maxOutputTokens: 300,
}
});
const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
const data = JSON.parse(body);
if (data.candidates && data.candidates[0] && data.candidates[0].content) {
resolve(data.candidates[0].content.parts[0].text.trim());
} else {
reject(new Error(`Gemini error: ${JSON.stringify(data).substring(0, 300)}`));
}
} catch (e) {
reject(new Error(`Gemini parse error: ${body.substring(0, 200)}`));
}
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
function fetchCollections() {
return new Promise((resolve, reject) => {
const auth = Buffer.from(COLLECTIONS_DASHBOARD_AUTH).toString('base64');
const options = {
hostname: 'localhost',
port: 9501,
path: '/api/collections',
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`
}
};
const req = http.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error(`Parse error: ${body.substring(0, 200)}`));
}
});
});
req.on('error', reject);
req.end();
});
}
function categorizeCollection(title) {
const t = title.toLowerCase();
// Vendor collections
const vendors = [
'kravet', 'thibaut', 'scalamandre', 'phillip jeffries', 'phillipe romano',
'brunschwig', 'lee jofa', 'clarke', 'fentucci', 'jeffrey stevens',
'bespoke', 'hollywood', 'graham & brown', 'cole & son', 'osborne',
'schumacher', 'ralph lauren', 'arte', 'elitis', 'york', 'brewster',
'dw bespoke'
];
for (const v of vendors) {
if (t.includes(v)) return 'vendor';
}
// Color collections
const colors = [
'blue', 'green', 'red', 'grey', 'gray', 'brown', 'black', 'white',
'pink', 'yellow', 'orange', 'beige', 'cream', 'coral', 'chocolate',
'metallic', 'multi'
];
for (const c of colors) {
if (t.includes(c)) return 'color';
}
// Material collections
const materials = [
'grasscloth', 'sisal', 'burlap', 'cotton', 'vinyl', 'linen', 'silk',
'leather', 'type ii', 'type 2', 'faux', 'natural', 'fabric', 'paint',
'paper backed'
];
for (const m of materials) {
if (t.includes(m)) return 'material';
}
// Pattern collections
const patterns = [
'stripe', 'plaid', 'geometric', 'floral', 'damask', 'chinoiserie',
'tropical', 'animal', 'birds', 'scenic', 'mural', 'botanical',
'novelty', 'abstract', 'trees', 'frames', 'pattern'
];
for (const p of patterns) {
if (t.includes(p)) return 'pattern';
}
// Style collections
const styles = [
'contemporary', 'modern', 'traditional', 'rustic', 'european',
'transitional', 'hamptons', 'commercial', 'durable', 'best seller',
'new arrival', 'exclusive', 'featured', 'imported', 'made in italy',
'budget'
];
for (const s of styles) {
if (t.includes(s)) return 'style';
}
// Functional
if (t.includes('upholstery') || t.includes('kids') || t.includes('on demand')) return 'functional';
// Alphabetical index
if (/^[a-z]-[a-z]$/.test(t.trim()) || t.includes('a-c') || t.includes('d-l') || t.includes('m-o') || t.includes('p-z')) return 'index';
return 'general';
}
function buildPrompt(title, productCount, category) {
const baseInstruction = `You are a senior interior designer writing catalog copy for a luxury wallcovering trade showroom. Write EXACTLY 3 sentences. Be authoritative, concise, and trade-professional.
VOCABULARY to use naturally: ground, colorway, substrate, hand, repeat, half-drop, selvage, bolt, mill, specification-grade, contract-grade, railroaded, Type II
NEVER use these words: elevate, transform, stunning, beautiful, perfect for, curated
The description will be wrapped in a <p> tag. Do NOT include any HTML tags in your response. Just plain text, 3 sentences.`;
const categoryGuide = {
vendor: `This is a vendor/brand collection. Mention the brand's heritage, what they're known for in the trade, and what substrates or specialties define their line.`,
color: `This is a color-themed collection. Describe the palette range (don't just name the color), which rooms and light conditions it suits, and what hardware/millwork it coordinates with.`,
material: `This is a material-focused collection. Describe the tactile quality and hand of the material, its durability classification and application, and installation considerations.`,
pattern: `This is a pattern-focused collection. Describe the design language, the repeat and scale range, and what architectural contexts suit the pattern.`,
style: `This is a style/era collection. Reference the design period or aesthetic movement, the architectural context, and the client profile it serves.`,
functional: `This is a functional/application collection. Describe the performance characteristics, specification context, and end-use applications.`,
index: `This is an alphabetical index collection for browsing. Describe it as a curated alphabetical selection spanning multiple vendors and styles, noting the breadth of the offering.`,
general: `Write a general trade description noting the breadth of the collection and its place in the catalog.`
};
return `${baseInstruction}
${categoryGuide[category] || categoryGuide.general}
Collection: "${title}"
Products in collection: ${productCount}
Write 3 sentences. No preamble, no quotes, no HTML. Just the description text.`;
}
async function main() {
console.log('=== Collection Descriptions Writer ===');
console.log(`Started: ${new Date().toISOString()}\n`);
// Step 1: Fetch all collections
console.log('Fetching collections from collections-agent...');
const collData = await fetchCollections();
const collections = collData.collections;
console.log(`Total collections: ${collections.length}`);
// Sort by product count DESC, take top 100
collections.sort((a, b) => (b.productCount || 0) - (a.productCount || 0));
const top100 = collections.slice(0, 100);
console.log(`Top 100 range: ${top100[0].productCount} to ${top100[top100.length - 1].productCount} products\n`);
// Step 2: Check each collection's body_html
const needsDescription = [];
let checked = 0;
let alreadyHasBody = 0;
let errors = 0;
for (const coll of top100) {
const numericId = coll.id.split('/').pop();
const isSmart = coll.isSmartCollection;
const endpoint = isSmart
? `/smart_collections/${numericId}.json`
: `/custom_collections/${numericId}.json`;
try {
const result = await shopifyGet(endpoint);
checked++;
if (result.status === 429) {
console.log(` Rate limited at ${checked}, waiting 2s...`);
await sleep(2000);
// Retry
const retry = await shopifyGet(endpoint);
if (retry.status === 429) {
console.log(` Still rate limited, waiting 5s...`);
await sleep(5000);
const retry2 = await shopifyGet(endpoint);
if (retry2.status !== 200) {
errors++;
continue;
}
const key2 = isSmart ? 'smart_collection' : 'custom_collection';
const bodyHtml2 = retry2.data[key2]?.body_html || '';
if (!bodyHtml2 || bodyHtml2.trim() === '' || bodyHtml2.trim() === '<p></p>') {
needsDescription.push({ ...coll, numericId });
} else {
alreadyHasBody++;
}
continue;
}
const key = isSmart ? 'smart_collection' : 'custom_collection';
const bodyHtml = retry.data[key]?.body_html || '';
if (!bodyHtml || bodyHtml.trim() === '' || bodyHtml.trim() === '<p></p>') {
needsDescription.push({ ...coll, numericId });
} else {
alreadyHasBody++;
}
continue;
}
if (result.status !== 200) {
console.log(` Error ${result.status} for ${coll.title} (${numericId})`);
errors++;
await sleep(RATE_LIMIT_MS);
continue;
}
const key = isSmart ? 'smart_collection' : 'custom_collection';
const bodyHtml = result.data[key]?.body_html || '';
if (!bodyHtml || bodyHtml.trim() === '' || bodyHtml.trim() === '<p></p>') {
needsDescription.push({ ...coll, numericId });
console.log(` [${checked}/100] EMPTY: ${coll.title} (${coll.productCount} products)`);
} else {
alreadyHasBody++;
console.log(` [${checked}/100] HAS BODY: ${coll.title}`);
}
} catch (err) {
console.log(` Error checking ${coll.title}: ${err.message}`);
errors++;
}
await sleep(RATE_LIMIT_MS);
}
console.log(`\n--- Check Summary ---`);
console.log(`Checked: ${checked}`);
console.log(`Already have body_html: ${alreadyHasBody}`);
console.log(`Need descriptions: ${needsDescription.length}`);
console.log(`Errors: ${errors}\n`);
if (needsDescription.length === 0) {
console.log('All top 100 collections already have descriptions. Done!');
return;
}
// Step 3: Generate and write descriptions
let written = 0;
let genErrors = 0;
const samples = [];
for (const coll of needsDescription) {
const category = categorizeCollection(coll.title);
const prompt = buildPrompt(coll.title, coll.productCount, category);
try {
// Generate with Gemini
const description = await geminiGenerate(prompt);
// Clean up — remove any quotes or HTML that Gemini might add
let cleaned = description
.replace(/^["']|["']$/g, '')
.replace(/<[^>]+>/g, '')
.trim();
// Ensure max 3 sentences
const sentences = cleaned.match(/[^.!?]+[.!?]+/g) || [cleaned];
if (sentences.length > 3) {
cleaned = sentences.slice(0, 3).join(' ').trim();
}
const bodyHtml = `<p>${cleaned}</p>`;
// Update via Shopify API
const isSmart = coll.isSmartCollection;
const endpoint = isSmart
? `/smart_collections/${coll.numericId}.json`
: `/custom_collections/${coll.numericId}.json`;
const key = isSmart ? 'smart_collection' : 'custom_collection';
const payload = { [key]: { id: parseInt(coll.numericId), body_html: bodyHtml } };
const result = await shopifyPut(endpoint, payload);
if (result.status === 429) {
console.log(` Rate limited writing ${coll.title}, waiting 3s and retrying...`);
await sleep(3000);
const retry = await shopifyPut(endpoint, payload);
if (retry.status === 200) {
written++;
console.log(` [${written}] WROTE (retry): ${coll.title}`);
if (samples.length < 10) samples.push({ title: coll.title, productCount: coll.productCount, category, description: cleaned });
} else {
console.log(` FAILED (retry) ${coll.title}: ${retry.status}`);
genErrors++;
}
} else if (result.status === 200) {
written++;
console.log(` [${written}] WROTE: ${coll.title} [${category}]`);
if (samples.length < 10) samples.push({ title: coll.title, productCount: coll.productCount, category, description: cleaned });
} else {
console.log(` FAILED ${coll.title}: ${result.status} - ${JSON.stringify(result.data).substring(0, 200)}`);
genErrors++;
}
} catch (err) {
console.log(` GEN ERROR ${coll.title}: ${err.message}`);
genErrors++;
}
await sleep(RATE_LIMIT_MS);
}
console.log(`\n=== FINAL REPORT ===`);
console.log(`Collections checked: ${checked}`);
console.log(`Already had descriptions: ${alreadyHasBody}`);
console.log(`Descriptions written: ${written}`);
console.log(`Errors: ${errors + genErrors}`);
console.log(`\n--- Sample Descriptions ---`);
for (const s of samples) {
console.log(`\n[${s.category.toUpperCase()}] ${s.title} (${s.productCount} products):`);
console.log(` "${s.description}"`);
}
console.log(`\nCompleted: ${new Date().toISOString()}`);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});