{
  "name": "Lumeor | AI content repurposing (Drive to Notion review board)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI content repurposing pipeline\nDrop a webinar or podcast recording in a Google Drive folder. The workflow transcribes it with timestamps, has Claude pull out the strongest ideas with verbatim quotes and draft a LinkedIn post, a newsletter blurb and a clip suggestion for each, checks every quote word for word against the transcript, and creates one Notion card per idea for an editor to review. Nothing is published from this workflow.\n\n**Setup**\n1. Create a Notion database with these properties: Name (title), Status (select, add \"Needs review\"), Source (url), LinkedIn post, Newsletter blurb, Clip, Quotes, Quote check (all text). Share it with your Notion integration.\n2. Credentials: Google Drive OAuth2 (trigger and download), OpenAI (transcription), Anthropic, Notion, Slack.\n3. Replace REPLACE_WITH_DRIVE_FOLDER_ID, REPLACE_WITH_NOTION_DATABASE_ID, REPLACE_WITH_EDITOR_CHANNEL and REPLACE_WITH_ALERTS_CHANNEL.\n4. Paste your one-page voice guide into the Claude node's system prompt where it says VOICE GUIDE.\n\n**Limits to know:** the transcription endpoint accepts files up to 25 MB, so export audio-only (MP3 or M4A) from long videos. It does not label speakers; use a dedicated speech-to-text service if you need speaker names.\n\nModel: claude-opus-5-5 for extraction and drafting. For simple high-volume classification steps you add later, claude-haiku-5-5 is a cheaper option.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-content-repurposing-pipeline/",
        "height": 820,
        "width": 560,
        "color": 7
      },
      "id": "c17abafe-fc27-49be-956f-52ada60d0344",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -440
      ]
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyX",
              "unit": "minutes",
              "value": 15
            }
          ]
        },
        "triggerOn": "specificFolder",
        "folderToWatch": {
          "__rl": true,
          "value": "REPLACE_WITH_DRIVE_FOLDER_ID",
          "mode": "id"
        },
        "event": "fileCreated",
        "options": {}
      },
      "id": "3695e42d-616d-4d08-bbdb-dbb4c961315a",
      "name": "New file in source folder",
      "type": "n8n-nodes-base.googleDriveTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Only audio or video files the transcription API accepts (25 MB limit) continue.\nconst MAX_BYTES = 25 * 1024 * 1024;\nreturn $input.all().map((item) => {\n  const f = item.json;\n  const size = Number(f.size || 0);\n  if (!/^(audio|video)\\//.test(f.mimeType || '')) {\n    return { json: { error: `Skipped \"${f.name}\": not an audio or video file (${f.mimeType})`, details: { file_id: f.id } } };\n  }\n  if (size > MAX_BYTES) {\n    return { json: { error: `Skipped \"${f.name}\": ${(size / 1048576).toFixed(1)} MB is over the 25 MB transcription limit. Export audio only and upload again.`, details: { file_id: f.id } } };\n  }\n  return { json: { id: f.id, name: f.name, mimeType: f.mimeType, webViewLink: f.webViewLink || `https://drive.google.com/file/d/${f.id}/view` } };\n});"
      },
      "id": "2195704c-7509-4271-8389-0393c1505ed3",
      "name": "Check file type and size",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "download",
        "fileId": {
          "__rl": true,
          "value": "={{ $json.id }}",
          "mode": "id"
        },
        "options": {}
      },
      "id": "fd5be05d-cf47-4885-a29c-bf36e4e4c92a",
      "name": "Download file",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        440,
        280
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/audio/transcriptions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            },
            {
              "parameterType": "formData",
              "name": "model",
              "value": "whisper-1"
            },
            {
              "parameterType": "formData",
              "name": "response_format",
              "value": "verbose_json"
            },
            {
              "parameterType": "formData",
              "name": "timestamp_granularities[]",
              "value": "segment"
            }
          ]
        },
        "options": {
          "timeout": 600000
        }
      },
      "id": "bb238810-02b4-456a-84c4-45439ab61a05",
      "name": "Transcribe with timestamps",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        280
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 10000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Turns transcription segments into \"[mm:ss] text\" lines, and keeps the source link with each transcript.\nconst ts = (sec) => {\n  const s = Math.floor(sec);\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const pad = (n) => String(n).padStart(2, '0');\n  return h ? `${h}:${pad(m)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`;\n};\nreturn $input.all().map((item, i) => {\n  const t = item.json;\n  const source = $('Check file type and size').itemMatching(i).json;\n  const segments = t.segments || [];\n  const lines = segments.length ? segments.map((s) => `[${ts(s.start)}] ${String(s.text).trim()}`) : [String(t.text || '')];\n  return {\n    json: {\n      source_name: source.name,\n      source_link: source.webViewLink,\n      duration_minutes: Math.round((t.duration || 0) / 60),\n      transcript: lines.join('\\n'),\n      transcript_plain: String(t.text || segments.map((s) => s.text).join(' ')),\n    },\n  };\n});"
      },
      "id": "7e28355c-9fc2-4fb6-b266-c787a824df3e",
      "name": "Build timestamped transcript",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        280
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 4000, system: \"You turn a long-form transcript into reviewed-ready marketing drafts for an editor.\\nPick the 3 to 5 strongest, most specific ideas. For each idea:\\n- quotes: 1 to 3 verbatim quotes copied exactly, word for word, from the transcript text (not from the timestamps), each 8 to 40 words, with the timestamp of the line where it starts.\\n- linkedin_post: under 1,200 characters, a hook in the first line, short paragraphs, no hashtags unless the voice guide allows them, no links.\\n- newsletter_blurb: 2 to 4 sentences.\\n- clip: start and end timestamps from the transcript lines (30 to 90 seconds apart) and a one-line caption.\\nNever put words in a speaker's mouth. If something is a paraphrase, it does not go in quotes.\\n\\nVOICE GUIDE (replace with your own): plain, specific, no hype words, write for operators at small and mid-sized companies.\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"ideas\": { \"type\": \"array\", \"items\": { \"type\": \"object\", \"properties\": { \"title\": { \"type\": \"string\", \"description\": \"Short working title for the idea\" }, \"summary\": { \"type\": \"string\", \"description\": \"One sentence summary\" }, \"quotes\": { \"type\": \"array\", \"items\": { \"type\": \"object\", \"properties\": { \"text\": { \"type\": \"string\", \"description\": \"Verbatim quote\" }, \"timestamp\": { \"type\": \"string\", \"description\": \"mm:ss or h:mm:ss\" } }, \"required\": [ \"text\", \"timestamp\" ], \"additionalProperties\": false } }, \"linkedin_post\": { \"type\": \"string\" }, \"newsletter_blurb\": { \"type\": \"string\" }, \"clip\": { \"type\": \"object\", \"properties\": { \"start\": { \"type\": \"string\", \"description\": \"Start timestamp\" }, \"end\": { \"type\": \"string\", \"description\": \"End timestamp\" }, \"caption\": { \"type\": \"string\" } }, \"required\": [ \"start\", \"end\", \"caption\" ], \"additionalProperties\": false } }, \"required\": [ \"title\", \"summary\", \"quotes\", \"linkedin_post\", \"newsletter_blurb\", \"clip\" ], \"additionalProperties\": false } } }, \"required\": [ \"ideas\" ], \"additionalProperties\": false } } }, messages: [ { role: 'user', content: 'Source: ' + $json.source_name + '\\n\\nTranscript with timestamps:\\n' + $json.transcript } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "e934e678-9f45-4bd4-9726-bec3e32c64bf",
      "name": "Extract ideas and draft posts",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        280
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Every quote must appear word for word in the transcript. Near misses are flagged for the editor;\n// ideas left with no verified quote are rejected before anyone sees them.\nconst norm = (s) => String(s).toLowerCase().replace(/[‘’]/g, \"'\").replace(/[^a-z0-9' ]+/g, ' ').replace(/\\s+/g, ' ').trim();\nconst shingles = (words) => words.slice(0, Math.max(1, words.length - 3)).map((_, i) => words.slice(i, i + 4).join(' '));\nconst cut = (s, n = 1990) => (String(s).length > n ? String(s).slice(0, n - 3) + '...' : String(s));\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const res = items[i].json;\n  const src = $('Build timestamped transcript').itemMatching(i).json;\n  if (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') {\n    out.push({ json: { error: `Claude stopped with stop_reason=${res.stop_reason} for \"${src.source_name}\"`, details: { source: src.source_link } } });\n    continue;\n  }\n  // Opus 5.5 may return a thinking block first, so find the text block explicitly.\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) {\n    out.push({ json: { error: `Claude response had no text block for \"${src.source_name}\"`, details: { source: src.source_link } } });\n    continue;\n  }\n  let data;\n  try {\n    data = JSON.parse(block.text);\n  } catch (e) {\n    out.push({ json: { error: `Claude returned invalid JSON for \"${src.source_name}\"`, details: { source: src.source_link } } });\n    continue;\n  }\n  const transcript = norm(src.transcript_plain);\n  for (const idea of data.ideas || []) {\n    const verified = [];\n    const nearMiss = [];\n    for (const q of idea.quotes || []) {\n      const nq = norm(q.text);\n      if (nq.split(' ').length >= 5 && transcript.includes(nq)) verified.push(q);\n      else {\n        const sh = shingles(nq.split(' '));\n        const hit = sh.filter((s) => transcript.includes(s)).length / sh.length;\n        if (hit >= 0.7) nearMiss.push(q);\n      }\n    }\n    if (!verified.length) {\n      out.push({ json: { error: `Rejected idea \"${idea.title}\": no quote matched the transcript word for word`, details: { source: src.source_link, near_misses: nearMiss.length } } });\n      continue;\n    }\n    const check = nearMiss.length ? `${verified.length} verified, ${nearMiss.length} near miss to review: ${nearMiss.map((q) => `\"${q.text}\" (${q.timestamp})`).join(' ')}` : `${verified.length} verified`;\n    out.push({\n      json: {\n        title: cut(idea.title, 200),\n        source_link: src.source_link,\n        source_name: src.source_name,\n        linkedin_post: cut(idea.linkedin_post),\n        newsletter_blurb: cut(idea.newsletter_blurb),\n        clip: cut(`${idea.clip.start} to ${idea.clip.end}: ${idea.clip.caption}`),\n        quotes: cut(verified.map((q) => `\"${q.text}\" (${q.timestamp})`).join('\\n')),\n        quote_check: cut(check),\n      },\n    });\n  }\n}\nreturn out;"
      },
      "id": "836498a7-9671-4ce0-a8f1-ae0abc09be7d",
      "name": "Verify quotes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        280
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "resource": "databasePage",
        "operation": "create",
        "databaseId": {
          "__rl": true,
          "value": "REPLACE_WITH_NOTION_DATABASE_ID",
          "mode": "id"
        },
        "title": "={{ $json.title }}",
        "simple": true,
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "Status|select",
              "selectValue": "Needs review"
            },
            {
              "key": "Source|url",
              "urlValue": "={{ $json.source_link }}"
            },
            {
              "key": "LinkedIn post|rich_text",
              "textContent": "={{ $json.linkedin_post }}"
            },
            {
              "key": "Newsletter blurb|rich_text",
              "textContent": "={{ $json.newsletter_blurb }}"
            },
            {
              "key": "Clip|rich_text",
              "textContent": "={{ $json.clip }}"
            },
            {
              "key": "Quotes|rich_text",
              "textContent": "={{ $json.quotes }}"
            },
            {
              "key": "Quote check|rich_text",
              "textContent": "={{ $json.quote_check }}"
            }
          ]
        },
        "options": {}
      },
      "id": "7e7ea66b-d3e3-48e9-92dc-354a9972943f",
      "name": "Create review card",
      "type": "n8n-nodes-base.notion",
      "typeVersion": 2.2,
      "position": [
        1540,
        260
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_EDITOR_CHANNEL",
          "mode": "name"
        },
        "text": "=:memo: {{ $input.all().length }} draft cards from *{{ $('Verify quotes').first().json.source_name }}* are waiting in Notion with status Needs review. Nothing is scheduled until an editor approves a card.",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "85b11e09-8c15-4b1d-9d70-5035f166a143",
      "name": "Tell the editor",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        260
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ALERTS_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Content repurposing needs attention*\n*Reason:* {{ typeof $json.error === 'string' ? $json.error : (($json.error && $json.error.message) || 'Unknown error') }}\n*Details:* {{ JSON.stringify($json.details || $json.error || {}).slice(0, 600) }}\n*Execution:* {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "02beabf9-9144-4045-8996-48fcc8485bf6",
      "name": "Alert: repurposing needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1320,
        520
      ]
    }
  ],
  "connections": {
    "New file in source folder": {
      "main": [
        [
          {
            "node": "Check file type and size",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check file type and size": {
      "main": [
        [
          {
            "node": "Download file",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: repurposing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download file": {
      "main": [
        [
          {
            "node": "Transcribe with timestamps",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Transcribe with timestamps": {
      "main": [
        [
          {
            "node": "Build timestamped transcript",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: repurposing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build timestamped transcript": {
      "main": [
        [
          {
            "node": "Extract ideas and draft posts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract ideas and draft posts": {
      "main": [
        [
          {
            "node": "Verify quotes",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: repurposing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify quotes": {
      "main": [
        [
          {
            "node": "Create review card",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: repurposing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create review card": {
      "main": [
        [
          {
            "node": "Tell the editor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
