{
  "name": "Analyse Amazon product reviews for competitive gaps with Bright Data, GPT-5.6, Google Sheets and Slack",
  "nodes": [
    {
      "id": "sticky-main",
      "name": "Sticky Note main",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -700,
        309
      ],
      "parameters": {
        "color": 1,
        "width": 560,
        "height": 885,
        "content": "## Find the complaints about your product that rivals get praised for\n\nPoint this at your own Amazon listing plus up to three competitors and it comes back with the themes behind the star ratings, and the gaps between you.\n\n### How it works\nSubmit the product URLs in the built-in form. Bright Data's Amazon reviews dataset pulls real reviews for every product at once. Because a review pull takes minutes rather than seconds, the workflow uses Bright Data's asynchronous API properly: it triggers the job, then polls the snapshot on a Wait loop with a ten-minute cap until the rows are ready.\n\nReviews are cleaned, weighted towards critical and most-helpful ones, and sent to GPT-5.6 once per product, which returns structured complaint themes, praise themes and feature requests. A comparison step then finds the themes you are criticised for that a competitor is praised for - the actual competitive gaps - and a second model writes a short brief. Findings land in Google Sheets, one row per theme, and a digest goes to Slack.\n\n### Setup\nAdd a Bright Data API key as a Header Auth credential (`Authorization` / `Bearer YOUR_KEY`), connect OpenAI, Google Sheets and Slack, then paste your Sheet URL and channel into Set Analysis Config. Activate the workflow, open the form URL and submit your product links.\n\n### Customization\nSwap the Amazon reviews dataset for Google Maps or Trustpilot reviews to analyse a local business instead, or drop the Slack node and keep the sheet."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec1",
      "name": "Sticky Note sec1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -60,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 696,
        "height": 366,
        "content": "## 1. Collect the products\nThe form takes your Amazon listing plus up to three rivals. Everything else you would tune lives in one config node."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec2",
      "name": "Sticky Note sec2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        816,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 1416,
        "height": 366,
        "content": "## 2. Scrape reviews asynchronously\nA review pull takes minutes, so this triggers the Bright Data job and polls the snapshot every 30 seconds until the rows are ready."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec3",
      "name": "Sticky Note sec3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2412,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 696,
        "height": 366,
        "content": "## 3. Clean and group\nStrips the duplicated half out of Bright Data's repeated fields, keeps the critical and most-helpful reviews, and emits one item per product."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec4",
      "name": "Sticky Note sec4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3288,
        309
      ],
      "parameters": {
        "color": 7,
        "width": 440,
        "height": 591,
        "content": "## 4. Extract themes\nThe extractor runs once per product and returns complaint themes, praise themes and feature requests as structured data."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec5",
      "name": "Sticky Note sec5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3812,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 584,
        "height": 590,
        "content": "## 5. Find the gaps\nA theme you are criticised for and a rival is praised for is a competitive gap. Those are matched here, then written up."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec6",
      "name": "Sticky Note sec6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4576,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 936,
        "height": 366,
        "content": "## 6. Deliver\nOne sheet row per complaint and per gap, plus a Slack digest that leads with the brief."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-warn",
      "name": "Sticky Note warn",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        816,
        736
      ],
      "parameters": {
        "color": 3,
        "width": 380,
        "height": 199,
        "content": "## Reviews take minutes, not seconds\nA single product URL took 92 seconds in testing. Four products with 50 reviews each can take several minutes - that is why the Wait loop exists. Do not lower the Wait below 30 seconds; you will just spend polls."
      },
      "typeVersion": 1
    },
    {
      "id": "t4-trigger",
      "name": "Start Review Analysis",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        0,
        460
      ],
      "parameters": {
        "options": {
          "buttonLabel": "Analyse reviews"
        },
        "formTitle": "Amazon review gap analysis",
        "formFields": {
          "values": [
            {
              "fieldType": "text",
              "fieldLabel": "Your Amazon product URL",
              "placeholder": "https://www.amazon.com/dp/B0CHWRXH8B",
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Competitor product URLs (one per line)",
              "placeholder": "https://www.amazon.com/dp/B09XS7JWHH\nhttps://www.amazon.com/dp/B0863TXGM3",
              "requiredField": true
            }
          ]
        },
        "formDescription": "Compare what buyers say about your product against your competitors."
      },
      "typeVersion": 2.5
    },
    {
      "id": "t4-cfg",
      "name": "Set Analysis Config",
      "type": "n8n-nodes-base.set",
      "position": [
        240,
        460
      ],
      "parameters": {
        "mode": "manual",
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "c0",
              "name": "my_product_url",
              "type": "string",
              "value": "={{ $json['Your Amazon product URL'] }}"
            },
            {
              "id": "c1",
              "name": "competitor_urls",
              "type": "string",
              "value": "={{ $json['Competitor product URLs (one per line)'] }}"
            },
            {
              "id": "c2",
              "name": "reviews_per_product",
              "type": "number",
              "value": 50
            },
            {
              "id": "c3",
              "name": "max_star_to_analyse",
              "type": "number",
              "value": 3
            },
            {
              "id": "c4",
              "name": "sheet_url",
              "type": "string",
              "value": "https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID"
            },
            {
              "id": "c5",
              "name": "slack_channel",
              "type": "string",
              "value": "#product"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "t4-input",
      "name": "Build Scrape Input",
      "type": "n8n-nodes-base.code",
      "position": [
        480,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Turns the form's product URLs into the payload Bright Data expects.\n// The reviews dataset accepts `url` and nothing else - days_range, sort_by and\n// filter_by_star are each rejected with a validation_error - so recency and\n// star filtering happen later, in Rank Themes And Gaps.\nconst cfg = $('Set Analysis Config').first().json;\n\nconst parse = (raw, role) =>\n  String(raw || '')\n    .split(/[\\n,]/)\n    .map((s) => s.trim())\n    .filter(Boolean)\n    .map((line) => {\n      // No URL constructor in the Code node sandbox, so the ASIN comes out\n      // with a regex.\n      const m = line.match(/\\/(?:dp|gp\\/product)\\/([A-Z0-9]{10})/i);\n      if (!m) {\n        throw new Error(\n          `\"${line}\" is not an Amazon product URL. Expected something like https://www.amazon.com/dp/B0CHWRXH8B`\n        );\n      }\n      return { asin: m[1].toUpperCase(), url: line.split('?')[0], role };\n    });\n\nconst mine = parse(cfg.my_product_url, 'mine');\nconst rivals = parse(cfg.competitor_urls, 'competitor');\n\nif (mine.length !== 1) {\n  throw new Error('Give exactly one product of your own in my_product_url.');\n}\nif (!rivals.length) {\n  throw new Error('Add at least one competitor product URL.');\n}\n\n// De-duplicate, and keep the run small enough to stay cheap.\nconst seen = new Set();\nconst products = [...mine, ...rivals].filter((p) => {\n  if (seen.has(p.asin)) return false;\n  seen.add(p.asin);\n  return true;\n}).slice(0, 4);\n\nreturn [{ json: { products, payload: products.map((p) => ({ url: p.url })) } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "t4-fetch",
      "name": "Fetch Reviews From Bright Data",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        876,
        460
      ],
      "parameters": {
        "url": "=https://api.brightdata.com/datasets/v3/trigger?dataset_id=gd_le8e811kzy4ggddlq&format=json&include_errors=true&limit_per_input={{ $('Set Analysis Config').first().json.reviews_per_product }}",
        "method": "POST",
        "options": {
          "timeout": 120000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify($json.payload) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "t4-accept",
      "name": "Check Scrape Accepted",
      "type": "n8n-nodes-base.code",
      "position": [
        1116,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Bright Data answers validation errors with a body, not always a status code,\n// and neverError hides the status anyway. Read the body.\n// single-item input: the trigger endpoint returns one JSON object per call.\nconst res = $input.first().json;\n\nif (res.error || res.errors) {\n  const detail = JSON.stringify(res.errors || res.error).slice(0, 400);\n  throw new Error(`Bright Data rejected the scrape request: ${detail}`);\n}\nif (!res.snapshot_id) {\n  throw new Error(\n    `No snapshot_id came back from Bright Data. Check that your API key is valid and the response was: ${JSON.stringify(res).slice(0, 300)}`\n  );\n}\n\nreturn [{ json: { snapshot_id: res.snapshot_id } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "t4-wait",
      "name": "Wait For Snapshot",
      "type": "n8n-nodes-base.wait",
      "position": [
        1356,
        460
      ],
      "parameters": {
        "unit": "seconds",
        "amount": 30
      },
      "typeVersion": 1.1
    },
    {
      "id": "t4-progress",
      "name": "Check Snapshot Progress",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1596,
        460
      ],
      "parameters": {
        "url": "=https://api.brightdata.com/datasets/v3/progress/{{ $('Check Scrape Accepted').first().json.snapshot_id }}",
        "method": "GET",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "t4-eval",
      "name": "Evaluate Scrape Progress",
      "type": "n8n-nodes-base.code",
      "position": [
        1836,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// The poll loop's guard rail. Without a cap this Wait -> Progress -> IF cycle\n// spins forever on a stuck snapshot and burns executions all night.\nconst MAX_POLLS = 20; // 20 x 30s = 10 minutes\n// single-item input: the progress endpoint returns one status object.\nconst res = $input.first().json;\nconst snapshotId = $('Check Scrape Accepted').first().json.snapshot_id;\nconst attempt = $runIndex + 1;\n\nif (res.status === 'failed') {\n  throw new Error(\n    `Bright Data reported the snapshot failed: ${JSON.stringify(res).slice(0, 300)}`\n  );\n}\nif (res.status !== 'ready' && attempt >= MAX_POLLS) {\n  throw new Error(\n    `Snapshot ${snapshotId} was still \"${res.status}\" after ${attempt} checks. Large review pulls can take longer - raise MAX_POLLS in Evaluate Scrape Progress, or lower reviews_per_product.`\n  );\n}\n\nreturn [\n  {\n    json: {\n      snapshot_id: snapshotId,\n      status: res.status,\n      attempt,\n      records: res.records || 0,\n    },\n  },\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "t4-ready",
      "name": "Reviews Ready?",
      "type": "n8n-nodes-base.if",
      "position": [
        2076,
        460
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond1",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.status }}",
              "rightValue": "ready"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "t4-download",
      "name": "Download Reviews",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        2472,
        460
      ],
      "parameters": {
        "url": "=https://api.brightdata.com/datasets/v3/snapshot/{{ $('Check Scrape Accepted').first().json.snapshot_id }}?format=json",
        "method": "GET",
        "options": {
          "timeout": 180000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "t4-norm",
      "name": "Normalise Reviews",
      "type": "n8n-nodes-base.code",
      "position": [
        2712,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// One n8n item per review, so $input.all() - $input.first() would silently\n// keep a single review and every downstream count would be wrong.\nconst rows = $input.all().map((i) => i.json);\n// single-item input: Build Scrape Input deliberately emits one item holding the\n// whole product list, so .first() is the only item there is.\nconst products = $('Build Scrape Input').first().json.products;\nconst roleByAsin = Object.fromEntries(products.map((p) => [p.asin, p.role]));\n\n// Bright Data returns several fields concatenated with themselves, e.g.\n// \"Verified Purchase, Verified Purchase\" and\n// \"October 25, 2023Reviewed in the United States on October 25, 2023\".\n// Halve any string that is exactly its own first half repeated.\nconst undouble = (s) => {\n  const t = String(s || '').trim();\n  if (!t) return '';\n  const half = t.length / 2;\n  if (t.length % 2 === 0 && t.slice(0, half) === t.slice(half)) return t.slice(0, half);\n  return t;\n};\n\n// The date field also glues the country sentence onto the end of the date.\nconst cleanDate = (s) => {\n  const t = undouble(s);\n  const m = t.match(/^([A-Za-z]+ \\d{1,2}, \\d{4})/);\n  return m ? m[1] : t.split('Reviewed in')[0].trim();\n};\n\nconst out = [];\nfor (const r of rows) {\n  if (r.error || r.warning) continue; // include_errors=true puts failures in the rows\n  const asin = r.asin || r.variant_asin;\n  if (!asin || !r.review_text) continue;\n  out.push({\n    json: {\n      asin,\n      role: roleByAsin[asin] || 'competitor',\n      product_name: String(r.product_name || '').slice(0, 120),\n      product_rating: r.product_rating,\n      product_rating_count: r.product_rating_count,\n      rating: Number(r.rating) || 0,\n      title: undouble(r.review_header).slice(0, 200),\n      text: String(r.review_text).replace(/\\s+/g, ' ').trim().slice(0, 1200),\n      posted: cleanDate(r.review_posted_date),\n      verified: r.is_verified === true,\n      helpful: Number(r.helpful_count) || 0,\n    },\n  });\n}\n\nif (!out.length) {\n  throw new Error(\n    'Bright Data returned no usable reviews. Check the product URLs are live Amazon listings with reviews.'\n  );\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "t4-group",
      "name": "Group Reviews By Product",
      "type": "n8n-nodes-base.code",
      "position": [
        2952,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// One item per product, because the Information Extractor downstream runs once\n// per item - that is the whole loop, no Split In Batches needed.\nconst cfg = $('Set Analysis Config').first().json;\nconst minStars = Number(cfg.max_star_to_analyse) || 3;\nconst rows = $input.all().map((i) => i.json);\n\nconst byAsin = {};\nfor (const r of rows) {\n  (byAsin[r.asin] = byAsin[r.asin] || []).push(r);\n}\n\nconst out = [];\nfor (const [asin, all] of Object.entries(byAsin)) {\n  // Critical reviews carry the signal. Five-star text is mostly \"love it\".\n  const critical = all.filter((r) => r.rating && r.rating <= minStars);\n  const praise = all.filter((r) => r.rating >= 4);\n  const pick = [...critical, ...praise.slice(0, Math.ceil(critical.length / 2) || 5)];\n\n  // Most-helpful first, so the character cap keeps the reviews other buyers\n  // actually voted for rather than whatever came back first.\n  pick.sort((a, b) => b.helpful - a.helpful);\n\n  let budget = 14000;\n  const lines = [];\n  for (const r of pick) {\n    const line = `[${r.rating}/5] ${r.title} :: ${r.text}`;\n    if (budget - line.length < 0) break;\n    budget -= line.length;\n    lines.push(line);\n  }\n\n  const first = all[0];\n  out.push({\n    json: {\n      asin,\n      role: first.role,\n      product_name: first.product_name,\n      product_rating: first.product_rating,\n      product_rating_count: first.product_rating_count,\n      reviews_analysed: lines.length,\n      reviews_total: all.length,\n      critical_count: critical.length,\n      review_blob: lines.join('\\n'),\n    },\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "t4-themes",
      "name": "Extract Review Themes",
      "type": "@n8n/n8n-nodes-langchain.informationExtractor",
      "onError": "continueRegularOutput",
      "position": [
        3348,
        460
      ],
      "parameters": {
        "text": "={{ 'Product: ' + $json.product_name + '\\nOverall rating: ' + $json.product_rating + ' from ' + $json.product_rating_count + ' ratings\\n\\nCustomer reviews:\\n' + $json.review_blob }}",
        "options": {},
        "schemaType": "fromJson",
        "jsonSchemaExample": "{\n  \"complaints\": [\n    {\n      \"theme\": \"battery drains within a year\",\n      \"mentions\": 7,\n      \"severity\": \"high\",\n      \"quote\": \"stopped holding charge after 10 months\"\n    }\n  ],\n  \"praise\": [\n    {\n      \"theme\": \"noise cancelling is excellent\",\n      \"mentions\": 12\n    }\n  ],\n  \"feature_requests\": [\n    \"longer warranty\"\n  ]\n}"
      },
      "typeVersion": 1.2
    },
    {
      "id": "t4-llm1",
      "name": "OpenAI Theme Extractor",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "onError": "continueRegularOutput",
      "position": [
        3356,
        700
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.6-terra"
        },
        "options": {
          "temperature": 0.2
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "t4-compare",
      "name": "Rank Themes And Gaps",
      "type": "n8n-nodes-base.code",
      "position": [
        3872,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// The Information Extractor ran once per product, so collect all of its output\n// and re-attach the product metadata it does not carry.\nconst extracted = $input.all().map((i) => i.json);\nconst meta = $('Group Reviews By Product').all().map((i) => i.json);\n\nconst norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();\n\nconst products = extracted.map((e, idx) => {\n  const m = meta[idx] || {};\n  const out = e.output || e; // extractor nests under `output` on some versions\n  return {\n    asin: m.asin,\n    role: m.role,\n    product_name: m.product_name,\n    product_rating: m.product_rating,\n    reviews_analysed: m.reviews_analysed,\n    critical_count: m.critical_count,\n    complaints: Array.isArray(out.complaints) ? out.complaints : [],\n    praise: Array.isArray(out.praise) ? out.praise : [],\n    feature_requests: Array.isArray(out.feature_requests) ? out.feature_requests : [],\n  };\n});\n\nconst mine = products.find((p) => p.role === 'mine');\nconst rivals = products.filter((p) => p.role !== 'mine');\n\nif (!mine) {\n  throw new Error('Lost track of which product is yours between scraping and analysis.');\n}\n\n// The actual point of the workflow: a theme buyers complain about on your\n// product and praise on a rival's is a competitive gap, not just a bug.\nconst rivalPraise = rivals.flatMap((r) =>\n  r.praise.map((p) => ({ ...p, product_name: r.product_name, asin: r.asin }))\n);\n\nconst gaps = [];\nfor (const c of mine.complaints) {\n  const cWords = new Set(norm(c.theme).split(' ').filter((w) => w.length > 3));\n  for (const p of rivalPraise) {\n    const pWords = norm(p.theme).split(' ').filter((w) => w.length > 3);\n    const shared = pWords.filter((w) => cWords.has(w));\n    if (shared.length >= 1) {\n      gaps.push({\n        your_complaint: c.theme,\n        their_strength: p.theme,\n        competitor: p.product_name,\n        severity: c.severity || 'medium',\n        overlap: shared.join(', '),\n      });\n      break;\n    }\n  }\n}\n\nreturn [\n  {\n    json: {\n      mine,\n      rivals,\n      gaps,\n      gap_count: gaps.length,\n      analysed_at: new Date().toISOString().slice(0, 10),\n    },\n  },\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "t4-brief",
      "name": "Write Competitive Brief",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "onError": "continueRegularOutput",
      "position": [
        4112,
        460
      ],
      "parameters": {
        "text": "=You are a product analyst briefing a team that sells the product below.\n\nYour product: {{ $json.mine.product_name }} ({{ $json.mine.product_rating }} stars)\nTop complaints about yours: {{ JSON.stringify($json.mine.complaints) }}\nWhat buyers praise about yours: {{ JSON.stringify($json.mine.praise) }}\nCompetitors and their themes: {{ JSON.stringify($json.rivals) }}\nThemes you are criticised for and a rival is praised for: {{ JSON.stringify($json.gaps) }}\n\nWrite a short brief in plain prose, no more than 200 words:\n1. The single most damaging complaint and what it is costing.\n2. The clearest competitive gap and which rival wins on it.\n3. One thing your product is genuinely beating them on, worth protecting.\n4. The one change you would make first.\n\nBase every statement on the themes above. If the evidence is thin, say so\nrather than inventing a finding. Do not use bullet points or headings.",
        "batching": {},
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "t4-llm2",
      "name": "OpenAI Brief Writer",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "onError": "continueRegularOutput",
      "position": [
        4120,
        700
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.6-terra"
        },
        "options": {
          "temperature": 0.3
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "t4-report",
      "name": "Build Report",
      "type": "n8n-nodes-base.code",
      "position": [
        4636,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Splits into one row per finding for the sheet, and one Slack message.\n// single-item input: the brief chain and Rank Themes And Gaps each emit exactly\n// one item - the whole comparison, not one item per product.\nconst brief = $input.first().json.text || $input.first().json.output || '';\nconst data = $('Rank Themes And Gaps').first().json;\nconst mine = data.mine;\n\nconst rows = [];\nfor (const c of mine.complaints.slice(0, 10)) {\n  rows.push({\n    date: data.analysed_at,\n    asin: mine.asin,\n    product: mine.product_name,\n    type: 'complaint',\n    theme: c.theme,\n    mentions: c.mentions || '',\n    severity: c.severity || '',\n    detail: c.quote || '',\n  });\n}\nfor (const g of data.gaps.slice(0, 10)) {\n  rows.push({\n    date: data.analysed_at,\n    asin: mine.asin,\n    product: mine.product_name,\n    type: 'gap',\n    theme: g.your_complaint,\n    mentions: '',\n    severity: g.severity,\n    detail: `${g.competitor} is praised for: ${g.their_strength}`,\n  });\n}\n\n// Amazon titles run past 100 characters, so cut them on a word boundary for\n// display. Slicing blind leaves the reader with \"...Carryi\".\nconst short = (s, max = 55) => {\n  const t = String(s || '');\n  if (t.length <= max) return t;\n  const cut = t.slice(0, max);\n  return cut.slice(0, cut.lastIndexOf(' ') > 20 ? cut.lastIndexOf(' ') : max) + '...';\n};\n\nconst top = mine.complaints\n  .slice(0, 3)\n  .map((c, i) => {\n    const n = Number(c.mentions) || 0;\n    return `${i + 1}. ${c.theme}${n ? ` (${n} mention${n === 1 ? '' : 's'})` : ''}`;\n  })\n  .join('\\n');\nconst gapLines = data.gaps\n  .slice(0, 3)\n  .map(\n    (g) => `- ${g.your_complaint} -> ${short(g.competitor)} wins on \"${g.their_strength}\"`\n  )\n  .join('\\n');\n\nconst message = [\n  `*Review gap analysis - ${short(mine.product_name, 70)}*`,\n  `${mine.reviews_analysed} reviews read, ${mine.critical_count} critical. Rating ${mine.product_rating}.`,\n  '',\n  '*Top complaints*',\n  top || '_none extracted_',\n  '',\n  `*Competitive gaps (${data.gap_count})*`,\n  gapLines || '_no overlapping themes found_',\n  '',\n  brief,\n].join('\\n');\n\n// One item carrying both payloads. Split Out turns `rows` into one item per\n// theme for the sheet; the Slack node runs once and reads `message` from here.\n// Appending this item directly would put a single row of message/rows/row_count\n// in the sheet instead of one row per theme.\nreturn [{ json: { message, rows, row_count: rows.length } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "t4-split",
      "name": "Split Out Theme Rows",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        4876,
        460
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "rows"
      },
      "typeVersion": 1
    },
    {
      "id": "t4-sheet",
      "name": "Log Themes To Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "onError": "continueRegularOutput",
      "position": [
        5116,
        460
      ],
      "parameters": {
        "columns": {
          "value": {},
          "mappingMode": "autoMapInputData",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Review themes"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Set Analysis Config').first().json.sheet_url }}"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "t4-slack",
      "name": "Post Digest To Slack",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "position": [
        5356,
        460
      ],
      "parameters": {
        "text": "={{ $('Build Report').first().json.message }}",
        "select": "channel",
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Analysis Config').first().json.slack_channel }}"
        },
        "operation": "post",
        "otherOptions": {}
      },
      "executeOnce": true,
      "typeVersion": 2.3
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Build Report": {
      "main": [
        [
          {
            "node": "Split Out Theme Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reviews Ready?": {
      "main": [
        [
          {
            "node": "Download Reviews",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait For Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Reviews": {
      "main": [
        [
          {
            "node": "Normalise Reviews",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise Reviews": {
      "main": [
        [
          {
            "node": "Group Reviews By Product",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait For Snapshot": {
      "main": [
        [
          {
            "node": "Check Snapshot Progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Scrape Input": {
      "main": [
        [
          {
            "node": "Fetch Reviews From Bright Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Themes To Sheet": {
      "main": [
        [
          {
            "node": "Post Digest To Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Brief Writer": {
      "ai_languageModel": [
        [
          {
            "node": "Write Competitive Brief",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Set Analysis Config": {
      "main": [
        [
          {
            "node": "Build Scrape Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank Themes And Gaps": {
      "main": [
        [
          {
            "node": "Write Competitive Brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Out Theme Rows": {
      "main": [
        [
          {
            "node": "Log Themes To Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Scrape Accepted": {
      "main": [
        [
          {
            "node": "Wait For Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Review Themes": {
      "main": [
        [
          {
            "node": "Rank Themes And Gaps",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start Review Analysis": {
      "main": [
        [
          {
            "node": "Set Analysis Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Theme Extractor": {
      "ai_languageModel": [
        [
          {
            "node": "Extract Review Themes",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Check Snapshot Progress": {
      "main": [
        [
          {
            "node": "Evaluate Scrape Progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Competitive Brief": {
      "main": [
        [
          {
            "node": "Build Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Evaluate Scrape Progress": {
      "main": [
        [
          {
            "node": "Reviews Ready?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Group Reviews By Product": {
      "main": [
        [
          {
            "node": "Extract Review Themes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Reviews From Bright Data": {
      "main": [
        [
          {
            "node": "Check Scrape Accepted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}