diff --git a/.github/pull-request-assets/podcast-player.webp b/.github/pull-request-assets/podcast-player.webp new file mode 100644 index 000000000..dd7e528dc Binary files /dev/null and b/.github/pull-request-assets/podcast-player.webp differ diff --git a/.github/workflows/generate-podcast.yml b/.github/workflows/generate-podcast.yml new file mode 100644 index 000000000..95f43848b --- /dev/null +++ b/.github/workflows/generate-podcast.yml @@ -0,0 +1,107 @@ +name: Generate Blog Podcasts + +on: + push: + branches: + - main + paths: + - 'apps/web/src/content/blog/en/**/*.md' + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + slug: + description: Generate one eligible English blog post by slug. + required: false + type: string + limit: + default: '2' + description: Maximum number of episodes to create in this run. + required: false + type: string + +permissions: + contents: write + +concurrency: + group: blog-podcast-generation + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + generate: + if: vars.PODCAST_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + AUTOCONTENT_API_KEY: ${{ secrets.AUTOCONTENT_API_KEY }} + AUTOCONTENT_PODCAST_SHOW_ID: ${{ secrets.AUTOCONTENT_PODCAST_SHOW_ID }} + AUTOCONTENT_PODCAST_VOICE_1: ${{ secrets.AUTOCONTENT_PODCAST_VOICE_1 }} + AUTOCONTENT_PODCAST_VOICE_2: ${{ secrets.AUTOCONTENT_PODCAST_VOICE_2 }} + NODE_OPTIONS: '--max-old-space-size=16384' + PODCAST_SITE_URL: ${{ vars.PODCAST_SITE_URL || 'https://capgo.app' }} + UV_THREADPOOL_SIZE: '32' + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Test podcast generator + run: bun test scripts/blogs/generate-podcast.test.ts + - name: Select and generate episodes + id: generate + shell: bash + run: | + limit="${{ inputs.limit }}" + if [[ -z "$limit" ]]; then + if [[ "${{ github.event_name }}" == "schedule" ]]; then + limit=2 + else + limit=1 + fi + fi + args=(--limit "$limit") + + if [[ -n "${{ inputs.slug }}" ]]; then + args+=(--slug "${{ inputs.slug }}") + elif [[ "${{ github.event_name }}" == "push" ]]; then + base="${{ github.event.before }}" + head="${{ github.sha }}" + if [[ -z "$base" || "$base" == "0000000000000000000000000000000000000000" ]]; then + base="$(git rev-list --max-parents=0 "$head")" + fi + git diff --name-only --diff-filter=AM "$base" "$head" -- apps/web/src/content/blog/en > "$RUNNER_TEMP/changed-blog-files.txt" + args+=(--changed-files "$RUNNER_TEMP/changed-blog-files.txt") + else + args+=(--backfill) + fi + + bun run blogs:generate_podcast -- "${args[@]}" + - name: Verify website + if: steps.generate.outputs.manifest_changed == 'true' + run: bun run ci:verify:web + - name: Commit generated episode records + if: steps.generate.outputs.manifest_changed == 'true' + shell: bash + run: | + set -euo pipefail + if git diff --quiet -- apps/web/src/data/podcastEpisodes.json; then + echo 'No podcast manifest changes to commit.' + exit 0 + fi + + git config user.email 'action@github.com' + git config user.name 'GitHub Action' + git add apps/web/src/data/podcastEpisodes.json + git commit -m 'chore: record generated podcast episodes' + git pull --rebase origin "${{ github.event.repository.default_branch }}" + git push origin "HEAD:${{ github.event.repository.default_branch }}" diff --git a/.github/workflows/import-outrank.yml b/.github/workflows/import-outrank.yml index 6d193dcaf..2abee80be 100644 --- a/.github/workflows/import-outrank.yml +++ b/.github/workflows/import-outrank.yml @@ -74,6 +74,17 @@ jobs: if: steps.import.outputs.files != '' run: xargs bunx prettier --write < .outrank-imported-files + - name: Generate podcasts for imported articles + if: steps.import.outputs.files != '' && vars.PODCAST_AUTOMATION_ENABLED == 'true' + continue-on-error: true + env: + AUTOCONTENT_API_KEY: ${{ secrets.AUTOCONTENT_API_KEY }} + AUTOCONTENT_PODCAST_SHOW_ID: ${{ secrets.AUTOCONTENT_PODCAST_SHOW_ID }} + AUTOCONTENT_PODCAST_VOICE_1: ${{ secrets.AUTOCONTENT_PODCAST_VOICE_1 }} + AUTOCONTENT_PODCAST_VOICE_2: ${{ secrets.AUTOCONTENT_PODCAST_VOICE_2 }} + PODCAST_SITE_URL: ${{ vars.PODCAST_SITE_URL || 'https://capgo.app' }} + run: bun run blogs:generate_podcast -- --changed-files .outrank-imported-files --limit 1 + - name: Verify website if: steps.import.outputs.files != '' run: bun run ci:verify:web @@ -89,7 +100,7 @@ jobs: run: | rm -f .outrank-imported-files - if [[ -z "$(git status --porcelain apps/web/src/content/blog/en)" ]]; then + if [[ -z "$(git status --porcelain -- apps/web/src/content/blog/en apps/web/src/data/podcastEpisodes.json)" ]]; then echo "No blog changes to publish." exit 0 fi @@ -97,7 +108,7 @@ jobs: default_branch="${{ github.event.repository.default_branch }}" git config user.email "action@github.com" git config user.name "GitHub Action" - git add apps/web/src/content/blog/en + git add apps/web/src/content/blog/en apps/web/src/data/podcastEpisodes.json git commit -m "chore: import Outrank articles" git pull --rebase origin "$default_branch" git push origin "HEAD:$default_branch" diff --git a/apps/web/public/_headers b/apps/web/public/_headers index 71903d8ee..392598d66 100644 --- a/apps/web/public/_headers +++ b/apps/web/public/_headers @@ -78,6 +78,13 @@ /sitemap.xml Content-Type: text/xml + +/podcast.xml + Content-Type: application/rss+xml; charset=utf-8 + Access-Control-Allow-Origin: * + Access-Control-Allow-Methods: GET, HEAD + Cache-Control: public, max-age=3600 + Link: ; rel="self"; type="application/rss+xml" /* X-Content-Type-Options: nosniff X-Frame-Options: DENY diff --git a/apps/web/public/capgo-podcast.png b/apps/web/public/capgo-podcast.png new file mode 100644 index 000000000..cf10ba73a Binary files /dev/null and b/apps/web/public/capgo-podcast.png differ diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt index a21d55534..846b37b9a 100644 --- a/apps/web/public/robots.txt +++ b/apps/web/public/robots.txt @@ -3,3 +3,4 @@ Allow: / Content-Signal: ai-train=yes, search=yes, ai-input=yes Sitemap: https://capgo.app/sitemap-index.xml +Sitemap: https://capgo.app/podcast.xml diff --git a/apps/web/src/components/PodcastEpisode.astro b/apps/web/src/components/PodcastEpisode.astro new file mode 100644 index 000000000..f15b2fa8b --- /dev/null +++ b/apps/web/src/components/PodcastEpisode.astro @@ -0,0 +1,33 @@ +--- +import { formatPodcastDurationLabel, getPodcastAudioMimeType, getPodcastEpisodeSummary, type PlayablePodcastEpisode } from '@/lib/podcast' + +interface Props { + episode: PlayablePodcastEpisode + feedUrl: string +} + +const { episode, feedUrl } = Astro.props +const duration = formatPodcastDurationLabel(episode.durationSeconds) +const summary = getPodcastEpisodeSummary(episode) +const publishedAt = new Date(episode.generatedAt) +const publishedDate = Number.isNaN(publishedAt.getTime()) ? undefined : new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(publishedAt) +--- + +
+

Capgo podcast, English episode

+

Listen: {episode.title}

+

{summary}

+ + + +
+ {(publishedDate || duration) && {[publishedDate, duration].filter(Boolean).join(' ยท ')}} + RSS feed + Open MP3 +
+
diff --git a/apps/web/src/components/SEO.astro b/apps/web/src/components/SEO.astro index f467bd95d..967eaf4a0 100644 --- a/apps/web/src/components/SEO.astro +++ b/apps/web/src/components/SEO.astro @@ -122,6 +122,7 @@ const titleFix = titleString || (imageUrl.split('/').pop() || '.').split('.')[0] + diff --git a/apps/web/src/data/podcastEpisodes.json b/apps/web/src/data/podcastEpisodes.json new file mode 100644 index 000000000..ec5fd838b --- /dev/null +++ b/apps/web/src/data/podcastEpisodes.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "episodes": [] +} diff --git a/apps/web/src/lib/podcast.ts b/apps/web/src/lib/podcast.ts new file mode 100644 index 000000000..0d51ade5d --- /dev/null +++ b/apps/web/src/lib/podcast.ts @@ -0,0 +1,264 @@ +import type { RSSOptions } from '@astrojs/rss' +import manifestJson from '../data/podcastEpisodes.json' + +export const podcastFeedPath = '/podcast.xml' +export const podcastName = 'Capgo Podcast' +export const podcastDescription = 'Practical conversations for teams building and shipping Capacitor applications.' +export const podcastLanguage = 'en' +export const podcastCategory = 'Technology' +export const podcastAuthor = 'Capgo' +export const podcastEmail = 'support@capgo.app' +export const podcastCoverPath = '/capgo-podcast.png' + +export interface PodcastEpisode { + audioMimeType?: string + description: string + durationSeconds?: number + episodeGuid?: string + fileSize?: number + generatedAt: string + providerAudioUrl?: string + providerEpisodeId: string | number + requestId?: string + slug: string + sourceArticleUrl: string + title: string +} + +export interface PlayablePodcastEpisode extends PodcastEpisode { + providerAudioUrl: string +} + +export interface FeedPodcastEpisode extends PlayablePodcastEpisode { + fileSize: number +} + +export interface PodcastManifest { + episodes: PodcastEpisode[] + version: 1 +} + +export interface PodcastStructuredData { + audio: Record + audioId: string + episode: Record +} + +const podcastManifest = manifestJson as unknown as PodcastManifest + +function hasHttpsUrl(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) return false + + try { + return new URL(value).protocol === 'https:' + } catch { + return false + } +} + +function hasPositiveNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function hasPositiveInteger(value: unknown): value is number { + return hasPositiveNumber(value) && Number.isInteger(value) +} + +function hasValidDate(value: unknown): value is string { + return typeof value === 'string' && !Number.isNaN(new Date(value).getTime()) +} + +function normalizedSiteUrl(siteUrl: string): string { + return new URL(siteUrl).toString() +} + +function escapeXml(value: string): string { + const escapes: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } + return value.replace(/[&<>"']/g, (character) => escapes[character] || character) +} + +export function getPodcastEpisodes(): PodcastEpisode[] { + return Array.isArray(podcastManifest.episodes) ? podcastManifest.episodes : [] +} + +export function isPlayablePodcastEpisode(episode: PodcastEpisode): episode is PlayablePodcastEpisode { + return hasHttpsUrl(episode.providerAudioUrl) && hasValidDate(episode.generatedAt) +} + +export function isPodcastFeedEpisode(episode: PodcastEpisode): episode is FeedPodcastEpisode { + return isPlayablePodcastEpisode(episode) && hasPositiveInteger(episode.fileSize) && hasHttpsUrl(episode.sourceArticleUrl) +} + +export function getPodcastEpisodeForSlug(slug: string, episodes: readonly PodcastEpisode[] = getPodcastEpisodes()): PlayablePodcastEpisode | undefined { + return episodes.find((episode): episode is PlayablePodcastEpisode => episode.slug === slug && isPlayablePodcastEpisode(episode)) +} + +export function getPodcastFeedEpisodes(episodes: readonly PodcastEpisode[] = getPodcastEpisodes()): FeedPodcastEpisode[] { + return episodes + .filter(isPodcastFeedEpisode) + .toSorted((left, right) => new Date(right.generatedAt).getTime() - new Date(left.generatedAt).getTime() || left.slug.localeCompare(right.slug)) +} + +export function getPodcastFeedUrl(siteUrl: string): string { + return new URL(podcastFeedPath, normalizedSiteUrl(siteUrl)).toString() +} + +export function getPodcastCoverUrl(siteUrl: string): string { + return new URL(podcastCoverPath, normalizedSiteUrl(siteUrl)).toString() +} + +export function getPodcastAudioMimeType(episode: PodcastEpisode): string { + return episode.audioMimeType || 'audio/mpeg' +} + +export function getPodcastEpisodeGuid(episode: PodcastEpisode): string { + return episode.episodeGuid || 'capgo-blog-podcast:' + episode.slug + ':' + String(episode.providerEpisodeId) +} + +export function getPodcastEpisodeSummary(episode: PodcastEpisode): string { + const sourceLink = 'Read the full article: ' + episode.sourceArticleUrl + const suffix = '\n\n' + sourceLink + const summary = episode.description.endsWith(suffix) ? episode.description.slice(0, -suffix.length).trim() : episode.description.trim() + + return summary || episode.title +} + +export function formatPodcastDuration(seconds: number | undefined): string | undefined { + if (!hasPositiveNumber(seconds)) return undefined + + const totalSeconds = Math.round(seconds) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const remainingSeconds = totalSeconds % 60 + + return [hours, minutes, remainingSeconds].map((part) => String(part).padStart(2, '0')).join(':') +} + +export function formatPodcastDurationLabel(seconds: number | undefined): string | undefined { + if (!hasPositiveNumber(seconds)) return undefined + + const totalSeconds = Math.round(seconds) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + + if (hours) return hours + ' hr ' + minutes + ' min' + if (minutes) return minutes + ' min' + return totalSeconds + ' sec' +} + +export function toIso8601Duration(seconds: number | undefined): string | undefined { + if (!hasPositiveNumber(seconds)) return undefined + + const totalSeconds = Math.round(seconds) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const remainingSeconds = totalSeconds % 60 + const parts = [] + + if (hours) parts.push(hours + 'H') + if (minutes) parts.push(minutes + 'M') + if (remainingSeconds || parts.length === 0) parts.push(remainingSeconds + 'S') + + return 'PT' + parts.join('') +} +export function createPodcastStructuredData(input: { articleUrl: string; episode: PlayablePodcastEpisode; imageUrl?: string; siteUrl: string }): PodcastStructuredData { + const audioId = input.articleUrl + '#podcast-audio' + const episodeId = input.articleUrl + '#podcast-episode' + const feedUrl = getPodcastFeedUrl(input.siteUrl) + const duration = toIso8601Duration(input.episode.durationSeconds) + const contentSize = hasPositiveInteger(input.episode.fileSize) ? String(input.episode.fileSize) + ' B' : undefined + + const audio: Record = { + '@type': 'AudioObject', + '@id': audioId, + associatedArticle: { '@id': input.episode.sourceArticleUrl + '#newsarticle' }, + contentUrl: input.episode.providerAudioUrl, + encodingFormat: getPodcastAudioMimeType(input.episode), + uploadDate: input.episode.generatedAt, + } + + if (duration) audio.duration = duration + if (contentSize) audio.contentSize = contentSize + + const episode: Record = { + '@type': 'PodcastEpisode', + '@id': episodeId, + associatedMedia: { '@id': audioId }, + datePublished: input.episode.generatedAt, + description: getPodcastEpisodeSummary(input.episode), + inLanguage: podcastLanguage, + isBasedOn: { '@id': input.episode.sourceArticleUrl + '#newsarticle' }, + name: input.episode.title, + partOfSeries: { + '@type': 'PodcastSeries', + '@id': feedUrl + '#podcast-series', + name: podcastName, + url: feedUrl, + webFeed: feedUrl, + }, + url: input.articleUrl + '#podcast', + } + + if (input.imageUrl) episode.image = input.imageUrl + + return { audio, audioId, episode } +} + +export function createPodcastRssOptions(siteUrl: string, episodes: readonly PodcastEpisode[] = getPodcastEpisodes()): RSSOptions { + const feedUrl = getPodcastFeedUrl(siteUrl) + const coverUrl = getPodcastCoverUrl(siteUrl) + const feedEpisodes = getPodcastFeedEpisodes(episodes) + const channelData = [ + '', + '' + podcastLanguage + '', + '' + escapeXml(coverUrl) + '' + podcastName + '' + escapeXml(normalizedSiteUrl(siteUrl)) + '', + '' + podcastAuthor + '', + '' + escapeXml(podcastDescription) + '', + 'false', + '' + podcastAuthor + '' + podcastEmail + '', + '', + '', + ].join('') + + return { + customData: channelData, + description: podcastDescription, + items: feedEpisodes.map((episode) => { + const duration = formatPodcastDuration(episode.durationSeconds) + const itemData = [ + '' + escapeXml(getPodcastEpisodeGuid(episode)) + '', + 'full', + 'false', + '', + ] + if (duration) itemData.push('' + duration + '') + + return { + categories: [podcastCategory], + customData: itemData.join(''), + description: episode.description, + enclosure: { + length: episode.fileSize, + type: getPodcastAudioMimeType(episode), + url: episode.providerAudioUrl, + }, + link: episode.sourceArticleUrl, + pubDate: new Date(episode.generatedAt), + title: episode.title, + } + }), + site: normalizedSiteUrl(siteUrl), + title: podcastName, + trailingSlash: true, + xmlns: { + atom: 'http://www.w3.org/2005/Atom', + itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', + }, + } +} diff --git a/apps/web/src/pages/blog/[slug].astro b/apps/web/src/pages/blog/[slug].astro index 00fd192ff..8b128f331 100644 --- a/apps/web/src/pages/blog/[slug].astro +++ b/apps/web/src/pages/blog/[slug].astro @@ -1,9 +1,11 @@ --- import Blog from '@/components/Blog.astro' import GetStarted from '@/components/GetStarted.astro' +import PodcastEpisode from '@/components/PodcastEpisode.astro' import { formatTime } from '@/config/app' import Layout from '@/layouts/Layout.astro' -import { createNewsArticleLdJson } from '@/lib/ldJson' +import { createLdJsonGraph, createNewsArticleLdJson } from '@/lib/ldJson' +import { createPodcastStructuredData, getPodcastEpisodeForSlug, getPodcastFeedUrl } from '@/lib/podcast' import m from '@/copy/messages' import { defaultLocale, locales } from '@/services/locale' import type { GetStaticPaths } from 'astro' @@ -83,6 +85,7 @@ const content: { description?: string image?: string author?: string + audio?: string ldJSON?: Object keywords?: string[] alternateVersions?: any[] @@ -113,10 +116,21 @@ if (entry.data.tag) { content['articleTags'] = tags } -// Create improved ldJSON using helper function -const articleUrl = getRelativeLocaleUrl(Astro.locals.locale, `/blog/${entry.data.slug}/`) +const baseUrl = Astro.locals.runtimeConfig.public.baseUrl +const articleUrl = new URL(getRelativeLocaleUrl(Astro.locals.locale, '/blog/' + entry.data.slug + '/'), baseUrl).toString() +const podcastEpisode = getPodcastEpisodeForSlug(entry.data.slug) +const podcastStructuredData = podcastEpisode + ? createPodcastStructuredData({ + articleUrl, + episode: podcastEpisode, + imageUrl: content['image'], + siteUrl: baseUrl, + }) + : undefined -content['ldJSON'] = createNewsArticleLdJson(Astro.locals.runtimeConfig.public, { +if (podcastEpisode) content['audio'] = podcastEpisode.providerAudioUrl + +const articleLdJson = createNewsArticleLdJson(Astro.locals.runtimeConfig.public, { title: entry.data.title, description: entry.data.description || '', url: articleUrl, @@ -131,6 +145,17 @@ content['ldJSON'] = createNewsArticleLdJson(Astro.locals.runtimeConfig.public, { articleBody: striptags(entry.rendered?.html || ''), locale: Astro.locals.locale, }) + +if (podcastStructuredData) { + ;(articleLdJson as unknown as Record).audio = { '@id': podcastStructuredData.audioId } +} + +const podcastLdJsonEntities = podcastStructuredData ? ([podcastStructuredData.episode, podcastStructuredData.audio] as unknown as import('schema-dts').Thing[]) : undefined + +content['ldJSON'] = createLdJsonGraph(Astro.locals.runtimeConfig.public, articleLdJson, { + additionalEntities: podcastLdJsonEntities, + includeOrganization: true, +}) --- @@ -188,6 +213,7 @@ content['ldJSON'] = createNewsArticleLdJson(Astro.locals.runtimeConfig.public, { src={entry.data.head_image} class="rounded border border-white/5 object-cover" /> + {podcastEpisode && }
diff --git a/apps/web/src/pages/podcast.xml.ts b/apps/web/src/pages/podcast.xml.ts new file mode 100644 index 000000000..cf74f05f9 --- /dev/null +++ b/apps/web/src/pages/podcast.xml.ts @@ -0,0 +1,21 @@ +import { createPodcastRssOptions, getPodcastFeedUrl } from '@/lib/podcast' +import { getRssString } from '@astrojs/rss' +import type { APIRoute } from 'astro' + +export const prerender = true + +export const GET: APIRoute = async (context) => { + const siteUrl = context.site?.toString() || context.url.origin + const feedUrl = getPodcastFeedUrl(siteUrl) + const body = await getRssString(createPodcastRssOptions(siteUrl)) + + return new Response(body, { + headers: { + 'Access-Control-Allow-Methods': 'GET, HEAD', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'public, max-age=3600', + 'Content-Type': 'application/rss+xml; charset=utf-8', + Link: '<' + feedUrl + '>; rel="self"; type="application/rss+xml"', + }, + }) +} diff --git a/bun.lock b/bun.lock index 07091cd6e..af3554276 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "capgo-website", "dependencies": { "@astrojs/cloudflare": "14.0.1", + "@astrojs/rss": "4.0.19", "@astrojs/sitemap": "3.7.3", "@astrojs/starlight": "0.41.1", "@astrojs/starlight-docsearch": "0.7.0", @@ -192,6 +193,8 @@ "@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="], + "@astrojs/rss": ["@astrojs/rss@4.0.19", "", { "dependencies": { "fast-xml-parser": "^5.5.7", "piccolore": "^0.1.3", "zod": "^4.3.6" } }, "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA=="], + "@astrojs/sitemap": ["@astrojs/sitemap@3.7.3", "", { "dependencies": { "sitemap": "^9.0.0", "stream-replace-string": "^2.0.0", "zod": "^4.3.6" } }, "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA=="], "@astrojs/starlight": ["@astrojs/starlight@0.41.1", "", { "dependencies": { "@astrojs/markdown-satteri": "^0.3.2", "@astrojs/mdx": "^7.0.0", "@astrojs/sitemap": "^3.7.2", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.44.0", "bcp-47": "^2.1.0", "hast-util-from-html": "^2.0.3", "hast-util-select": "^6.0.4", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", "i18next": "^26.0.7", "js-yaml": "^4.1.1", "klona": "^2.0.6", "magic-string": "^0.30.21", "mdast-util-directive": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.5.2", "rehype": "^13.0.2", "rehype-format": "^5.0.1", "remark-directive": "^4.0.0", "satteri": "^0.9.1", "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.2.0", "astro": "^7.0.2" }, "optionalPeers": ["@astrojs/markdown-remark"] }, "sha512-avf2OmrVg6GdVU18juebjjIIuLa+uS3syHuJ/3yDaEFP/8it+YvcxRrYDSf7K6rC4v770UxIddba2hAqQyTeYA=="], diff --git a/package.json b/package.json index ccb68c888..5c18ac5e1 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "generate:plugins-readme": "bun run scripts/generate-plugins-readme.ts", "generate:blog-images": "bun run scripts/generate-blog-images.ts", "blogs:normalize_tags": "bun run scripts/blogs/normalize_tags.ts", + "blogs:generate_podcast": "bun run scripts/blogs/generate-podcast.ts", "visual-diff:setup": "bun run scripts/visual-diff.mjs install", "visual-diff:capture:before": "bun run scripts/visual-diff.mjs capture before", "visual-diff:capture:after": "bun run scripts/visual-diff.mjs capture after", @@ -62,6 +63,7 @@ }, "dependencies": { "@astrojs/cloudflare": "14.0.1", + "@astrojs/rss": "4.0.19", "@astrojs/sitemap": "3.7.3", "@astrojs/starlight": "0.41.1", "@astrojs/starlight-docsearch": "0.7.0", diff --git a/scripts/blogs/generate-podcast.test.ts b/scripts/blogs/generate-podcast.test.ts new file mode 100644 index 000000000..5a167e1c8 --- /dev/null +++ b/scripts/blogs/generate-podcast.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test' +import { + buildEpisodeDescription, + findProviderEpisodeByArticle, + HttpAutoContentClient, + publishCandidates, + type AutoContentClient, + type BlogPost, + type PodcastConfiguration, + type PodcastManifest, +} from './generate-podcast' + +const post: BlogPost = { + content: 'Capgo lets teams publish live updates for Capacitor applications.', + createdAt: '2026-07-11T00:00:00.000Z', + description: 'How Capgo makes live updates safer.', + filePath: '/tmp/capgo-live-updates.md', + slug: 'capgo-live-updates', + sourceArticleUrl: 'https://capgo.app/blog/capgo-live-updates/', + title: 'Capgo Live Updates', +} + +const configuration: PodcastConfiguration = { + apiBaseUrl: 'https://api.autocontentapi.com', + apiKey: 'test-key', + podcastShowId: 'show-1', + pollIntervalMs: 1, + pollTimeoutMs: 1, + siteUrl: 'https://capgo.app/', + voice1: 12, + voice2: 34, +} + +describe('blog podcast generation', () => { + test('always includes the canonical source article in the provider description', () => { + expect(buildEpisodeDescription(post)).toBe('How Capgo makes live updates safer.\n\nRead the full article: https://capgo.app/blog/capgo-live-updates/') + }) + + test('recognizes an already attached provider episode by its source article', () => { + const episode = findProviderEpisodeByArticle([{ description: 'Read the full article: https://capgo.app/blog/capgo-live-updates/', episodeId: 42 }], post.sourceArticleUrl) + + expect(episode?.episodeId).toBe(42) + }) + + test('sends numeric voice IDs and article text to the documented AutoContent endpoint', async () => { + const requests: Array<{ body?: string; url: string }> = [] + const client = new HttpAutoContentClient(configuration, async (input, init) => { + requests.push({ body: init?.body?.toString(), url: input.toString() }) + return new Response(JSON.stringify({ request_id: 'request-1', status: 0 }), { status: 200 }) + }) + + await expect( + client.createPodcast({ + callbackData: 'blog:capgo-live-updates', + content: post.content, + instructions: 'Use only this article.', + voice1: 12, + voice2: 34, + }), + ).resolves.toEqual({ requestId: 'request-1' }) + + expect(requests[0].url).toBe('https://api.autocontentapi.com/content/CreatePodcastCustomVoices') + expect(JSON.parse(requests[0].body || '{}')).toMatchObject({ + callbackData: 'blog:capgo-live-updates', + resources: [{ content: post.content, type: 'text' }], + voice1: 12, + voice2: 34, + }) + }) + + test('creates, waits for, and attaches exactly one episode when capacity remains', async () => { + const calls: string[] = [] + const client: AutoContentClient = { + attachEpisode: async ({ description, requestId, title }) => { + calls.push(`attach:${requestId}`) + expect(description).toContain(post.sourceArticleUrl) + expect(title).toBe('Capgo podcast: Capgo Live Updates') + return { episodeId: 42 } + }, + createPodcast: async ({ callbackData, content, instructions, voice1, voice2 }) => { + calls.push('create') + expect(callbackData).toBe('blog:capgo-live-updates') + expect(content).toContain(post.content) + expect(instructions).toContain(post.sourceArticleUrl) + expect([voice1, voice2]).toEqual([12, 34]) + return { requestId: 'request-1' } + }, + getStatus: async () => { + calls.push('status') + return { audio_duration: 90, audio_url: 'https://audio.example/episode.mp3', file_size: 1024, status: 100 } + }, + getUsage: async () => ({ allowedDailyPodcasts: 2, usedDailyPodcasts: 1 }), + listShowEpisodes: async () => [], + } + const manifest: PodcastManifest = { episodes: [], version: 1 } + + const result = await publishCandidates([post], manifest, configuration, client) + + expect(calls).toEqual(['create', 'status', 'attach:request-1']) + expect(result.publishedCount).toBe(1) + expect(result.deferredCount).toBe(0) + expect(result.manifest.episodes).toHaveLength(1) + expect(result.manifest.episodes[0]).toMatchObject({ + audioMimeType: 'audio/mpeg', + episodeGuid: 'capgo-blog-podcast:capgo-live-updates:42', + providerEpisodeId: 42, + requestId: 'request-1', + slug: 'capgo-live-updates', + sourceArticleUrl: post.sourceArticleUrl, + }) + }) + + test('defers a candidate without creating audio when the daily allowance is exhausted', async () => { + const client: AutoContentClient = { + attachEpisode: async () => ({ episodeId: 42 }), + createPodcast: async () => { + throw new Error('should not create a podcast') + }, + getStatus: async () => ({ status: 0 }), + getUsage: async () => ({ allowedDailyPodcasts: 1, usedDailyPodcasts: 1 }), + listShowEpisodes: async () => [], + } + + const result = await publishCandidates([post], { episodes: [], version: 1 }, configuration, client) + + expect(result.publishedCount).toBe(0) + expect(result.deferredCount).toBe(1) + expect(result.manifestChanged).toBeFalse() + }) +}) diff --git a/scripts/blogs/generate-podcast.ts b/scripts/blogs/generate-podcast.ts new file mode 100644 index 000000000..6517034e5 --- /dev/null +++ b/scripts/blogs/generate-podcast.ts @@ -0,0 +1,575 @@ +import { globSync } from 'glob' +import matter from 'gray-matter' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { isAbsolute, relative, resolve, sep } from 'node:path' + +const PROJECT_ROOT = process.cwd() +const BLOG_DIRECTORY = resolve(PROJECT_ROOT, 'apps/web/src/content/blog/en') +const MANIFEST_PATH = resolve(PROJECT_ROOT, 'apps/web/src/data/podcastEpisodes.json') +const DEFAULT_API_BASE_URL = 'https://api.autocontentapi.com' +const DEFAULT_SITE_URL = 'https://capgo.app' +const DEFAULT_LIMIT = 1 +const COMPLETED_STATUS = 100 +const DEFAULT_POLL_INTERVAL_MS = 10_000 +const DEFAULT_POLL_TIMEOUT_MS = 20 * 60 * 1000 + +type Environment = Record + +export interface BlogPost { + content: string + createdAt: string + description?: string + filePath: string + slug: string + sourceArticleUrl: string + title: string +} + +export interface PodcastEpisode { + audioMimeType?: string + description: string + durationSeconds?: number + episodeGuid?: string + fileSize?: number + generatedAt: string + providerAudioUrl?: string + providerEpisodeId: string | number + requestId?: string + slug: string + sourceArticleUrl: string + title: string +} + +export interface PodcastManifest { + episodes: PodcastEpisode[] + version: 1 +} + +export interface PodcastConfiguration { + apiBaseUrl: string + apiKey: string + podcastShowId: string + pollIntervalMs: number + pollTimeoutMs: number + siteUrl: string + voice1: number + voice2: number +} + +export interface PodcastGenerationOptions { + backfill: boolean + changedFilesPath?: string + dryRun: boolean + limit: number + slug?: string +} + +export interface DailyUsage { + allowedDailyPodcasts?: number + usedDailyPodcasts?: number +} + +export interface PodcastStatus { + audio_duration?: number + audio_url?: string + error_code?: number + error_message?: string + file_size?: number + status?: number | string +} + +export interface CreatePodcastInput { + callbackData: string + content: string + instructions: string + voice1: number + voice2: number +} + +export interface AttachEpisodeInput { + description: string + requestId: string + title: string +} + +export interface AutoContentClient { + attachEpisode(input: AttachEpisodeInput): Promise<{ episodeId: string | number }> + createPodcast(input: CreatePodcastInput): Promise<{ requestId: string }> + getUsage(): Promise + getStatus(requestId: string): Promise + listShowEpisodes(): Promise +} + +export interface PublishCandidatesResult { + deferredCount: number + manifest: PodcastManifest + manifestChanged: boolean + publishedCount: number + reconciledCount: number +} + +const sleep = (milliseconds: number) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +function parsePositiveInteger(value: string | undefined, name: string, fallback?: number): number { + if (!value && fallback !== undefined) return fallback + const parsed = Number.parseInt(value ?? '', 10) + if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer.`) + return parsed +} + +function normalizeSiteUrl(siteUrl: string): string { + return new URL(siteUrl).toString() +} + +function sourceArticleUrl(siteUrl: string, slug: string): string { + return new URL(`blog/${encodeURIComponent(slug)}/`, normalizeSiteUrl(siteUrl)).toString() +} + +function isInsideDirectory(directory: string, candidate: string): boolean { + const pathRelative = relative(directory, candidate) + return pathRelative !== '' && !pathRelative.startsWith(`..${sep}`) && pathRelative !== '..' && !isAbsolute(pathRelative) +} + +function parseDate(value: unknown): string { + const date = value instanceof Date ? value : new Date(typeof value === 'string' ? value : 0) + return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString() +} + +function parseOptionalDate(value: unknown): string | undefined { + if (typeof value !== 'string' || !value.trim()) return undefined + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? undefined : date.toISOString() +} + +export function buildPodcastEpisodeGuid(slug: string, providerEpisodeId: string | number): string { + return 'capgo-blog-podcast:' + slug + ':' + providerEpisodeId +} + +function readBlogPost(filePath: string, siteUrl: string): BlogPost | undefined { + const parsed = matter(readFileSync(filePath, 'utf8')) + const frontmatter = parsed.data as Record + const slug = asString(frontmatter.slug) + const title = asString(frontmatter.title) + const locale = asString(frontmatter.locale) + + if (frontmatter.published === false || (locale !== undefined && locale !== 'en')) return undefined + if (!slug || !title || !parsed.content.trim()) return undefined + + return { + content: parsed.content.trim(), + createdAt: parseDate(frontmatter.created_at), + description: asString(frontmatter.description), + filePath, + slug, + sourceArticleUrl: sourceArticleUrl(siteUrl, slug), + title, + } +} + +function sortPosts(posts: BlogPost[]): BlogPost[] { + return posts.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.slug.localeCompare(right.slug)) +} + +function allBlogFiles(): string[] { + return globSync('**/*.md', { absolute: true, cwd: BLOG_DIRECTORY, nodir: true }) +} + +function changedBlogFiles(changedFilesPath: string): string[] { + const rawPaths = readFileSync(changedFilesPath, 'utf8') + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + + return rawPaths.flatMap((rawPath) => { + const filePath = resolve(PROJECT_ROOT, rawPath) + if (!filePath.endsWith('.md') || !isInsideDirectory(BLOG_DIRECTORY, filePath) || !existsSync(filePath)) return [] + return [filePath] + }) +} + +export function readPodcastManifest(manifestPath = MANIFEST_PATH): PodcastManifest { + if (!existsSync(manifestPath)) return { episodes: [], version: 1 } + + const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as Partial + if (parsed.version !== 1 || !Array.isArray(parsed.episodes)) throw new Error(`Invalid podcast manifest: ${manifestPath}`) + + return { + episodes: parsed.episodes.filter((episode): episode is PodcastEpisode => isRecord(episode) && typeof episode.slug === 'string'), + version: 1, + } +} + +export function writePodcastManifest(manifest: PodcastManifest, manifestPath = MANIFEST_PATH): void { + const sortedManifest: PodcastManifest = { + episodes: manifest.episodes.toSorted((left, right) => left.slug.localeCompare(right.slug)), + version: 1, + } + writeFileSync(manifestPath, `${JSON.stringify(sortedManifest, null, 2)}\n`, 'utf8') +} + +export function getEligibleBlogPosts(options: PodcastGenerationOptions, manifest: PodcastManifest, siteUrl = DEFAULT_SITE_URL): BlogPost[] { + if (!options.backfill && !options.changedFilesPath && !options.slug) { + throw new Error('Pass --backfill, --changed-files , or --slug .') + } + + const files = options.changedFilesPath ? changedBlogFiles(options.changedFilesPath) : allBlogFiles() + const posts = sortPosts( + files.flatMap((filePath) => { + const post = readBlogPost(filePath, siteUrl) + return post ? [post] : [] + }), + ) + const scopedPosts = options.slug ? posts.filter((post) => post.slug === options.slug) : posts + const publishedSlugs = new Set(manifest.episodes.map((episode) => episode.slug)) + + return scopedPosts.filter((post) => !publishedSlugs.has(post.slug)).slice(0, options.limit) +} + +export function buildEpisodeDescription(post: BlogPost): string { + const summary = post.description || `A two-host conversation about ${post.title}.` + return `${summary}\n\nRead the full article: ${post.sourceArticleUrl}` +} + +function buildPodcastInstructions(post: BlogPost): string { + return [ + 'Create a natural, useful two-host conversation in English.', + 'Base every claim on the supplied article only; do not invent facts, features, prices, or customer stories.', + 'Explain the practical implications and trade-offs rather than reading the article aloud.', + `End with a brief invitation to read the full article at ${post.sourceArticleUrl}.`, + ].join(' ') +} + +function buildPodcastSource(post: BlogPost): string { + return `Article title: ${post.title}\nArticle URL: ${post.sourceArticleUrl}\n\n${post.content}` +} + +export function readPodcastConfiguration(environment: Environment = process.env): PodcastConfiguration { + const required = (name: string) => { + const value = environment[name]?.trim() + if (!value) throw new Error(`${name} is required to generate a podcast.`) + return value + } + + return { + apiBaseUrl: (environment.AUTOCONTENT_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/$/, ''), + apiKey: required('AUTOCONTENT_API_KEY'), + podcastShowId: required('AUTOCONTENT_PODCAST_SHOW_ID'), + pollIntervalMs: parsePositiveInteger(environment.PODCAST_POLL_INTERVAL_MS, 'PODCAST_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS), + pollTimeoutMs: parsePositiveInteger(environment.PODCAST_POLL_TIMEOUT_MS, 'PODCAST_POLL_TIMEOUT_MS', DEFAULT_POLL_TIMEOUT_MS), + siteUrl: normalizeSiteUrl(environment.PODCAST_SITE_URL || DEFAULT_SITE_URL), + voice1: parsePositiveInteger(required('AUTOCONTENT_PODCAST_VOICE_1'), 'AUTOCONTENT_PODCAST_VOICE_1'), + voice2: parsePositiveInteger(required('AUTOCONTENT_PODCAST_VOICE_2'), 'AUTOCONTENT_PODCAST_VOICE_2'), + } +} + +export class HttpAutoContentClient implements AutoContentClient { + readonly #configuration: PodcastConfiguration + readonly #fetch: typeof fetch + + constructor(configuration: PodcastConfiguration, fetchImplementation: typeof fetch = fetch) { + this.#configuration = configuration + this.#fetch = fetchImplementation + } + + async getUsage(): Promise { + return this.request('GET', '/content/Usage') + } + + async listShowEpisodes(): Promise { + return this.request('GET', `/podcast/shows/${encodeURIComponent(this.#configuration.podcastShowId)}/episodes`) + } + + async createPodcast(input: CreatePodcastInput): Promise<{ requestId: string }> { + const response = await this.request>('POST', '/content/CreatePodcastCustomVoices', { + callbackData: input.callbackData, + duration: 'default', + language: 'English', + resources: [{ content: input.content, type: 'text' }], + style: 'deep dive', + text: input.instructions, + voice1: input.voice1, + voice2: input.voice2, + }) + const requestId = asString(response.request_id) || asString(response.requestId) + if (!requestId) throw new Error('AutoContent accepted the podcast request without returning request_id.') + return { requestId } + } + + async getStatus(requestId: string): Promise { + return this.request('GET', `/content/Status/${encodeURIComponent(requestId)}`) + } + + async attachEpisode(input: AttachEpisodeInput): Promise<{ episodeId: string | number }> { + const response = await this.request>('POST', `/podcast/shows/${encodeURIComponent(this.#configuration.podcastShowId)}/episodes`, input) + const episodeId = response.episodeId ?? response.episode_id + if ((typeof episodeId !== 'string' && typeof episodeId !== 'number') || !response.success) { + throw new Error('AutoContent did not confirm that the podcast episode was attached to the show.') + } + return { episodeId } + } + + async request(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const response = await this.#fetch(`${this.#configuration.apiBaseUrl}${path}`, { + body: body === undefined ? undefined : JSON.stringify(body), + headers: { + Accept: 'application/json', + Authorization: `Bearer ${this.#configuration.apiKey}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + method, + }) + const responseText = await response.text() + let payload: unknown = {} + + if (responseText) { + try { + payload = JSON.parse(responseText) + } catch { + payload = responseText + } + } + + if (!response.ok) { + const message = isRecord(payload) ? asString(payload.message) || asString(payload.error) : undefined + throw new Error(`AutoContent ${method} ${path} failed (${response.status})${message ? `: ${message}` : ''}`) + } + + return payload as T + } +} + +function episodeRecords(payload: unknown): Array> { + if (Array.isArray(payload)) return payload.filter(isRecord) + if (!isRecord(payload)) return [] + + for (const key of ['episodes', 'items', 'data']) { + if (Array.isArray(payload[key])) return payload[key].filter(isRecord) + } + + return [] +} + +export function findProviderEpisodeByArticle(payload: unknown, sourceArticleUrl: string): Record | undefined { + const alternateUrl = sourceArticleUrl.replace(/\/$/, '') + return episodeRecords(payload).find((episode) => + [episode.description, episode.sourceArticleUrl, episode.source_article_url].some( + (value) => typeof value === 'string' && (value.includes(sourceArticleUrl) || value.includes(alternateUrl)), + ), + ) +} + +function episodeFromProvider(post: BlogPost, providerEpisode: Record): PodcastEpisode | undefined { + const providerEpisodeId = providerEpisode.episodeId ?? providerEpisode.episode_id ?? providerEpisode.id + if (typeof providerEpisodeId !== 'string' && typeof providerEpisodeId !== 'number') return undefined + + const providerDate = + parseOptionalDate(providerEpisode.generatedAt) || + parseOptionalDate(providerEpisode.generated_at) || + parseOptionalDate(providerEpisode.publishedAt) || + parseOptionalDate(providerEpisode.published_at) || + parseOptionalDate(providerEpisode.createdAt) || + parseOptionalDate(providerEpisode.created_at) + + return { + audioMimeType: asString(providerEpisode.audioMimeType) || asString(providerEpisode.audio_mime_type) || 'audio/mpeg', + description: asString(providerEpisode.description) || buildEpisodeDescription(post), + durationSeconds: asNumber(providerEpisode.audioDuration ?? providerEpisode.audio_duration), + episodeGuid: buildPodcastEpisodeGuid(post.slug, providerEpisodeId), + fileSize: asNumber(providerEpisode.fileSize ?? providerEpisode.file_size), + generatedAt: providerDate || new Date().toISOString(), + providerAudioUrl: asString(providerEpisode.audioUrl) || asString(providerEpisode.audio_url), + providerEpisodeId, + requestId: asString(providerEpisode.requestId) || asString(providerEpisode.request_id), + slug: post.slug, + sourceArticleUrl: post.sourceArticleUrl, + title: asString(providerEpisode.title) || 'Capgo podcast: ' + post.title, + } +} + +function dailyCapacity(usage: DailyUsage, requestedLimit: number): number { + const allowed = asNumber(usage.allowedDailyPodcasts) + const used = asNumber(usage.usedDailyPodcasts) + if (allowed === undefined || used === undefined) return requestedLimit + return Math.max(0, Math.min(requestedLimit, allowed - used)) +} + +async function waitForPodcastCompletion( + client: Pick, + requestId: string, + pollIntervalMs: number, + pollTimeoutMs: number, + sleepImplementation: (milliseconds: number) => Promise, +): Promise { + const attempts = Math.ceil(pollTimeoutMs / pollIntervalMs) + 1 + + for (let attempt = 0; attempt < attempts; attempt += 1) { + const status = await client.getStatus(requestId) + const errorCode = asNumber(status.error_code) + const state = status.status + + if (errorCode && errorCode !== 0) throw new Error(status.error_message || `AutoContent failed request ${requestId} with error code ${errorCode}.`) + if (typeof state === 'string' && /failed|error/i.test(state)) throw new Error(status.error_message || `AutoContent failed request ${requestId}.`) + if (state === COMPLETED_STATUS) { + if (!status.audio_url) throw new Error(`AutoContent completed request ${requestId} without audio_url.`) + return status + } + + if (attempt < attempts - 1) await sleepImplementation(pollIntervalMs) + } + + throw new Error(`Timed out waiting for AutoContent request ${requestId}. It will be retried by the next scheduled run.`) +} + +export async function publishCandidates( + candidates: BlogPost[], + manifest: PodcastManifest, + configuration: PodcastConfiguration, + client: AutoContentClient, + sleepImplementation: (milliseconds: number) => Promise = sleep, +): Promise { + if (candidates.length === 0) { + return { deferredCount: 0, manifest, manifestChanged: false, publishedCount: 0, reconciledCount: 0 } + } + + const providerEpisodes = await client.listShowEpisodes() + const reconciledEpisodes: PodcastEpisode[] = [] + const missingCandidates: BlogPost[] = [] + + for (const candidate of candidates) { + const providerEpisode = findProviderEpisodeByArticle(providerEpisodes, candidate.sourceArticleUrl) + const recoveredEpisode = providerEpisode && episodeFromProvider(candidate, providerEpisode) + if (recoveredEpisode) reconciledEpisodes.push(recoveredEpisode) + else missingCandidates.push(candidate) + } + + const capacity = dailyCapacity(await client.getUsage(), missingCandidates.length) + const candidatesToPublish = missingCandidates.slice(0, capacity) + const publishedEpisodes: PodcastEpisode[] = [] + + for (const candidate of candidatesToPublish) { + const description = buildEpisodeDescription(candidate) + const { requestId } = await client.createPodcast({ + callbackData: `blog:${candidate.slug}`, + content: buildPodcastSource(candidate), + instructions: buildPodcastInstructions(candidate), + voice1: configuration.voice1, + voice2: configuration.voice2, + }) + const completed = await waitForPodcastCompletion(client, requestId, configuration.pollIntervalMs, configuration.pollTimeoutMs, sleepImplementation) + const { episodeId } = await client.attachEpisode({ + description, + requestId, + title: `Capgo podcast: ${candidate.title}`, + }) + + publishedEpisodes.push({ + audioMimeType: 'audio/mpeg', + description, + durationSeconds: asNumber(completed.audio_duration), + episodeGuid: buildPodcastEpisodeGuid(candidate.slug, episodeId), + fileSize: asNumber(completed.file_size), + generatedAt: new Date().toISOString(), + providerAudioUrl: completed.audio_url, + providerEpisodeId: episodeId, + requestId, + slug: candidate.slug, + sourceArticleUrl: candidate.sourceArticleUrl, + title: 'Capgo podcast: ' + candidate.title, + }) + } + + const episodes = [...manifest.episodes, ...reconciledEpisodes, ...publishedEpisodes] + return { + deferredCount: missingCandidates.length - candidatesToPublish.length, + manifest: { episodes, version: 1 }, + manifestChanged: reconciledEpisodes.length > 0 || publishedEpisodes.length > 0, + publishedCount: publishedEpisodes.length, + reconciledCount: reconciledEpisodes.length, + } +} + +function parseArguments(args: string[]): PodcastGenerationOptions { + const options: PodcastGenerationOptions = { backfill: false, dryRun: false, limit: DEFAULT_LIMIT } + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + const value = argument.includes('=') ? argument.slice(argument.indexOf('=') + 1) : args[index + 1] + + if (argument === '--backfill') options.backfill = true + else if (argument === '--dry-run') options.dryRun = true + else if (argument === '--slug' || argument.startsWith('--slug=')) { + if (!value) throw new Error('--slug requires a value.') + options.slug = value + if (argument === '--slug') index += 1 + } else if (argument === '--changed-files' || argument.startsWith('--changed-files=')) { + if (!value) throw new Error('--changed-files requires a value.') + options.changedFilesPath = resolve(PROJECT_ROOT, value) + if (argument === '--changed-files') index += 1 + } else if (argument === '--limit' || argument.startsWith('--limit=')) { + options.limit = parsePositiveInteger(value, '--limit') + if (argument === '--limit') index += 1 + } else if (argument === '--help' || argument === '-h') { + console.log('Usage: bun run blogs:generate_podcast -- --backfill|--changed-files |--slug [--limit ] [--dry-run]') + process.exit(0) + } else throw new Error(`Unknown argument: ${argument}`) + } + + return options +} + +function writeGithubOutputs(result: Pick): void { + if (!process.env.GITHUB_OUTPUT) return + writeFileSync(process.env.GITHUB_OUTPUT, `generated_count=${result.publishedCount}\nmanifest_changed=${result.manifestChanged}\n`, { encoding: 'utf8', flag: 'a' }) +} + +export async function runPodcastGeneration(options: PodcastGenerationOptions, environment: Environment = process.env): Promise { + const siteUrl = environment.PODCAST_SITE_URL || DEFAULT_SITE_URL + const manifest = readPodcastManifest() + const candidates = getEligibleBlogPosts(options, manifest, siteUrl) + + if (options.dryRun) { + console.log(candidates.map((candidate) => candidate.slug).join('\n')) + return { deferredCount: 0, manifest, manifestChanged: false, publishedCount: 0, reconciledCount: 0 } + } + + if (candidates.length === 0) { + const result = { deferredCount: 0, manifest, manifestChanged: false, publishedCount: 0, reconciledCount: 0 } + writeGithubOutputs(result) + console.log('No eligible blog posts need a podcast episode.') + return result + } + + const configuration = readPodcastConfiguration(environment) + const result = await publishCandidates(candidates, manifest, configuration, new HttpAutoContentClient(configuration)) + if (result.manifestChanged) writePodcastManifest(result.manifest) + writeGithubOutputs(result) + + console.log('Published ' + result.publishedCount + ' podcast episode(s); reconciled ' + result.reconciledCount + '; deferred ' + result.deferredCount + '.') + console.log('Website RSS feed: ' + new URL('podcast.xml', configuration.siteUrl).toString()) + return result +} + +if (import.meta.main) { + runPodcastGeneration(parseArguments(process.argv.slice(2))).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/blogs/podcast-discovery.test.ts b/scripts/blogs/podcast-discovery.test.ts new file mode 100644 index 000000000..f06b0f8e3 --- /dev/null +++ b/scripts/blogs/podcast-discovery.test.ts @@ -0,0 +1,73 @@ +import { getRssString } from '@astrojs/rss' +import { describe, expect, test } from 'bun:test' +import { + createPodcastRssOptions, + createPodcastStructuredData, + getPodcastEpisodeGuid, + getPodcastFeedEpisodes, + isPlayablePodcastEpisode, + type PodcastEpisode, +} from '../../apps/web/src/lib/podcast' + +const episode: PodcastEpisode = { + audioMimeType: 'audio/mpeg', + description: 'A practical episode about releases & rollbacks.\n\nRead the full article: https://capgo.app/blog/release-safety/', + durationSeconds: 185, + episodeGuid: 'capgo-blog-podcast:release-safety:42', + fileSize: 123456, + generatedAt: '2026-07-11T09:00:00.000Z', + providerAudioUrl: 'https://audio.example.com/release-safety.mp3', + providerEpisodeId: 42, + slug: 'release-safety', + sourceArticleUrl: 'https://capgo.app/blog/release-safety/', + title: 'Capgo podcast: Release safety', +} + +describe('podcast discovery', () => { + test('keeps playable episodes visible while only complete enclosures reach the feed', () => { + const audioOnly: PodcastEpisode = { + ...episode, + fileSize: undefined, + slug: 'audio-only', + } + + expect(isPlayablePodcastEpisode(audioOnly)).toBeTrue() + expect(getPodcastFeedEpisodes([audioOnly, episode]).map((item) => item.slug)).toEqual(['release-safety']) + }) + + test('creates a stable podcast graph linked to the blog article', () => { + const graph = createPodcastStructuredData({ + articleUrl: 'https://capgo.app/blog/release-safety/', + episode: episode as typeof episode & { providerAudioUrl: string }, + imageUrl: 'https://capgo.app/social/release-safety.png', + siteUrl: 'https://capgo.app/', + }) + + expect(graph.audio).toMatchObject({ + '@type': 'AudioObject', + contentUrl: episode.providerAudioUrl, + duration: 'PT3M5S', + encodingFormat: 'audio/mpeg', + }) + expect(graph.episode).toMatchObject({ + '@type': 'PodcastEpisode', + description: 'A practical episode about releases & rollbacks.', + isBasedOn: { '@id': 'https://capgo.app/blog/release-safety/#newsarticle' }, + partOfSeries: { + webFeed: 'https://capgo.app/podcast.xml', + }, + url: 'https://capgo.app/blog/release-safety/#podcast', + }) + }) + + test('generates an RSS 2.0 podcast enclosure, stable GUID, and canonical article link', async () => { + const xml = await getRssString(createPodcastRssOptions('https://capgo.app/', [episode])) + + expect(xml).toContain('xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"') + expect(xml).toContain('https://audio.example.com/release-safety.mp3') + expect(xml).toContain('length="123456"') + expect(xml).toContain('https://capgo.app/blog/release-safety/') + expect(xml).toContain(getPodcastEpisodeGuid(episode)) + expect(xml).toContain('00:03:05') + }) +})