PDF記事をPodcastに変換 - 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で表示 →
このワークフローを共有