Skip to content

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(/&nbsp;/g, ' ').replace(/&amp;/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": {}
}