将PDF文章转换为播客 - N8N
高级
这是一个自动化工作流,包含 17 个节点。主要使用 Set, Code, Merge, ReadPDF, EmailSend 等节点。 使用Google TTS和Cloudflare R2将PDF文章转换为音频播客
前置要求
- •可能需要目标 API 的认证凭证
分类
-
工作流预览
可视化展示节点连接关系,支持缩放和平移
导出工作流
复制以下 JSON 配置到 n8n 导入,即可使用此工作流
{
"name": "将PDF文章转换为播客 - 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": "# 将PDF文章转换为播客"
},
"typeVersion": 1
}
],
"settings": {
"executionOrder": "v1"
},
"connections": {
"Merge": {
"main": [
[
{
"node": "Upload MP3 to R2",
"type": "main",
"index": 0
}
]
]
},
"Build RSS XML": {
"main": [
[
{
"node": "Upload RSS to R2",
"type": "main",
"index": 0
}
]
]
},
"Google TTS API": {
"main": [
[
{
"node": "Convert Audio to Binary",
"type": "main",
"index": 0
}
]
]
},
"Extract PDF Text": {
"main": [
[
{
"node": "Clean & Process Text",
"type": "main",
"index": 0
}
]
]
},
"Upload MP3 to R2": {
"main": [
[
{
"node": "Build RSS XML",
"type": "main",
"index": 0
}
]
]
},
"Upload RSS to R2": {
"main": [
[
{
"node": "Update Monthly Usage",
"type": "main",
"index": 0
}
]
]
},
"Aggregate for Email": {
"main": [
[
{
"node": "Send Email",
"type": "main",
"index": 0
}
]
]
},
"Clean & Process Text": {
"main": [
[
{
"node": "Detect Sections & Split",
"type": "main",
"index": 0
}
]
]
},
"Update Monthly Usage": {
"main": [
[
{
"node": "Aggregate for Email",
"type": "main",
"index": 0
}
]
]
},
"Check TTS Usage Limit": {
"main": [
[
{
"node": "Google TTS API",
"type": "main",
"index": 0
}
]
]
},
"Upload PDF for Podcast": {
"main": [
[
{
"node": "Extract PDF Text",
"type": "main",
"index": 0
},
{
"node": "⚙️ Workflow Config",
"type": "main",
"index": 0
}
]
]
},
"⚙️ Workflow Config": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Convert Audio to Binary": {
"main": [
[
{
"node": "Stitch All Mp3 Together",
"type": "main",
"index": 0
}
]
]
},
"Detect Sections & Split": {
"main": [
[
{
"node": "Check TTS Usage Limit",
"type": "main",
"index": 0
}
]
]
},
"Stitch All Mp3 Together": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
}
}
}常见问题
如何使用这个工作流?
复制上方的 JSON 配置代码,在您的 n8n 实例中创建新工作流并选择「从 JSON 导入」,粘贴配置后根据需要修改凭证设置即可。
这个工作流适合什么场景?
高级
需要付费吗?
本工作流完全免费,您可以直接导入使用。但请注意,工作流中使用的第三方服务(如 OpenAI API)可能需要您自行付费。
相关工作流推荐
基于GPT-4o技术和新闻情感分析的自动化股票分析报告
使用GPT-4o技术和新闻情感分析的自动化股票分析报告
Set
Code
Html
+14
45 节点Elay Guez
财务
地缘政治突发新闻警报
使用AI评分和Telegram警报过滤地缘政治突发新闻
If
Code
Merge
+9
30 节点Dev Dutta
市场调研
AI研究代理:使用Mistral最佳OCR自动分析PDF
AI研究代理:使用Mistral最佳OCR自动分析PDF
Set
Code
Gmail
+11
30 节点Derek Cheung
人工智能
基于AI的潜在客户资格评定与个性化触达(使用Relevance AI)
基于AI的潜在客户资格评定与个性化触达:使用Relevance AI
Set
Code
Gmail
+11
34 节点Diptamoy Barman
内容创作
基于AI的Loom视频问答(Gemini-2.5和Slack通知)
基于AI的Loom视频问答:使用Gemini-2.5和Slack通知
If
Set
Code
+7
24 节点GiovanniSegar
人工智能
社交媒体教程
基于AI的社交媒体内容生成与发布
Set
Code
Form
+11
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 查看 →
分享此工作流