{
  "name": "LinkedIn Carousel Generator",
  "nodes": [
    {
      "id": "2dd90848-0d9a-444b-a172-030a5d4dad53",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1328,
        -128
      ],
      "parameters": {
        "width": 480,
        "height": 656,
        "content": "## LinkedIn Carousel Generator\n\n### How it works\n\n1. Receives a POST request to start the process.\n2. Processes the input data to determine the route.\n3. Retrieves and extracts text from a URL if provided.\n4. Generates a LinkedIn carousel using OpenAI.\n5. Formats and validates the carousel for Canva output.\n\n### Setup steps\n\n- [ ] Configure the Webhook node with your LinkedIn Generator trigger URL.\n- [ ] Set up OpenAI credentials for the 'Generate Carousel via OpenAI' node.\n- [ ] Ensure the 'Retrieve Article Content' node's HTTP request is permitted.\n\n### Customization\n\nAdjust the OpenAI prompt in 'Generate Carousel via OpenAI' to change the carousel content style."
      },
      "typeVersion": 1
    },
    {
      "id": "bdae8110-11a5-4a10-a520-bc17e3d46df9",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -768,
        48
      ],
      "parameters": {
        "color": 7,
        "width": 672,
        "height": 272,
        "content": "## Trigger and input processing\n\nHandles incoming webhook trigger and processes input data."
      },
      "typeVersion": 1
    },
    {
      "id": "edb2445b-7ed7-45ff-91e0-125e8fcbf291",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 304,
        "content": "## Content retrieval and extraction\n\nRetrieves article content from a URL and extracts text using custom code."
      },
      "typeVersion": 1
    },
    {
      "id": "bc148c5a-8ce4-46f3-a30c-7f2299379e63",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        480,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 704,
        "height": 400,
        "content": "## Generate and validate carousel\n\nCreates a LinkedIn carousel with OpenAI, formats it for Canva, and validates the structure."
      },
      "typeVersion": 1
    },
    {
      "id": "d6cf8cb8-4cc8-49b8-b7e2-dfd86c55ca18",
      "name": "LinkedIn Generator Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -720,
        160
      ],
      "parameters": {
        "path": "linkedin-carousel-generator",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "lastNode"
      },
      "typeVersion": 2
    },
    {
      "id": "5c142fd1-dab9-4e6b-aa42-0e7beb1cab64",
      "name": "Parse Input for Processing",
      "type": "n8n-nodes-base.code",
      "position": [
        -480,
        160
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const body = $json.body && typeof $json.body === 'object' ? $json.body : $json;\nconst rawText = body.text ?? body.articleText ?? body.content ?? '';\nconst sourceUrl = String(body.url ?? body.articleUrl ?? '').trim();\nconst language = String(body.language ?? 'es').trim().toLowerCase();\nconst audience = String(body.audience ?? 'Creadores de contenido y consultores B2B').trim();\nconst slideCount = Math.min(Math.max(Number(body.slideCount ?? 8), 5), 10);\nconst sourceText = typeof rawText === 'string' ? rawText.trim() : '';\nif (!sourceUrl && !sourceText) {\n  throw new Error('Provide either url or text/articleText/content.');\n}\nreturn { json: { sourceUrl, sourceText, language, audience, slideCount, inputMode: sourceUrl ? 'url' : 'text' } };"
      },
      "typeVersion": 2
    },
    {
      "id": "2acdc356-aacd-4eb5-89e4-24018c94cc1f",
      "name": "If URL Provided",
      "type": "n8n-nodes-base.if",
      "position": [
        -240,
        160
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.sourceUrl }}",
              "operation": "notEmpty"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "a5082412-3178-4e81-8126-3ee9322ab83c",
      "name": "Fetch Article Content",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        0,
        0
      ],
      "parameters": {
        "url": "={{ $json.sourceUrl }}",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "1f1b60ec-b5b1-4f1f-bf53-6ebb22220129",
      "name": "Extract Article Text",
      "type": "n8n-nodes-base.code",
      "position": [
        240,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const html = String($json.data ?? $json.body ?? $json.html ?? '');\nconst text = html.replace(/<script[\\s\\S]*?<\\/script>/gi, ' ').replace(/<style[\\s\\S]*?<\\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/\\s+/g, ' ').trim();\nif (!text) throw new Error('The URL did not return readable article content.');\nreturn { json: { ...$json, sourceText: text.slice(0, 40000) } };"
      },
      "typeVersion": 2
    },
    {
      "id": "1a748471-a856-4e5d-b033-f9a025dc444c",
      "name": "OpenAI Carousel Generator",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        528,
        160
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini",
          "cachedResultName": "GPT-4O-MINI"
        },
        "options": {
          "maxTokens": 4000,
          "temperature": 0.4
        },
        "messages": {
          "values": [
            {
              "content": "You are a senior B2B LinkedIn content strategist. Turn the supplied article or long-form text into a practical LinkedIn carousel that can be copied into Canva.\n\nReturn ONLY valid JSON, with no markdown fences, using exactly this shape:\n{\n  \"title\": \"string\",\n  \"source\": \"url or text\",\n  \"audience\": \"string\",\n  \"language\": \"string\",\n  \"slides\": [\n    {\"number\": 1, \"headline\": \"max 60 chars\", \"body\": \"max 220 chars\", \"visual\": \"short visual direction\", \"design_notes\": \"short Canva layout note\"}\n  ],\n  \"caption\": \"LinkedIn post caption with a clear hook and CTA\",\n  \"hashtags\": [\"#tag\"],\n  \"alt_text\": \"accessible description of the carousel\"\n}\n\nRules:\n- Produce exactly {{ $json.slideCount }} slides, not a range. The first slide must be a strong cover and the final slide must be an actionable CTA.\n- Return the exact requested values in the `language` and `audience` fields: language must be `{{ $json.language }}` and audience must be `{{ $json.audience }}`. Do not substitute a display name such as English for the language code.\n- Extract only claims supported by the source; do not invent facts, statistics, quotes, or citations.\n- Use concise, specific B2B language for content creators and B2B consultants.\n- One core idea per slide. Make headlines skimmable and body copy ready to paste into a design.\n- Use the requested language for all user-facing copy.\n- Keep hashtags relevant and limited to 5.\n\nRequested language: {{ $json.language }}\nAudience: {{ $json.audience }}\nSource mode: {{ $json.inputMode }}\nSource URL: {{ $json.sourceUrl || 'Provided text' }}\nSource content:\n{{ $json.sourceText }}\n"
            }
          ]
        }
      },
      "typeVersion": 1.8
    },
    {
      "id": "a5eb42af-fb8e-49e5-96c0-6979082a8938",
      "name": "Prepare Canva Format",
      "type": "n8n-nodes-base.code",
      "position": [
        880,
        256
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "return (() => {  const request = $items('Normalize Input', 0, 0)[0]?.json ?? {};  const responseParts = [    $json.message?.content,    $json.output,    $json.text,    $json.response?.output_text,    $json.choices?.[0]?.message?.content,  ].filter((value) => typeof value === 'string' && value.trim());  const rawResponse = String(responseParts[0] ?? '').trim();  const jsonStart = rawResponse.indexOf('{');  const jsonEnd = rawResponse.lastIndexOf('}');  if (jsonStart < 0 || jsonEnd <= jsonStart) throw new Error('OpenAI did not return a JSON carousel.');  let carousel;  try { carousel = JSON.parse(rawResponse.slice(jsonStart, jsonEnd + 1)); } catch (error) { throw new Error('Could not parse OpenAI carousel JSON: ' + error.message); }  if (!Array.isArray(carousel.slides)) throw new Error('Carousel must contain slides.');  const requestedCount = Number(request.slideCount);  if (requestedCount < 5 || requestedCount > 10) throw new Error('Requested slide count must be between 5 and 10.');  if (carousel.slides.length < requestedCount) throw new Error('OpenAI returned only ' + carousel.slides.length + ' slides; ' + requestedCount + ' are required.');  carousel.slides = carousel.slides.slice(0, requestedCount).map((slide, index) => ({ ...slide, number: index + 1 }));  carousel.language = request.language;  carousel.audience = request.audience;  if (!Array.isArray(carousel.hashtags)) carousel.hashtags = [];  return { json: { carousel, canvaCopy: carousel.slides.map((slide) => 'SLIDE ' + slide.number + '\\n' + slide.headline + '\\n' + slide.body + '\\nVisual: ' + slide.visual + '\\nDesign: ' + slide.design_notes).join('\\n\\n'), sourceUrl: request.sourceUrl ?? '', language: request.language } };})()"
      },
      "typeVersion": 2
    },
    {
      "id": "a5819765-9245-49fd-be9b-e585c14dd485",
      "name": "Check Carousel Structure",
      "type": "n8n-nodes-base.code",
      "position": [
        1040,
        160
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const request = $items('Normalize Input', 0, 0)[0]?.json ?? {};\nconst carousel = $json.carousel;\nif (!carousel || !Array.isArray(carousel.slides)) throw new Error('OpenAI response does not contain carousel slides.');\nconst expectedCount = Number(request.slideCount);\nif (expectedCount && carousel.slides.length !== expectedCount) throw new Error(`Expected exactly ${expectedCount} slides but received ${carousel.slides.length}.`);\nconst expectedLanguage = String(request.language ?? '').trim().toLowerCase();\nif (expectedLanguage && String(carousel.language ?? '').trim().toLowerCase() !== expectedLanguage) throw new Error(`Expected language ${expectedLanguage} but received ${carousel.language ?? 'unknown'}.`);\nconst expectedAudience = String(request.audience ?? '').trim();\nif (expectedAudience && String(carousel.audience ?? '').trim() !== expectedAudience) throw new Error('OpenAI response audience does not match the requested audience.');\nreturn { json: $json };"
      },
      "typeVersion": 2
    }
  ],
  "connections": {
    "If URL Provided": {
      "main": [
        [
          {
            "node": "OpenAI Carousel Generator",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Article Content",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Article Text": {
      "main": [
        [
          {
            "node": "OpenAI Carousel Generator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Canva Format": {
      "main": [
        [
          {
            "node": "Check Carousel Structure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Article Content": {
      "main": [
        [
          {
            "node": "Extract Article Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Carousel Generator": {
      "main": [
        [
          {
            "node": "Prepare Canva Format",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LinkedIn Generator Webhook": {
      "main": [
        [
          {
            "node": "Parse Input for Processing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Input for Processing": {
      "main": [
        [
          {
            "node": "If URL Provided",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}
