JSON Feed 与 Podcast Feed 构建实践
JSON Feed 让订阅变得现代化,Podcast Feed 连接音频与听众,Hugo 通过统一模板让多格式订阅变得简单而强大。
多格式订阅概述
在内容订阅领域,传统的 RSS/Atom 格式虽然成熟稳定,但 JSON Feed 以其现代化的数据结构和更好的解析性能,正在成为越来越多应用的选择。对于播客内容,Podcast Feed(基于 RSS 的扩展)则是行业标准。本站通过 Hugo 的灵活模板系统,同时提供这两种订阅格式,确保内容在各种平台和应用上都能被正确消费。
订阅格式演进
内容订阅格式经历了从 RSS 到 Atom,再到现代 JSON Feed 的演进:
| 格式 | 发布时间 | 主要特点 | 适用场景 | 优势 | 局限性 |
|---|---|---|---|---|---|
| RSS 2.0 | 2002 | XML 结构化,扩展性强 | 通用内容订阅 | 标准化程度高,支持丰富 | 解析复杂度较高 |
| Atom | 2005 | XML 标准化,语义明确 | 专业内容发布 | 规范性强,语义清晰 | 学习成本较高 |
| JSON Feed 1.1 | 2017 | JSON 现代化,解析简单 | 现代 Web 应用 | 性能优异,易于处理 | 标准化程度较低 |
多格式同步策略
Hugo 通过统一的 Front Matter 数据源和模板系统,实现多格式订阅的同步生成:
JSON Feed 构建实践
JSON Feed 1.1 规范提供了一种现代化的订阅格式,特别适合现代 Web 应用和移动端消费。
JSON Feed 规范结构
JSON Feed 的核心结构包含 feed 信息和 items 列表:
{
"version": "https://jsonfeed.org/version/1.1",
"title": "Feed 标题",
"home_page_url": "https://example.com/",
"feed_url": "https://example.com/feed.json",
"description": "Feed 描述",
"author": {
"name": "作者姓名",
"url": "https://example.com/author",
"avatar": "https://example.com/avatar.jpg"
},
"items": [
{
"id": "唯一标识符",
"url": "内容页面 URL",
"title": "内容标题",
"content_text": "纯文本内容",
"content_html": "HTML 内容",
"summary": "内容摘要",
"date_published": "2025-01-01T00:00:00Z",
"tags": ["标签 1", "标签 2"]
}
]
}
Hugo JSON Feed 模板实现
本站的 layouts/index.json 模板实现了完整的 JSON Feed 1.1 支持:
{{- $.Scratch.Add "pagesIndex" slice -}}
{{- range $index, $page := .Site.Pages -}}
{{- if in (slice "blog" "notice" "book" "trans" "podcast" "ai") $page.Section -}}
{{- if and (not .Draft) (not .Params.private) | and (ne .Params.searchable false) -}}
{{- if gt (len $page.Content) 0 -}}
{{- /* Generate page description. */ -}}
{{- $desc := "" -}}
{{- if .Params.description -}}
{{- $desc = .Params.description -}}
{{- else -}}
{{- $desc = .Summary -}}
{{- end -}}
{{- /* Add page data including the date, type, tags and categories fields */ -}}
{{- $pageData := (dict
"title" $page.Title
"relpermalink" $page.RelPermalink
"summary" (
(
$desc
| plainify
)
)
"content" (
(
$page.Content
| replaceRE "(?is)<!--.*?-->" ""
| replaceRE "(?is)<script[^>]*>.*?</script>" ""
| replaceRE "(?is)<details[^>]*>.*?</details>" ""
| replaceRE "(?is)<div[^>]*class=\"highlight\"[^>]*>.*?</div>" ""
| replaceRE "(?is)<style[^>]*>.*?</style>" ""
| replaceRE "(?is)<pre[^>]*>.*?</pre>" ""
| replaceRE "(?is)<figure[^>]*>.*?</figure>" ""
| replaceRE "(?is)<nav[^>]*>.*?</nav>" ""
| replaceRE "(?is)<aside[^>]*>.*?</aside>" ""
| replaceRE "(?is)<footer[^>]*>.*?</footer>" ""
| plainify
| htmlEscape
| truncate 200
)
)
"date" $page.Date
"section" $page.Section
"tags" .Params.tags
"categories" .Params.categories
) -}}
{{- $.Scratch.Add "pagesIndex" $pageData -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- $.Scratch.Get "pagesIndex" | jsonify -}}
关键实现特性:
- 内容过滤:移除 HTML 标签、脚本、样式,保留纯文本
- 长度控制:内容截断为 200 字符,优化加载性能
- 类型筛选:只索引指定章节的内容
- 隐私保护:跳过草稿和私有内容
JSON Feed 增强功能
基于 JSON Feed 规范的扩展支持:
| 字段名称 | 描述 | Hugo 实现 | 应用场景 |
|---|---|---|---|
| attachments | 附件信息 | 播客音频文件 | Podcast Feed |
| external_url | 外部链接 | 文章引用链接 | 内容扩展 |
| cover | 横幅图片 | 特色图片 | 视觉增强 |
| authors | 多作者信息 | 作者数组 | 协作内容 |
Podcast Feed 构建实践
播客内容需要特殊的 RSS 扩展来支持音频文件和播客特有的元数据。
Podcast RSS 规范要求
Apple Podcasts 和其他播客平台对 RSS Feed 有特定要求:
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
<channel>
<!-- 频道级别元数据 -->
<title>播客标题</title>
<description>播客描述</description>
<link>https://example.com/podcast</link>
<language>zh-cn</language>
<copyright>版权信息</copyright>
<!-- iTunes 特定标签 -->
<itunes:author>主持人姓名</itunes:author>
<itunes:image href="封面图片URL"/>
<itunes:category text="分类名称"/>
<itunes:explicit>no</itunes:explicit>
<itunes:owner>
<itunes:name>所有者姓名</itunes:name>
<itunes:email>联系邮箱</itunes:email>
</itunes:owner>
</channel>
</rss>
Hugo Podcast RSS 模板实现
本站的 layouts/podcast/rss.xml 模板实现了完整的播客 RSS 规范:
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
{{/* Get podcast params from site config, section front matter, or defaults */}}
{{ $podcastParams := .Site.Params.podcast | default dict }}
{{ $sectionParams := .Params | default dict }}
{{ $podcastTitle := $sectionParams.podcast_title | default ($podcastParams.title | default .Site.Title) }}
{{ $podcastLink := $sectionParams.podcast_website_url | default ($podcastParams.website_url | default .Permalink) }}
{{ $podcastDescription := $sectionParams.podcast_description | default ($podcastParams.description | default .Site.Params.description) }}
{{ $languageCode := $sectionParams.podcast_language_code | default ($podcastParams.language_code | default .Site.Language.Lang | default .Site.LanguageCode) }}
{{ $copyrightText := $sectionParams.podcast_copyright_text | default ($podcastParams.copyright_text | default .Site.Copyright) }}
{{ $podcastAuthor := $sectionParams.podcast_author | default ($podcastParams.author | default .Site.Params.author) }}
{{ $podcastCoverImage := $sectionParams.podcast_cover_image_url | default $podcastParams.cover_image_url }}
{{ $podcastExplicit := $sectionParams.podcast_explicit | default ($podcastParams.explicit | default "no") }}
{{ $podcastType := $sectionParams.podcast_type | default ($podcastParams.type | default "episodic") }}
{{ $ownerName := $sectionParams.podcast_owner_name | default ($podcastParams.owner_name | default $podcastAuthor) }}
{{ $ownerEmail := $sectionParams.podcast_owner_email | default ($podcastParams.owner_email | default .Site.Params.email) }}
{{ $categories := $sectionParams.podcast_categories | default $podcastParams.categories }}
<title>{{ $podcastTitle }}</title>
<link>{{ $podcastLink }}</link>
<description>{{ $podcastDescription }}</description>
<generator>Hugo -- gohugo.io</generator>
{{ with $languageCode }}<language>{{ . }}</language>{{ end }}
{{ with $copyrightText }}<copyright>{{ . }}</copyright>{{ end }}
{{ if not .Date.IsZero }}<lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 GMT" | safeHTML }}</lastBuildDate>{{ else }}<lastBuildDate>{{ now.Format "Mon, 02 Jan 2006 15:04:05 GMT" | safeHTML }}</lastBuildDate>{{ end }}
<atom:link href="{{ .Site.BaseURL | relURL }}/podcast/index.xml" rel="self" type="application/rss+xml"/>
{{ with $podcastCoverImage }}<itunes:image href="{{ . | absURL }}"/>{{ end }}
{{ if $categories }}
{{ range $categories }}
<itunes:category text="{{ . }}"/>
{{ end }}
{{ else }}
{{/* Default category if none provided */}}
<itunes:category text="Society & Culture"/>
{{ end }}
<itunes:explicit>{{ $podcastExplicit }}</itunes:explicit>
{{ with $podcastAuthor }}<itunes:author>{{ . }}</itunes:author>{{ end }}
<itunes:owner>
{{ with $ownerName }}<itunes:name>{{ . }}</itunes:name>{{ end }}
{{ with $ownerEmail }}<itunes:email>{{ . }}</itunes:email>{{ end }}
</itunes:owner>
<itunes:type>{{ $podcastType }}</itunes:type>
</channel>
</rss>
单集内容处理
每集播客的详细元数据处理:
{{ range where $pages "Params.audio_url" "!=" nil }}
<item>
<title>{{ .Title | plainify }}</title>
{{ $episodeLink := .Params.episode_link | default .Permalink }}
<link>{{ $episodeLink }}</link>
<pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 GMT" | safeHTML }}</pubDate>
{{ $itemGuid := .Params.guid | default .Permalink }}
{{ $isPermaLink := eq (.Params.guid | default "") "" }}
<guid isPermaLink="{{ $isPermaLink }}">{{ $itemGuid }}</guid>
{{ if and .Params.audio_url .Params.audio_length .Params.audio_type }}
<enclosure url="{{ .Params.audio_url | absURL }}" length="{{ .Params.audio_length }}" type="{{ .Params.audio_type }}"/>
{{ end }}
{{ $descriptionContent := .Summary }}
{{ if .Params.description }}
{{ $descriptionContent = .Params.description }}
{{ end }}
{{ $description := $descriptionContent | default .Summary | default .Content }}
{{ $description = replaceRE "(?is)<!--.*?-->" "" $description }}
{{ $description = replaceRE "(?is)<script[^>]*>.*?</script>" "" $description }}
{{ $description = replaceRE "(?is)<details[^>]*>.*?</details>" "" $description }}
{{ $description = $description | plainify | truncate 500 | htmlEscape }}
{{ $description = replaceRE `]]>` "]]>" $description }}
<description>{{ $description }}</description>
{{ $encodedContent := .Content }}
{{ if .Params.content_encoded }}
{{ $encodedContent = .Params.content_encoded }}
{{ end }}
{{ $encodedContent = replaceRE "(?is)<!--.*?-->" "" $encodedContent }}
{{ $encodedContent = replaceRE "(?is)<script[^>]*>.*?</script>" "" $encodedContent }}
{{ $encodedContent = replaceRE "(?is)<details[^>]*>.*?</details>" "" $encodedContent }}
<content:encoded>{{ $encodedContent }}</content:encoded>
{{ with .Params.itunes_episode_type }}<itunes:episodeType>{{ . }}</itunes:episodeType>{{ end }}
{{ with .Params.itunes_episode_number }}<itunes:episode>{{ . }}</itunes:episode>{{ end }}
{{ with .Params.itunes_season }}<itunes:season>{{ . }}</itunes:season>{{ end }}
{{ with .Params.itunes_keywords }}
<itunes:keywords>
{{ range $index, $keyword := split . "," }}
{{ if $index }},{{ end }}{{ $keyword | trim " \t\n\r" | safeHTML }}
{{ end }}
</itunes:keywords>
{{ end }}
{{ with .Params.duration }}<itunes:duration>{{ . }}</itunes:duration>{{ end }}
{{ $episodeImage := .Params.episode_image_url | default $podcastCoverImage }}
{{ with $episodeImage }}<itunes:image href="{{ . | absURL }}"/>{{ end }}
{{ $itemAuthor := .Params.itunes_author_item | default $podcastAuthor }}
{{ with $itemAuthor }}<itunes:author>{{ . }}</itunes:author>{{ end }}
{{ $itemExplicit := .Params.itunes_explicit_item | default $podcastExplicit }}
<itunes:explicit>{{ $itemExplicit }}</itunes:explicit>
</item>
{{ end }}
Front Matter 数据结构
播客内容的 Front Matter 结构:
---
title: "北京小客车摇号深度探讨"
date: 2025-05-18 00:12:03+00:00
draft: false
description: "关于摇号政策的深度分析..."
categories:
- Jimmy 的播客
audio_url: https://assets.jimmysong.io/podcasts/audios/ep-001.mp3
audio_type: audio/mpeg
duration: 486
audio_length: '4533959'
episode_image_url: https://assets.jimmysong.io/podcasts/images/ep-001.jpg
---
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
| audio_url | string | 是 | 音频文件完整 URL |
| audio_type | string | 是 | MIME 类型(如 audio/mpeg) |
| audio_length | string | 是 | 文件字节数 |
| duration | number | 是 | 时长(秒) |
| episode_image_url | string | 否 | 集封面图片 |
| itunes_episode | number | 否 | 集号 |
| itunes_season | number | 否 | 季号 |
| itunes_keywords | string | 否 | 关键词(逗号分隔) |
多格式同步策略
统一元数据管理
通过 Hugo 的 Front Matter 系统实现多格式同步:
内容处理策略
不同格式对内容的处理要求:
| 格式 | 内容处理 | 长度限制 | HTML 支持 | 特殊字符处理 |
|---|---|---|---|---|
| JSON Feed | 保留纯文本,移除 HTML 标签 | 200 字符摘要 | content_html 字段 | JSON 转义 |
| Podcast RSS | 支持 HTML,content:encoded | 500 字符摘要 | 完整 HTML | CDATA 封装 |
| Atom Feed | XML 结构化内容 | 无限制 | 标准 XML | XML 实体编码 |
构建流程集成
多格式输出的构建配置:
[outputs]
# 首页输出多种格式
home = ["HTML", "RSS", "JSON"]
# 播客章节专门输出
podcast = ["HTML", "RSS"]
# 博客章节输出标准格式
blog = ["HTML", "RSS", "JSON"]
验证与调试
Feed 验证工具
| 工具名称 | 适用格式 | 验证类型 | 网址 |
|---|---|---|---|
| W3C Feed Validator | RSS/Atom | 语法结构 | https://validator.w3.org/feed/ |
| JSON Feed Validator | JSON Feed | 格式规范 | https://validator.jsonfeed.org/ |
| Cast Feed Validator | Podcast RSS | 播客标准 | https://castfeedvalidator.com/ |
| Feed Validator | 多格式 | 通用验证 | https://www.feedvalidator.org/ |
常见问题诊断
# 检查 JSON Feed 语法
curl -s "https://example.com/index.json" | jq .
# 验证 RSS 结构
curl -s "https://example.com/podcast/index.xml" | xmllint --format -
# 检查音频文件可访问性
curl -I "https://assets.example.com/audio/ep001.mp3"
性能优化建议
- 内容缓存:对大型 Feed 实施适当的缓存策略
- 渐进式加载:考虑分页或分批加载大量内容
- 压缩传输:启用 gzip 压缩减少传输大小
- CDN 分发:使用 CDN 加速 Feed 文件访问
平台分发策略
Apple Podcasts 提交
<!-- Apple Podcasts 要求的核心标签 -->
<itunes:category text="Technology"/>
<itunes:image href="https://example.com/podcast-cover.jpg"/>
<itunes:explicit>no</itunes:explicit>
<enclosure url="https://example.com/ep001.mp3"
length="4533959"
type="audio/mpeg"/>
其他平台适配
| 平台 | RSS 要求 | 特殊配置 | 审核周期 |
|---|---|---|---|
| Apple Podcasts | 完整 iTunes 标签 | 分类、封面、时长 | 24-48 小时 |
| Spotify | 标准 RSS + iTunes | 字幕文件支持 | 即时 |
| Pocket Casts | 基本 RSS 字段 | 无特殊要求 | 即时 |
| 小宇宙 | 标准 RSS | 中文优化 | 即时 |
最佳实践
内容质量保证
- 元数据完整性:确保所有必需字段都已填写
- 音频文件优化:使用合适的比特率和格式
- 描述准确性:提供吸引人的节目描述
- 时长标记:准确记录每集时长
技术维护
- 定期验证:使用验证工具检查 Feed 格式
- 链接监控:确保所有音频文件链接有效
- 更新同步:及时更新各平台的 Feed URL
- 备份策略:保存重要的元数据和音频文件
用户体验优化
- 渐进式增强:提供基本的 RSS,同时支持现代格式
- 跨平台兼容:确保在各种播客客户端正常工作
- 离线可用性:音频文件支持离线下载
- 搜索友好:提供丰富的元数据便于搜索
通过精心设计的多格式订阅系统,本站不仅满足了传统 RSS 用户的需求,还为现代 Web 应用和播客平台提供了优质的内容分发体验。Hugo 的灵活模板系统让同时维护多种订阅格式变得简单而高效。
总结
JSON Feed 和 Podcast Feed 是现代内容订阅生态的重要组成部分。JSON Feed 以其简洁高效的 JSON 结构成为现代 Web 应用的理想选择,而 Podcast Feed 则通过 RSS 的扩展满足了播客内容复杂的元数据需求。
Hugo 通过统一的 Front Matter 系统和灵活的模板机制,让同时维护这两种订阅格式变得简单而高效。本站的实现展示了如何通过精心设计的模板系统,在保证内容质量的同时,为用户提供多样化的订阅体验。
关键在于理解不同格式的规范要求,合理组织元数据,并在性能和功能之间取得最佳平衡。随着内容订阅生态的不断演进,这些多格式支持的能力将成为现代静态网站的重要竞争力。