8
n8n 中文网amn8n.com

将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)可能需要您自行付费。

工作流信息
难度等级
高级
节点数量17
分类-
节点类型9
难度说明

适合高级用户,包含 16+ 个节点的复杂工作流

作者
Dev Dutta

Dev Dutta

@devdutta

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

外部链接
在 n8n.io 查看

分享此工作流