PDF 문서를 팟캐스트로 변환 - N8N
고급
이것은자동화 워크플로우로, 17개의 노드를 포함합니다.주로 Set, Code, Merge, ReadPDF, EmailSend 등의 노드를 사용하며. Google TTS와 Cloudflare R2를 사용하여 PDF 문서를 오디오 팟캐스트로 변환
사전 요구사항
- •대상 API의 인증 정보가 필요할 수 있음
카테고리
-
워크플로우 미리보기
노드 연결 관계를 시각적으로 표시하며, 확대/축소 및 이동을 지원합니다
워크플로우 내보내기
다음 JSON 구성을 복사하여 n8n에 가져오면 이 워크플로우를 사용할 수 있습니다
{
"name": "Convert PDF Articles to Podcast - N8N",
"nodes": [
{
"id": "8616c449-1148-438e-b1a0-57dcc9b6caed",
"name": "⚙️ 워크플로우 설정",
"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": "팟캐스트용 PDF 업로드",
"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": "PDF 텍스트 추출",
"type": "n8n-nodes-base.readPDF",
"position": [
336,
128
],
"parameters": {
"binaryPropertyName": "pdfFile"
},
"typeVersion": 1
},
{
"id": "b533785e-e3bb-4bc6-b3a4-ab1f452ae330",
"name": "텍스트 정리 및 처리",
"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": "섹션 감지 및 분할",
"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": "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": "오디오를 바이너리로 변환",
"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": "모든 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": "MP3를 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": "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": "RSS를 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": "월간 사용량 업데이트",
"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": "이메일용 데이터 집계",
"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": "이메일 전송",
"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": "병합",
"type": "n8n-nodes-base.merge",
"position": [
256,
384
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineByPosition"
},
"typeVersion": 3.2
},
{
"id": "ad592fb6-b1b5-4af6-a572-7e2994512bea",
"name": "스티커 메모",
"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
}
]
]
}
}
}자주 묻는 질문
이 워크플로우를 어떻게 사용하나요?
위의 JSON 구성 코드를 복사하여 n8n 인스턴스에서 새 워크플로우를 생성하고 "JSON에서 가져오기"를 선택한 후, 구성을 붙여넣고 필요에 따라 인증 설정을 수정하세요.
이 워크플로우는 어떤 시나리오에 적합한가요?
고급
유료인가요?
이 워크플로우는 완전히 무료이며 직접 가져와 사용할 수 있습니다. 다만, 워크플로우에서 사용하는 타사 서비스(예: OpenAI API)는 사용자 직접 비용을 지불해야 할 수 있습니다.
관련 워크플로우 추천
GPT-4o 기술과 뉴스 감정 분석 기반 자동 주식 분석 리포트
사용GPT-4o技术및新闻情感분석의자동화股票분석보고서
Set
Code
Html
+
Set
Code
Html
45 노드Elay Guez
금융
지리정치 긴급 뉴스 알림
AI 평점과 Telegram 경고를 사용하여 지리정치 긴급 뉴스를 필터링합니다.
If
Code
Merge
+
If
Code
Merge
30 노드Dev Dutta
시장 조사
AI 연구 에이전트: Mistral 최적 OCR을 사용한 PDF 자동 분석
AI 연구 에이전트: Mistral 최적 OCR을 사용한 PDF 자동 분석
Set
Code
Gmail
+
Set
Code
Gmail
30 노드Derek Cheung
인공지능
AI 기반 잠재고객 자격 평가 및 개인화 접근 (Relevance AI 활용)
AI 기반 잠재고객 자격 평가 및 개인화 접근: Relevance AI 사용
Set
Code
Gmail
+
Set
Code
Gmail
34 노드Diptamoy Barman
콘텐츠 제작
AI 기반 Loom 비디오 질의응답 (Gemini-2.5 및 Slack 알림)
AI 기반 Loom 비디오 질의응답: Gemini-2.5 및 Slack 알림 활용
If
Set
Code
+
If
Set
Code
24 노드GiovanniSegar
인공지능
소셜 미디어 튜토리얼
AI 기반 소셜 미디어 콘텐츠 생성 및 게시
Set
Code
Form
+
Set
Code
Form
26 노드Amjid Ali
인공지능
워크플로우 정보
난이도
고급
노드 수17
카테고리-
노드 유형9
저자
Dev Dutta
@devduttaData and Product Analytics| Business Intelligence| Data Governance| Tech. Strategy| Driving Digital Transformation Programs| Process Automation | Agentic AI
외부 링크
n8n.io에서 보기 →
이 워크플로우 공유