Convertir les articles PDF en podcast - N8N

Avancé

Ceci est uncontenant 17 nœuds.Utilise principalement des nœuds comme Set, Code, Merge, ReadPDF, EmailSend. Utiliser Google TTS et Cloudflare R2 pour convertir des articles PDF en podcasts audio

Prérequis
  • Peut nécessiter les informations d'identification d'authentification de l'API cible

Catégorie

-
Aperçu du workflow
Visualisation des connexions entre les nœuds, avec support du zoom et du déplacement
Exporter le workflow
Copiez la configuration JSON suivante dans n8n pour importer et utiliser ce workflow
{
  "name": "Convert PDF Articles to Podcast - N8N",
  "nodes": [
    {
      "id": "8616c449-1148-438e-b1a0-57dcc9b6caed",
      "name": "⚙️ Configuration du workflow",
      "type": "n8n-nodes-base.set",
      "position": [
        32,
        480
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "r2-public-url",
              "name": "R2_PUBLIC_URL",
              "type": "string",
              "value": "https://pub-YOUR-R2-ID-HERE.r2.dev"
            },
            {
              "id": "r2-bucket",
              "name": "R2_BUCKET",
              "type": "string",
              "value": "your-bucket-name"
            },
            {
              "id": "rss-filename",
              "name": "RSS_FILENAME",
              "type": "string",
              "value": "your-podcast-feed.xml"
            },
            {
              "id": "c7427d04-8c09-4e62-bc17-073033449664",
              "name": "PODCAST_ARTWORK_URL",
              "type": "string",
              "value": "https://pub-YOUR-R2-ID-HERE.r2.dev/artwork/podcast-image.png"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "0cb6f54b-34b7-449c-b42c-1a8eb341001a",
      "name": "Téléverser un PDF pour podcast",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        80,
        128
      ],
      "parameters": {
        "options": {},
        "formTitle": "Upload PDF for Podcast Conversion",
        "formFields": {
          "values": [
            {
              "fieldType": "file",
              "fieldLabel": "pdfFile",
              "multipleFiles": false,
              "requiredField": true,
              "acceptFileTypes": "pdf"
            }
          ]
        },
        "formDescription": "Upload a PDF file to convert to audio podcast. Processing will start immediately after upload"
      },
      "typeVersion": 2.3
    },
    {
      "id": "4f7236ac-852d-4c6f-9393-1fd6ed1c5610",
      "name": "Extraire le texte du PDF",
      "type": "n8n-nodes-base.readPDF",
      "position": [
        336,
        128
      ],
      "parameters": {
        "binaryPropertyName": "pdfFile"
      },
      "typeVersion": 1
    },
    {
      "id": "b533785e-e3bb-4bc6-b3a4-ab1f452ae330",
      "name": "Nettoyer et traiter le texte",
      "type": "n8n-nodes-base.code",
      "position": [
        544,
        128
      ],
      "parameters": {
        "jsCode": "// Get PDF text and metadata\nconst pdfText = $input.item.json.text;\nconst fileName = $input.item.binary.pdfFile?.fileName || 'uploaded-document.pdf';\nconst documentTitle = fileName.replace('.pdf', '').replace(/_/g, ' ');\n\n// Clean text\nlet cleanedText = pdfText\n  .replace(/\\f/g, '\\n')\n  .replace(/\\n{3,}/g, '\\n\\n')\n  .replace(/^\\d+\\s*$/gm, '')\n  .replace(/PSYCHOLOGY SECONDARY COURSE/gi, '')\n  .replace(/MODULE - I.*?Psychology/gis, '')\n  .trim();\n\n// Extract document metadata\nconst wordCount = cleanedText.split(/\\s+/).length;\nconst charCount = cleanedText.length;\nconst estimatedMinutes = Math.ceil(charCount / 1000);\n\nreturn {\n  json: {\n    documentTitle,\n    fileName,\n    cleanedText,\n    wordCount,\n    charCount,\n    estimatedMinutes,\n    uploadDate: new Date().toISOString()\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "aac02256-376d-421d-897b-06bdfd1b9494",
      "name": "Détecter les sections et diviser",
      "type": "n8n-nodes-base.code",
      "position": [
        736,
        128
      ],
      "parameters": {
        "jsCode": "const input = $input.item.json;\nconst cleanedText = input.cleanedText;\nconst documentTitle = input.documentTitle;\n\nconst MAX_BYTES = 4500;\n\nconst sectionRegex = /(\\d+\\.\\d+)\\s+([A-Z][A-Z\\s]{10,})/g;\nlet sections = [];\nlet lastIndex = 0;\nlet match;\n\nwhile ((match = sectionRegex.exec(cleanedText)) !== null) {\n  const sectionNumber = match[1];\n  const sectionTitle = match[2].trim();\n  const startIndex = match.index;\n\n  if (lastIndex < startIndex) {\n    const prevText = cleanedText.substring(lastIndex, startIndex).trim();\n    if (prevText.length > 100) {\n      sections.push({ text: prevText, number: sections.length + 1, title: 'Introduction' });\n    }\n  }\n\n  sections.push({ number: sectionNumber, title: sectionTitle, startIndex });\n  lastIndex = startIndex;\n}\n\nconst episodes = [];\nfor (let i = 0; i < sections.length; i++) {\n  const section = sections[i];\n  const nextSection = sections[i + 1];\n  const endIndex = nextSection ? nextSection.startIndex : cleanedText.length;\n  const sectionText = cleanedText.substring(section.startIndex, endIndex).trim();\n\n  if (Buffer.byteLength(sectionText, 'utf8') > MAX_BYTES) {\n    const chunks = splitTextIntoChunks(sectionText, MAX_BYTES);\n    chunks.forEach((chunk, idx) => {\n      episodes.push({\n        episodeNumber: `${section.number}.${idx + 1}`,\n        episodeTitle: `${documentTitle} - Section ${section.number}.${idx + 1}: ${section.title}`,\n        sectionTitle: section.title,\n        textContent: chunk,\n        charCount: chunk.length,\n        totalEpisodes: null\n      });\n    });\n  } else {\n    episodes.push({\n      episodeNumber: section.number,\n      episodeTitle: `${documentTitle} - Section ${section.number}: ${section.title}`,\n      sectionTitle: section.title,\n      textContent: sectionText,\n      charCount: sectionText.length,\n      totalEpisodes: null\n    });\n  }\n}\n\nif (episodes.length === 0) {\n  const chunks = splitTextIntoChunks(cleanedText, MAX_BYTES);\n  chunks.forEach((chunk, idx) => {\n    episodes.push({\n      episodeNumber: idx + 1,\n      episodeTitle: `${documentTitle} - Part ${idx + 1}`,\n      sectionTitle: `Part ${idx + 1}`,\n      textContent: chunk,\n      charCount: chunk.length,\n      totalEpisodes: chunks.length\n    });\n  });\n}\n\nepisodes.forEach(ep => ep.totalEpisodes = episodes.length);\n\nfunction splitTextIntoChunks(text, maxBytes) {\n  const chunks = [];\n  let currentChunk = '';\n  const sentences = text.split(/(?<=[.!?])\\s+/);\n\n  for (const sentence of sentences) {\n    const testChunk = currentChunk + (currentChunk ? ' ' : '') + sentence;\n    if (Buffer.byteLength(testChunk, 'utf8') > maxBytes) {\n      if (currentChunk) {\n        chunks.push(currentChunk);\n        currentChunk = sentence;\n      } else {\n        const words = sentence.split(' ');\n        for (const word of words) {\n          const testWord = currentChunk + (currentChunk ? ' ' : '') + word;\n          if (Buffer.byteLength(testWord, 'utf8') > maxBytes) {\n            chunks.push(currentChunk);\n            currentChunk = word;\n          } else {\n            currentChunk = testWord;\n          }\n        }\n      }\n    } else {\n      currentChunk = testChunk;\n    }\n  }\n\n  if (currentChunk) {\n    chunks.push(currentChunk);\n  }\n\n  return chunks;\n}\n\nreturn episodes.map(ep => ({ json: ep }));"
      },
      "typeVersion": 2
    },
    {
      "id": "3a32b49b-bcac-4a61-b405-2f5d412a1191",
      "name": "Vérifier la limite d'utilisation TTS",
      "type": "n8n-nodes-base.code",
      "position": [
        944,
        128
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const now = new Date();\nconst monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;\n\nconst monthlyUsage = $getWorkflowStaticData('global');\n\nif (!monthlyUsage[monthKey]) {\n  monthlyUsage[monthKey] = { charCount: 0, requestCount: 0 };\n}\n\nconst currentUsage = monthlyUsage[monthKey].charCount;\nconst charCount = $input.item.json.charCount;\nconst MONTHLY_LIMIT = 950000;\n\nif (currentUsage + charCount > MONTHLY_LIMIT) {\n  throw new Error(\n    `⚠️ MONTHLY TTS LIMIT REACHED!\\n` +\n    `Current: ${currentUsage.toLocaleString()} chars\\n` +\n    `This request: ${charCount.toLocaleString()} chars\\n` +\n    `Limit: ${MONTHLY_LIMIT.toLocaleString()} chars`\n  );\n}\n\nreturn {\n  json: {\n    ...$input.item.json,\n    usageInfo: {\n      currentMonthUsage: currentUsage,\n      thisRequestChars: charCount,\n      remainingChars: MONTHLY_LIMIT - currentUsage - charCount,\n      monthKey\n    }\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "ce537777-d89c-445b-b1ed-199beac096ce",
      "name": "Google TTS API",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        1136,
        128
      ],
      "parameters": {
        "url": "https://texttospeech.googleapis.com/v1/text:synthesize",
        "method": "POST",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "input.text",
              "value": "={{ $json.textContent }}"
            },
            {
              "name": "voice.languageCode",
              "value": "en-US"
            },
            {
              "name": "voice.name",
              "value": "en-US-Wavenet-D"
            },
            {
              "name": "audioConfig.audioEncoding",
              "value": "MP3"
            },
            {
              "name": "audioConfig.speakingRate",
              "value": "1.0"
            },
            {
              "name": "audioConfig.pitch",
              "value": "0.0"
            }
          ]
        },
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "alwaysOutputData": true,
      "waitBetweenTries": 2000
    },
    {
      "id": "7948efd4-3d73-4496-95fa-12d25ff97fe0",
      "name": "Convertir l'audio en binaire",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        128
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const audioBase64 = $input.item.json.audioContent;\n\nif (!audioBase64) {\n  console.log(`Skipping item ${$itemIndex} - no audio content`);\n  return null;\n}\n\nconst allSectionItems = $('Detect Sections & Split').all();\nconst sectionData = allSectionItems[$itemIndex] ? allSectionItems[$itemIndex].json : {};\n\nconst episodeTitle = sectionData.episodeTitle || 'Episode';\nconst episodeNumber = sectionData.episodeNumber || 'N/A';\nconst sectionTitle = sectionData.sectionTitle || '';\nconst charCount = sectionData.charCount || 0;\n\nconst date = new Date().toISOString().split('T')[0];\nconst safeTitle = episodeTitle\n  .replace(/[^a-zA-Z0-9\\s-]/g, '')\n  .replace(/\\s+/g, '_')\n  .substring(0, 100);\nconst fileName = `${safeTitle}_${date}.mp3`;\n\nconst binaryBuffer = Buffer.from(audioBase64, 'base64');\n\nreturn {\n  json: {\n    fileName,\n    fileSize: binaryBuffer.length,\n    mimeType: 'audio/mpeg',\n    episodeTitle,\n    episodeNumber,\n    sectionTitle,\n    charCount,\n    audioContent: audioBase64\n  },\n  binary: {\n    audioFile: {\n      data: audioBase64,\n      mimeType: 'audio/mpeg',\n      fileName: fileName,\n      fileExtension: 'mp3'\n    }\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "9b9c9e38-5b5a-491f-b3b7-5d0d39c73ad5",
      "name": "Assembler tous les MP3",
      "type": "n8n-nodes-base.code",
      "position": [
        32,
        336
      ],
      "parameters": {
        "jsCode": "const allItems = $input.all();\n\nconst sortedItems = allItems.sort((a, b) => {\n  const numA = a.json.episodeNumber || 0;\n  const numB = b.json.episodeNumber || 0;\n  return numA - numB;\n});\n\nconst mp3Buffers = [];\nlet totalSize = 0;\nlet totalChars = 0;\nconst episodesList = [];\n\nfor (const item of sortedItems) {\n  const audioData = item.json.audioContent;\n  const buffer = Buffer.from(audioData, 'base64');\n  mp3Buffers.push(buffer);\n  totalSize += buffer.length;\n  totalChars += item.json.charCount || 0;\n\n  episodesList.push({\n    number: item.json.episodeNumber,\n    title: item.json.episodeTitle,\n    charCount: item.json.charCount\n  });\n}\n\nconst combinedBuffer = Buffer.concat(mp3Buffers);\nconst combinedBase64 = combinedBuffer.toString('base64');\n\nconst date = new Date().toISOString().split('T')[0];\nconst fileName = `Chapter-2_Complete_${date}.mp3`;\n\nreturn [{\n  json: {\n    fileName,\n    fileSize: totalSize,\n    mimeType: 'audio/mpeg',\n    totalEpisodes: sortedItems.length,\n    totalCharacters: totalChars,\n    episodes: episodesList,\n    episodeTitle: `Chapter-2 - Complete (${sortedItems.length} sections)`,\n    episodeNumber: 'Complete',\n    charCount: totalChars\n  },\n  binary: {\n    audioFile: {\n      data: combinedBase64,\n      mimeType: 'audio/mpeg',\n      fileName: fileName,\n      fileExtension: 'mp3'\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "72ae9ece-7ea5-48f8-969c-79f70376ff1b",
      "name": "Téléverser MP3 vers R2",
      "type": "n8n-nodes-cloudflare-r2-storage.cloudflareR2Storage",
      "position": [
        432,
        384
      ],
      "parameters": {
        "objectKey": "={{ $json.fileName }}",
        "bucketName": "={{ $('⚙️ Workflow Config').first().json.R2_BUCKET }}",
        "contentType": "audio/mpeg",
        "binaryPropertyName": "audioFile"
      },
      "typeVersion": 1
    },
    {
      "id": "9789ab0b-b679-4314-a814-5407a42fb7d1",
      "name": "Construire RSS XML",
      "type": "n8n-nodes-base.code",
      "position": [
        624,
        384
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Enhanced RSS Feed Code v2 - \"Build RSS XML\" Node\n// Copy this ENTIRE code and replace the existing code in \"Build RSS XML\" node\n\n// Get data from Merge node (has original MP3 data)\nconst mp3Data = $('Merge').first().json;\nconst episodes = mp3Data.episodes || [];\nconst totalEpisodes = mp3Data.totalEpisodes || 1;\nconst fileName = mp3Data.fileName || 'Podcast.mp3';\nconst fileSize = mp3Data.fileSize || 0;\n\n// Get configuration from standalone config node\nconst config = $('⚙️ Workflow Config').first().json;\nconst R2_PUBLIC_URL = config.R2_PUBLIC_URL;\nconst RSS_FILENAME = config.RSS_FILENAME;\nconst PODCAST_ARTWORK_URL = config.PODCAST_ARTWORK_URL || \"https://via.placeholder.com/3000x3000.png?text=Personal+Podcast\";\n\n// Construct R2 public URLs\nconst r2Mp3Url = `${R2_PUBLIC_URL}/${fileName}`;\nconst pubDate = new Date().toUTCString();\n\n// Extract document title\nconst docTitle = fileName.replace(/_Complete_\\d{4}-\\d{2}-\\d{2}\\.mp3$/, '').replace(/_/g, ' ');\n\n// Calculate estimated duration (MP3 128kbps = ~16KB/sec)\nconst estimatedDurationSeconds = Math.round(fileSize / 16000);\nconst durationHours = Math.floor(estimatedDurationSeconds / 3600);\nconst durationMinutes = Math.floor((estimatedDurationSeconds % 3600) / 60);\nconst durationSeconds = estimatedDurationSeconds % 60;\nconst durationFormatted = durationHours > 0\n  ? `${durationHours}:${String(durationMinutes).padStart(2, '0')}:${String(durationSeconds).padStart(2, '0')}`\n  : `${durationMinutes}:${String(durationSeconds).padStart(2, '0')}`;\n\n// Build episode description (plain text)\nconst episodeDescription = episodes.map((ep, idx) =>\n  `${idx + 1}. ${ep.title} (${ep.charCount.toLocaleString()} characters)`\n).join('\\n');\n\n// Build episode description (HTML for better formatting)\nconst episodeDescriptionHTML = episodes.map((ep, idx) =>\n  `<p><strong>${idx + 1}. ${ep.title}</strong><br/><em>${ep.charCount.toLocaleString()} characters</em></p>`\n).join('\\n');\n\n// Current year for copyright\nconst currentYear = new Date().getFullYear();\n\n// Build enhanced RSS feed XML with improved metadata\nconst rssXml = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"\n     xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\"\n     xmlns:atom=\"http://www.w3.org/2005/Atom\"\n     xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n  <channel>\n    <title>Personal Podcast</title>\n    <description>Transform any PDF article, research paper, or document into high-quality audio. Perfect for learning while commuting, exercising, or multitasking.</description>\n    <language>en-us</language>\n    <link>https://your-website-url.com</link>\n    <atom:link href=\"${R2_PUBLIC_URL}/${RSS_FILENAME}\" rel=\"self\" type=\"application/rss+xml\"/>\n\n    <!-- iTunes Podcast Metadata -->\n    <itunes:author>Your Name</itunes:author>\n    <itunes:owner>\n      <itunes:name>Your Name</itunes:name>\n      <itunes:email>your-email@example.com</itunes:email>\n    </itunes:owner>\n    <itunes:image href=\"${PODCAST_ARTWORK_URL}\"/>\n    <itunes:category text=\"Education\">\n      <itunes:category text=\"Self-Improvement\"/>\n    </itunes:category>\n    <itunes:category text=\"Technology\"/>\n    <itunes:explicit>no</itunes:explicit>\n    <itunes:type>episodic</itunes:type>\n    <itunes:summary>Your personal AI narrator for all your reading materials. Convert PDFs to audio instantly.</itunes:summary>\n    <itunes:subtitle>PDF Articles converted to audio for personal listening</itunes:subtitle>\n\n    <!-- Podcast Metadata -->\n    <copyright>© ${currentYear} Your Podcast Name</copyright>\n    <generator>n8n.io - Convert PDF Articles to Podcast</generator>\n    <lastBuildDate>${pubDate}</lastBuildDate>\n\n    <!-- Episode Item -->\n    <item>\n      <title>${docTitle} - Complete (${totalEpisodes} sections)</title>\n      <description><![CDATA[\n        📚 Complete audio edition of ${docTitle}\n\n        🎯 What's inside:\n        ${episodeDescription}\n\n        ⏱️ Duration: ${durationFormatted}\n        📄 Sections: ${totalEpisodes}\n        🗓️ Published: ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}\n      ]]></description>\n      <enclosure url=\"${r2Mp3Url}\" length=\"${fileSize}\" type=\"audio/mpeg\"/>\n      <guid isPermaLink=\"false\">${fileName}</guid>\n      <pubDate>${pubDate}</pubDate>\n\n      <!-- iTunes Episode Metadata -->\n      <itunes:title>${docTitle} - Complete Edition</itunes:title>\n      <itunes:author>Your Name</itunes:author>\n      <itunes:duration>${durationFormatted}</itunes:duration>\n      <itunes:summary><![CDATA[Complete audio version with ${totalEpisodes} sections. ${episodeDescription.substring(0, 150)}...]]></itunes:summary>\n      <itunes:subtitle>${totalEpisodes} sections • ${durationFormatted}</itunes:subtitle>\n      <itunes:image href=\"${PODCAST_ARTWORK_URL}\"/>\n      <itunes:episodeType>full</itunes:episodeType>\n      <itunes:explicit>no</itunes:explicit>\n\n      <!-- Rich Content -->\n      <content:encoded><![CDATA[\n        <h2>🎧 ${docTitle} - Complete Audio Edition</h2>\n        <p><strong>Duration:</strong> ${durationFormatted} | <strong>Sections:</strong> ${totalEpisodes} | <strong>Size:</strong> ${Math.round(fileSize / 1024 / 1024 * 100) / 100} MB</p>\n        <hr/>\n        <h3>📑 Sections Included:</h3>\n        ${episodeDescriptionHTML}\n        <hr/>\n        <p><em>Generated automatically by Convert PDF Articles to Podcast workflow</em></p>\n      ]]></content:encoded>\n    </item>\n\n  </channel>\n</rss>`;\n\n// Convert XML to base64 for binary storage\nconst rssBase64 = Buffer.from(rssXml, 'utf-8').toString('base64');\n\n// Return data with enhanced metadata\nreturn {\n  json: {\n    ...mp3Data,\n    r2Mp3Url: r2Mp3Url,\n    rssGenerated: true,\n    rssFileName: RSS_FILENAME,\n    estimatedDuration: durationFormatted,\n    podcastArtwork: PODCAST_ARTWORK_URL\n  },\n  binary: {\n    rssFile: {\n      data: rssBase64,\n      mimeType: 'application/rss+xml',\n      fileName: RSS_FILENAME,\n      fileExtension: 'xml'\n    }\n  }\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "dd7a9af8-5031-48a7-a9b8-9658941792d8",
      "name": "Téléverser RSS vers R2",
      "type": "n8n-nodes-cloudflare-r2-storage.cloudflareR2Storage",
      "position": [
        832,
        384
      ],
      "parameters": {
        "objectKey": "={{ $('⚙️ Workflow Config').first().json.RSS_FILENAME }}",
        "bucketName": "={{ $('⚙️ Workflow Config').first().json.R2_BUCKET }}",
        "contentType": "application/rss+xml",
        "binaryPropertyName": "rssFile"
      },
      "typeVersion": 1
    },
    {
      "id": "b600862c-f392-49da-b9dd-4fe550c76615",
      "name": "Mettre à jour l'utilisation mensuelle",
      "type": "n8n-nodes-base.code",
      "position": [
        1040,
        384
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Get data from Merge node (has original MP3 data)\nconst data = $('Merge').first().json;\nconst totalChars = data.totalCharacters || data.charCount || 0;\nconst totalEpisodes = data.totalEpisodes || 1;\n\nconst now = new Date();\nconst monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;\n\nconst monthlyUsage = $getWorkflowStaticData('global');\n\nif (!monthlyUsage[monthKey]) {\n  monthlyUsage[monthKey] = {\n    charCount: 0,\n    requestCount: 0,\n    lastUpdated: new Date().toISOString()\n  };\n}\n\nmonthlyUsage[monthKey].charCount += totalChars;\nmonthlyUsage[monthKey].requestCount += totalEpisodes;\nmonthlyUsage[monthKey].lastUpdated = new Date().toISOString();\n\nreturn {\n  json: {\n    ...data,\n    updatedUsage: {\n      monthKey,\n      totalChars: monthlyUsage[monthKey].charCount,\n      totalRequests: monthlyUsage[monthKey].requestCount,\n      lastUpdated: monthlyUsage[monthKey].lastUpdated,\n      percentUsed: Math.round((monthlyUsage[monthKey].charCount / 950000) * 100)\n    }\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "f532fce3-46d4-42b3-8ae4-645805f90cd1",
      "name": "Agréger pour email",
      "type": "n8n-nodes-base.code",
      "position": [
        1248,
        384
      ],
      "parameters": {
        "jsCode": "// Get data from Merge node (has original MP3 data)\nconst data = $('Merge').first().json;\nconst episodes = data.episodes || [];\nconst totalChars = data.totalCharacters || data.charCount || 0;\nconst totalEpisodes = data.totalEpisodes || episodes.length;\nconst mp3FileSize = data.fileSize || 0;\nconst mp3FileName = data.fileName || 'Podcast.mp3';\nconst mp3FileNameWithoutExt = mp3FileName.replace(/\\.mp3$/i, '').replace(/_Complete_\\d{4}-\\d{2}-\\d{2}$/, '');\n\n// Get R2 RSS feed URL from standalone config node\nconst config = $('⚙️ Workflow Config').first().json;\nconst R2_PUBLIC_URL = config.R2_PUBLIC_URL;\nconst RSS_FILENAME = config.RSS_FILENAME;\nconst rssFeedUrl = `${R2_PUBLIC_URL}/${RSS_FILENAME}`;\n\n// Get usage data\nconst usageData = data.updatedUsage || {\n  totalChars: totalChars,\n  totalRequests: totalEpisodes,\n  percentUsed: Math.round((totalChars / 950000) * 100)\n};\n\n// Build episode list HTML\nconst episodeListHTML = episodes.map((ep, idx) =>\n  `<li><strong>${ep.title}</strong><br>Episode ${ep.number} | ${ep.charCount.toLocaleString()} characters</li>`\n).join('\\n');\n\n// Build email HTML\nconst emailHTML = `\n<h2>🎧 New Podcast Episode Available!</h2>\n\n<p><strong>Total Sections:</strong> ${totalEpisodes} (stitched into 1 complete file)</p>\n<p><strong>Total Characters:</strong> ${totalChars.toLocaleString()}</p>\n<p><strong>File Size:</strong> ${Math.round(mp3FileSize / 1024 / 1024 * 100) / 100} MB</p>\n<p><strong>File Name:</strong> ${mp3FileNameWithoutExt}</p>\n\n<hr>\n\n<h3>📋 Sections Created:</h3>\n<ul>\n${episodeListHTML}\n</ul>\n\n<hr>\n\n<h3>📡 Subscribe to Podcast Feed</h3>\n<p>Copy this RSS feed URL and paste it into your podcast app:</p>\n<p style=\"background-color: #f0f0f0; padding: 10px; font-family: monospace; word-break: break-all; font-size: 12px;\">${rssFeedUrl}</p>\n<p><em>Supported apps: Apple Podcasts, Spotify, Pocket Casts, Overcast, Castro, and most other podcast apps</em></p>\n\n<hr>\n\n<h3>📊 Monthly Usage</h3>\n<p><strong>Used:</strong> ${usageData.totalChars.toLocaleString()} / 950,000 characters (${usageData.percentUsed}%)</p>\n<p><strong>Total Episodes This Month:</strong> ${usageData.totalRequests}</p>\n\n<hr>\n\n<p style=\"color: #666;\">✅ All ${totalEpisodes} sections are stitched into 1 MP3 file hosted on Cloudflare R2!</p>\n<p style=\"color: #666;\">✅ RSS feed generated and ready for subscription!</p>\n`;\n\nreturn {\n  json: {\n    totalEpisodes: totalEpisodes,\n    totalCharacters: totalChars,\n    rssFeedUrl: rssFeedUrl,\n    emailSubject: `New Podcast: ${totalEpisodes} Sections from ${mp3FileNameWithoutExt}`,\n    emailHTML: emailHTML,\n    usageData: usageData\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "67f70d82-cc76-471f-8cbd-986f24e3e84b",
      "name": "Envoyer l'email",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        1456,
        384
      ],
      "parameters": {
        "html": "={{ $json.emailHTML }}",
        "options": {},
        "subject": "={{ $json.emailSubject }}",
        "toEmail": "your-email@example.com",
        "fromEmail": "noreply@yourdomain.com",
        "emailFormat": "html"
      },
      "typeVersion": 2
    },
    {
      "id": "9c67b04e-d782-49dd-b577-f6abf22ad944",
      "name": "Fusionner",
      "type": "n8n-nodes-base.merge",
      "position": [
        256,
        384
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "ad592fb6-b1b5-4af6-a572-7e2994512bea",
      "name": "Note autocollante",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -80
      ],
      "parameters": {
        "color": 2,
        "width": 520,
        "height": 880,
        "content": "# Convert PDF Articles to Podcast\n\n**Transform any PDF into an audio podcast automatically!**\n\nThis workflow extracts text from PDFs, converts it to natural-sounding speech using Google Cloud Text-to-Speech, stores files in cloud storage, and generates an RSS feed compatible with all major podcast apps.\n\n## 🎯 Use Case\nPerfect for consuming long-form content (articles, research papers, study materials) while commuting, exercising, or multitasking. Turn your reading list into a personal podcast feed.\n\n## 🔧 How it Works\n1. Upload a PDF article via web form\n2. Extract and split text into sections\n3. Convert text to speech using Google TTS API\n4. Stitch audio sections into complete MP3 file\n5. Upload MP3 to Cloudflare R2 storage\n6. Generate iTunes-compatible RSS feed\n7. Upload RSS feed to R2\n8. Send email notification with RSS feed link\n9. Subscribe to feed in any podcast app!\n\n## 📋 Requirements\n* **Google Cloud Text-to-Speech API** - Free tier: 1M characters/month (WaveNet voices) - [Setup Guide](https://cloud.google.com/text-to-speech/docs)\n* **Cloudflare R2 Object Storage** - Free tier: 10GB storage, unlimited egress - [Setup Guide](https://developers.cloudflare.com/r2/)\n* **Cloudflare R2 Storage Community Node** - Install via: Settings → Community Nodes → Install → `n8n-nodes-cloudflare-r2-storage`\n* **Email Service** - SMTP or OAuth credentials (Gmail, SendGrid, etc.)\n\n## ⚙️ Configuration\nUpdate the **Workflow Config** node with:\n- R2 bucket name and public URL\n- RSS feed filename\n- Podcast artwork URL\n- Your email address\n\nSee full setup instructions in the [GitHub repo](https://github.com/devdutta/PDF-to-Podcast---N8N)!\n"
      },
      "typeVersion": 1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "9c67b04e-d782-49dd-b577-f6abf22ad944": {
      "main": [
        [
          {
            "node": "72ae9ece-7ea5-48f8-969c-79f70376ff1b",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "9789ab0b-b679-4314-a814-5407a42fb7d1": {
      "main": [
        [
          {
            "node": "dd7a9af8-5031-48a7-a9b8-9658941792d8",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ce537777-d89c-445b-b1ed-199beac096ce": {
      "main": [
        [
          {
            "node": "7948efd4-3d73-4496-95fa-12d25ff97fe0",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4f7236ac-852d-4c6f-9393-1fd6ed1c5610": {
      "main": [
        [
          {
            "node": "b533785e-e3bb-4bc6-b3a4-ab1f452ae330",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "72ae9ece-7ea5-48f8-969c-79f70376ff1b": {
      "main": [
        [
          {
            "node": "9789ab0b-b679-4314-a814-5407a42fb7d1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "dd7a9af8-5031-48a7-a9b8-9658941792d8": {
      "main": [
        [
          {
            "node": "b600862c-f392-49da-b9dd-4fe550c76615",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "f532fce3-46d4-42b3-8ae4-645805f90cd1": {
      "main": [
        [
          {
            "node": "67f70d82-cc76-471f-8cbd-986f24e3e84b",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "b533785e-e3bb-4bc6-b3a4-ab1f452ae330": {
      "main": [
        [
          {
            "node": "aac02256-376d-421d-897b-06bdfd1b9494",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "b600862c-f392-49da-b9dd-4fe550c76615": {
      "main": [
        [
          {
            "node": "f532fce3-46d4-42b3-8ae4-645805f90cd1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3a32b49b-bcac-4a61-b405-2f5d412a1191": {
      "main": [
        [
          {
            "node": "ce537777-d89c-445b-b1ed-199beac096ce",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "0cb6f54b-34b7-449c-b42c-1a8eb341001a": {
      "main": [
        [
          {
            "node": "4f7236ac-852d-4c6f-9393-1fd6ed1c5610",
            "type": "main",
            "index": 0
          },
          {
            "node": "8616c449-1148-438e-b1a0-57dcc9b6caed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8616c449-1148-438e-b1a0-57dcc9b6caed": {
      "main": [
        [
          {
            "node": "9c67b04e-d782-49dd-b577-f6abf22ad944",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "7948efd4-3d73-4496-95fa-12d25ff97fe0": {
      "main": [
        [
          {
            "node": "9b9c9e38-5b5a-491f-b3b7-5d0d39c73ad5",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "aac02256-376d-421d-897b-06bdfd1b9494": {
      "main": [
        [
          {
            "node": "3a32b49b-bcac-4a61-b405-2f5d412a1191",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "9b9c9e38-5b5a-491f-b3b7-5d0d39c73ad5": {
      "main": [
        [
          {
            "node": "9c67b04e-d782-49dd-b577-f6abf22ad944",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Foire aux questions

Comment utiliser ce workflow ?

Copiez le code de configuration JSON ci-dessus, créez un nouveau workflow dans votre instance n8n et sélectionnez "Importer depuis le JSON", collez la configuration et modifiez les paramètres d'authentification selon vos besoins.

Dans quelles scénarios ce workflow est-il adapté ?

Avancé

Est-ce payant ?

Ce workflow est entièrement gratuit et peut être utilisé directement. Veuillez noter que les services tiers utilisés dans le workflow (comme l'API OpenAI) peuvent nécessiter un paiement de votre part.

Informations sur le workflow
Niveau de difficulté
Avancé
Nombre de nœuds17
Catégorie-
Types de nœuds9
Description de la difficulté

Adapté aux utilisateurs avancés, avec des workflows complexes contenant 16+ nœuds

Auteur
Dev Dutta

Dev Dutta

@devdutta

Data and Product Analytics| Business Intelligence| Data Governance| Tech. Strategy| Driving Digital Transformation Programs| Process Automation | Agentic AI

Liens externes
Voir sur n8n.io

Partager ce workflow

Catégories

Catégories: 34