AI Product Description Generator
Module 22 · ai · intermediate
starter.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "22 · AI Product Description Generator (starter)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 7
}
]
}
},
"id": "m22-daily-7-am",
"name": "Daily 7 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "pd/generate",
"responseMode": "responseNode",
"options": {}
},
"id": "m22-generate-api",
"name": "Generate API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
180
],
"webhookId": "course-22-generate"
},
{
"parameters": {
"httpMethod": "GET",
"path": "pd/review",
"responseMode": "responseNode",
"options": {}
},
"id": "m22-review-api",
"name": "Review API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
400
],
"webhookId": "course-22-review"
},
{
"parameters": {
"httpMethod": "POST",
"path": "pd/review/decide",
"responseMode": "responseNode",
"options": {}
},
"id": "m22-decide-api",
"name": "Decide API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
580
],
"webhookId": "course-22-decide"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "brand_name",
"value": "Juniper & Oak",
"type": "string"
},
{
"id": "s2",
"name": "voice",
"value": "Warm, calm and specific. Short sentences. Talks about how the piece feels and fits into a home, not hype.",
"type": "string"
},
{
"id": "s3",
"name": "audience",
"value": "Homeowners and renters furnishing a cozy, natural-looking home",
"type": "string"
},
{
"id": "s4",
"name": "banned_phrases",
"value": "={{ ['best ever', 'miracle', 'guaranteed', 'world-class', 'revolutionary', 'cure', 'eco-friendly', 'non-toxic', 'hypoallergenic', 'game-changer'] }}",
"type": "array"
},
{
"id": "s5",
"name": "max_words",
"value": 120,
"type": "number"
},
{
"id": "s6",
"name": "min_existing_words",
"value": 30,
"type": "number"
},
{
"id": "s7",
"name": "batch_size",
"value": 10,
"type": "number"
},
{
"id": "s8",
"name": "max_tags",
"value": 10,
"type": "number"
},
{
"id": "s9",
"name": "use_ai",
"value": true,
"type": "boolean"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m22-settings-edit-me",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
240,
280
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $('Daily 7 AM').isExecuted || $('Generate API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "generate"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r2",
"leftValue": "={{ $('Review API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "review"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r3",
"leftValue": "={{ $('Decide API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "decide"
}
]
},
"options": {}
},
"id": "m22-route",
"name": "Route",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
460,
280
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $('Generate API').isExecuted && $('Generate API').first().json.body.source !== 'shopify' }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m22-products-from-api",
"name": "Products from API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
700,
60
]
},
{
"parameters": {
"authentication": "accessToken",
"resource": "product",
"operation": "getAll",
"returnAll": false,
"limit": 250,
"additionalFields": {
"published_status": "any"
}
},
"id": "m22-get-shopify-products",
"name": "Get Shopify Products",
"type": "n8n-nodes-base.shopify",
"typeVersion": 1,
"position": [
920,
180
],
"onError": "continueRegularOutput",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// Normalize products (API body or Shopify) and pick the ones that need copy, up to the batch size.\nconst s = $('Settings (edit me)').first().json;\nconst viaApi = $('Generate API').isExecuted;\nconst body = viaApi ? $('Generate API').first().json.body ?? {} : {};\nconst fromShopify = $('Get Shopify Products').isExecuted;\n\nconst strip = (html) => String(html ?? '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\\s+/g, ' ').trim();\nconst words = (t) => (t ? t.split(/\\s+/).length : 0);\nconst toList = (t) => (Array.isArray(t) ? t : String(t ?? '').split(',')).map((x) => String(x).trim()).filter(Boolean);\n\nlet raw = [], source_error = null;\nif (fromShopify) {\n const items = $('Get Shopify Products').all().map((i) => i.json);\n if (items.some((p) => p.id)) raw = items.filter((p) => p.id).map((p) => ({\n id: String(p.id), title: p.title, product_type: p.product_type, vendor: p.vendor, tags: p.tags, body_html: p.body_html,\n // Shopify keeps specs in options/variants; take what's there (Exercise 2 adds metafields).\n attributes: Object.fromEntries((p.options ?? []).filter((o) => o.name !== 'Title').map((o) => [o.name.toLowerCase(), (o.values ?? []).join(', ')])),\n price: p.variants?.[0]?.price,\n }));\n else source_error = String(items[0]?.error?.message ?? items[0]?.error ?? 'Shopify returned no products (check the Shopify credential)').slice(0, 300);\n} else raw = body.products ?? [];\n\nconst all = raw.map((p) => {\n const existing = strip(p.body_html);\n return {\n product_id: String(p.id ?? p.product_id ?? ''),\n title: String(p.title ?? '').trim(),\n product_type: p.product_type ?? '',\n vendor: p.vendor ?? '',\n tags: toList(p.tags),\n attributes: p.attributes ?? {},\n price: p.price ?? null,\n existing_text: existing,\n existing_words: words(existing),\n draft: p.draft ?? null, // copy written elsewhere: skip the AI, just run the checks\n };\n}).filter((p) => p.product_id && p.title);\n\nconst force = body.force === true;\nconst drafts = $getWorkflowStaticData('global').drafts ?? {};\nconst inReview = (p) => ['pending', 'rejected'].includes(drafts[p.product_id]?.state); // don't overwrite a reviewer's queue\nconst 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))));\nconst batchSize = Number(body.batch_size ?? s.batch_size);\nconst batch = needs.slice(0, batchSize);\nconst meta = {\n _run: {\n products_seen: all.length, needing_copy: needs.length, in_batch: batch.length, remaining: needs.length - batch.length,\n 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 })),\n 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 })),\n source: fromShopify ? 'shopify' : 'api', source_error, dry_run: body.dry_run === true, via_api: viaApi,\n use_ai: s.use_ai && body.ai !== false,\n },\n};\nif (!batch.length) return [{ json: { nothing_to_do: true, ...meta } }];\nreturn batch.map((p, i) => ({ json: { ...p, use_ai: meta._run.use_ai && !p.draft, ...(i === 0 ? meta : {}) } }));\n"
},
"id": "m22-pick-products",
"name": "Pick Products",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1140,
60
]
},
{
"parameters": {
"content": "## Your task\nProducts are loaded and the ones needing copy are picked (try a dry run with `products` in the body).\n1. **Use AI?** → **Basic LLM Chain** + OpenAI + structured output (description, bullets, seo_title, meta_description, tags), *On Error: Continue*; **Merge** both branches.\n2. **Check Copy**: AI copy, else a template from the facts; guardrails: banned phrases, numbers not in the product data, length, thin data; SEO limits; safe HTML.\n3. **Queue for Review** (static data) → respond.\n4. Review queue endpoint; approve (with edits) → **Shopify → Product → Update**; reject.\n\nTest: `./test/test.sh`",
"height": 260,
"width": 420
},
"id": "starter-task-note",
"name": "Your Task",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
-340
]
}
],
"connections": {
"Daily 7 AM": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Generate API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Review API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Decide API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Products from API?",
"type": "main",
"index": 0
}
],
[],
[]
]
},
"Products from API?": {
"main": [
[
{
"node": "Pick Products",
"type": "main",
"index": 0
}
],
[
{
"node": "Get Shopify Products",
"type": "main",
"index": 0
}
]
]
},
"Get Shopify Products": {
"main": [
[
{
"node": "Pick Products",
"type": "main",
"index": 0
}
]
]
},
"Pick Products": {
"main": [
[]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}