Convertir artículos PDF en podcast - N8N
Este es unautomatización que contiene 17 nodos.Utiliza principalmente nodos como Set, Code, Merge, ReadPDF, EmailSend. Usar Google TTS y Cloudflare R2 para convertir artículos PDF en podcasts de audio
- •Pueden requerirse credenciales de autenticación para la API de destino
Nodos utilizados (17)
Categoría
{
"name": "Convert PDF Articles to Podcast - N8N",
"nodes": [
{
"id": "8616c449-1148-438e-b1a0-57dcc9b6caed",
"name": "⚙️ Configuración del flujo de trabajo",
"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": "Subir PDF para 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": "Extraer texto del PDF",
"type": "n8n-nodes-base.readPDF",
"position": [
336,
128
],
"parameters": {
"binaryPropertyName": "pdfFile"
},
"typeVersion": 1
},
{
"id": "b533785e-e3bb-4bc6-b3a4-ab1f452ae330",
"name": "Limpiar y procesar texto",
"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": "Detectar secciones y dividir",
"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": "Verificar límite de uso de 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 audio a binario",
"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": "Unir todos los 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": "Subir MP3 a 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": "Construir 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": "Subir RSS a 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": "Actualizar uso mensual",
"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": "Agregar para correo",
"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": "Enviar correo",
"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": "Combinar",
"type": "n8n-nodes-base.merge",
"position": [
256,
384
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineByPosition"
},
"typeVersion": 3.2
},
{
"id": "ad592fb6-b1b5-4af6-a572-7e2994512bea",
"name": "Nota adhesiva",
"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
}
]
]
}
}
}¿Cómo usar este flujo de trabajo?
Copie el código de configuración JSON de arriba, cree un nuevo flujo de trabajo en su instancia de n8n y seleccione "Importar desde JSON", pegue la configuración y luego modifique la configuración de credenciales según sea necesario.
¿En qué escenarios es adecuado este flujo de trabajo?
Avanzado
¿Es de pago?
Este flujo de trabajo es completamente gratuito, puede importarlo y usarlo directamente. Sin embargo, tenga en cuenta que los servicios de terceros utilizados en el flujo de trabajo (como la API de OpenAI) pueden requerir un pago por su cuenta.
Flujos de trabajo relacionados recomendados
Dev Dutta
@devduttaData and Product Analytics| Business Intelligence| Data Governance| Tech. Strategy| Driving Digital Transformation Programs| Process Automation | Agentic AI
Compartir este flujo de trabajo