AI Product Description Generator
Module 22 · ai · intermediate
select.js
// Normalize products (API body or Shopify) and pick the ones that need copy, up to the batch size.
const s = $('Settings (edit me)').first().json;
const viaApi = $('Generate API').isExecuted;
const body = viaApi ? $('Generate API').first().json.body ?? {} : {};
const fromShopify = $('Get Shopify Products').isExecuted;
const strip = (html) => String(html ?? '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim();
const words = (t) => (t ? t.split(/\s+/).length : 0);
const toList = (t) => (Array.isArray(t) ? t : String(t ?? '').split(',')).map((x) => String(x).trim()).filter(Boolean);
let raw = [], source_error = null;
if (fromShopify) {
const items = $('Get Shopify Products').all().map((i) => i.json);
if (items.some((p) => p.id)) raw = items.filter((p) => p.id).map((p) => ({
id: String(p.id), title: p.title, product_type: p.product_type, vendor: p.vendor, tags: p.tags, body_html: p.body_html,
// Shopify keeps specs in options/variants; take what's there (Exercise 2 adds metafields).
attributes: Object.fromEntries((p.options ?? []).filter((o) => o.name !== 'Title').map((o) => [o.name.toLowerCase(), (o.values ?? []).join(', ')])),
price: p.variants?.[0]?.price,
}));
else source_error = String(items[0]?.error?.message ?? items[0]?.error ?? 'Shopify returned no products (check the Shopify credential)').slice(0, 300);
} else raw = body.products ?? [];
const all = raw.map((p) => {
const existing = strip(p.body_html);
return {
product_id: String(p.id ?? p.product_id ?? ''),
title: String(p.title ?? '').trim(),
product_type: p.product_type ?? '',
vendor: p.vendor ?? '',
tags: toList(p.tags),
attributes: p.attributes ?? {},
price: p.price ?? null,
existing_text: existing,
existing_words: words(existing),
draft: p.draft ?? null, // copy written elsewhere: skip the AI, just run the checks
};
}).filter((p) => p.product_id && p.title);
const force = body.force === true;
const drafts = $getWorkflowStaticData('global').drafts ?? {};
const inReview = (p) => ['pending', 'rejected'].includes(drafts[p.product_id]?.state); // don't overwrite a reviewer's queue
const needs = all.filter((p) => force || p.draft || (!inReview(p) && (p.existing_words < s.min_existing_words || /\b(todo|tbd|lorem ipsum)\b/i.test(p.existing_text))));
const batchSize = Number(body.batch_size ?? s.batch_size);
const batch = needs.slice(0, batchSize);
const meta = {
_run: {
products_seen: all.length, needing_copy: needs.length, in_batch: batch.length, remaining: needs.length - batch.length,
skipped_in_review: all.filter((p) => !needs.includes(p) && inReview(p)).map((p) => ({ product_id: p.product_id, title: p.title, state: drafts[p.product_id].state })),
skipped_has_copy: all.filter((p) => !needs.includes(p) && !inReview(p)).map((p) => ({ product_id: p.product_id, title: p.title, words: p.existing_words })),
source: fromShopify ? 'shopify' : 'api', source_error, dry_run: body.dry_run === true, via_api: viaApi,
use_ai: s.use_ai && body.ai !== false,
},
};
if (!batch.length) return [{ json: { nothing_to_do: true, ...meta } }];
return batch.map((p, i) => ({ json: { ...p, use_ai: meta._run.use_ai && !p.draft, ...(i === 0 ? meta : {}) } }));