HackerNews 播客生成器 - 核心技术实现
项目概述
这是一个基于 Next.js 15 的自动化播客生成系统,核心功能是:
- 抓取 HackerNews 热门资讯
- 使用 AI 将多条资讯整合成简短的双人播客对话(300字以内)
- 将对话文本转换为语音播客
技术栈:Next.js 15 (App Router) + TypeScript + TailwindCSS + 硅基流动 API
核心架构
项目采用三层架构:
1. API 层 (app/api/*) - 处理业务逻辑
2. 服务层 (lib/*) - 封装外部 API 调用
3. 展示层 (components/*) - React 组件
一、核心类型定义
文件:lib/types.ts
typescript
// HackerNews 故事类型
export interface HNStory {
id: number;
title: string;
url?: string;
text?: string;
by: string;
time: number;
score: number;
descendants?: number;
}
// 播客类型
export interface Podcast {
id: string;
title: string;
dialogue: string;
audioUrl?: string | null;
stories: HNStory[];
createdAt: string;
status: 'generating' | 'ready' | 'error' | 'dialogue_only';
}
二、HackerNews API 封装
文件:lib/hackernews.ts
typescript
import axios from 'axios';
import { HNStory } from './types';
const HN_API_BASE = 'https://hacker-news.firebaseio.com/v0';
/**
* 获取热门故事 ID 列表
*/
export async function getTopStoryIds(limit: number = 10): Promise<number[]> {
const response = await axios.get(`${HN_API_BASE}/topstories.json`);
return response.data.slice(0, limit);
}
/**
* 获取单个故事详情
*/
export async function getStory(id: number): Promise<HNStory | null> {
try {
const response = await axios.get(`${HN_API_BASE}/item/${id}.json`);
return response.data;
} catch (error) {
console.error(`Failed to fetch story ${id}:`, error);
return null;
}
}
/**
* 获取多个热门故事
*/
export async function getTopStories(limit: number = 5): Promise<HNStory[]> {
const ids = await getTopStoryIds(limit);
const stories = await Promise.all(ids.map(id => getStory(id)));
return stories.filter((story): story is HNStory => story !== null);
}
/**
* 格式化故事为文本摘要
*/
export function formatStoryForPrompt(story: HNStory): string {
return `标题: ${story.title}
作者: ${story.by}
评分: ${story.score}
${story.url ? `链接: ${story.url}` : ''}
${story.text ? `内容: ${story.text.substring(0, 200)}...` : ''}`;
}
核心要点:
- 使用 HackerNews 官方 API
- 并发获取多个故事详情(Promise.all)
- 格式化故事为 AI 可读的文本格式
三、硅基流动 API 封装
文件:lib/siliconflow.ts
3.1 生成播客对话(DeepSeek-V3)
typescript
import axios from 'axios';
import { HNStory } from './types';
import { formatStoryForPrompt } from './hackernews';
const SILICONFLOW_API_BASE = 'https://api.siliconflow.cn/v1';
/**
* 使用 DeepSeek-V3 生成播客对谈内容
*/
export async function generatePodcastDialogue(
stories: HNStory[],
apiKey: string
): Promise<string> {
const storiesText = stories.map((story, index) =>
`${index + 1}. ${formatStoryForPrompt(story)}`
).join('\n\n');
const prompt = `你是一个专业的播客制作人。请根据以下 HackerNews 热门资讯,创作一段简短精炼的双人播客对谈内容。
要求:
1. 使用 [S1] 和 [S2] 标记两位主持人的对话
2. S1 是一位技术专家,语气专业但不失幽默
3. S2 是一位好奇的提问者,善于提出有趣的问题
4. 将所有资讯整合成一个连贯的话题讨论,提炼核心趋势和亮点
5. 对话要自然流畅、通俗易懂,不要逐条罗列新闻
6. **总长度严格控制在 300 字以内**
HackerNews 资讯:
${storiesText}
请生成简短的播客对谈内容(300字以内):`;
try {
const response = await axios.post(
`${SILICONFLOW_API_BASE}/chat/completions`,
{
model: 'deepseek-ai/DeepSeek-V3',
messages: [
{
role: 'user',
content: prompt
}
],
max_tokens: 512,
temperature: 0.7,
top_p: 0.9
},
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
}
);
return response.data.choices[0].message.content;
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.error?.message
|| error.response?.statusText
|| error.message;
const statusCode = error.response?.status;
throw new Error(`AI 对话生成失败 (${statusCode || 'Network Error'}): ${errorMessage}`);
}
throw new Error('AI 对话生成失败: 未知错误');
}
}
核心要点:
- 使用 DeepSeek-V3 模型(推理速度快、质量高)
- Prompt 工程:明确角色定位、对话格式、长度限制
- 错误处理:提取详细的 API 错误信息
3.2 生成播客音频(MOSS-TTSD)
typescript
/**
* 使用 MOSS-TTSD 生成播客音频
* 返回 Base64 Data URL(适用于 Vercel 只读文件系统)
*/
export async function generatePodcastAudio(
dialogue: string,
apiKey: string
): Promise<string> {
try {
const requestData = {
model: 'fnlp/MOSS-TTSD-v0.5',
input: dialogue,
voice: 'fnlp/MOSS-TTSD-v0.5:alex',
response_format: 'mp3',
stream: false,
speed: 1,
gain: 0,
max_tokens: 4096,
};
const response = await fetch(`${SILICONFLOW_API_BASE}/audio/speech`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestData),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`TTS 音频合成失败 (${response.status}): ${errorText}`);
}
const audioBuffer = await response.arrayBuffer();
// 将音频转换为 Base64 Data URL
const base64Audio = Buffer.from(audioBuffer).toString('base64');
const audioDataUrl = `data:audio/mpeg;base64,${base64Audio}`;
return audioDataUrl;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('TTS 音频合成失败: 未知错误');
}
}
核心要点:
- 使用 MOSS-TTSD-v0.5 模型(支持中英文、语音克隆)
- 使用 fetch API 而非 axios(更好地处理二进制数据)
- 关键设计 :返回 Base64 Data URL 而非文件路径
- 原因:Vercel 等平台是只读文件系统,无法写入文件
- 优势:无需文件存储,直接在浏览器中播放和下载
四、API 路由实现
4.1 获取资讯 API
文件:app/api/stories/route.ts
typescript
import { NextResponse } from 'next/server';
import { getTopStories } from '@/lib/hackernews';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '5', 10);
const stories = await getTopStories(limit);
return NextResponse.json({
success: true,
stories
});
} catch (error) {
console.error('Error fetching stories:', error);
return NextResponse.json(
{ success: false, error: '获取资讯失败' },
{ status: 500 }
);
}
}
4.2 生成对话 API
文件:app/api/generate-dialogue/route.ts
typescript
import { NextResponse } from 'next/server';
import { generatePodcastDialogue } from '@/lib/siliconflow';
import { HNStory } from '@/lib/types';
export async function POST(request: Request) {
try {
const { stories, apiKey } = await request.json();
if (!apiKey) {
return NextResponse.json(
{ success: false, error: '请提供 API Key' },
{ status: 400 }
);
}
if (!stories || !Array.isArray(stories) || stories.length === 0) {
return NextResponse.json(
{ success: false, error: '请提供有效的资讯列表' },
{ status: 400 }
);
}
const dialogue = await generatePodcastDialogue(stories as HNStory[], apiKey);
return NextResponse.json({
success: true,
dialogue
});
} catch (error) {
console.error('Error generating dialogue:', error);
return NextResponse.json(
{ success: false, error: '生成对谈内容失败' },
{ status: 500 }
);
}
}
4.3 生成音频 API
文件:app/api/generate-audio/route.ts
typescript
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
try {
const { dialogue, apiKey } = await request.json();
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) {
return NextResponse.json(
{ success: false, error: '请提供有效的 API Key' },
{ status: 400 }
);
}
if (!dialogue || typeof dialogue !== 'string' || dialogue.trim().length === 0) {
return NextResponse.json(
{ success: false, error: '请提供对谈内容(对话文案不能为空)' },
{ status: 400 }
);
}
// 调用 SiliconFlow TTS API
const requestData = {
model: 'fnlp/MOSS-TTSD-v0.5',
input: dialogue,
voice: 'fnlp/MOSS-TTSD-v0.5:alex',
response_format: 'mp3',
stream: false,
speed: 1,
gain: 0,
max_tokens: 4096,
};
const response = await fetch('https://api.siliconflow.cn/v1/audio/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestData),
});
if (!response.ok) {
const errorText = await response.text();
return NextResponse.json(
{ success: false, error: `TTS API 调用失败 (${response.status})`, details: errorText },
{ status: response.status }
);
}
// 获取音频数据并转换为 Base64 Data URL
const audioBuffer = await response.arrayBuffer();
const base64Audio = Buffer.from(audioBuffer).toString('base64');
const audioDataUrl = `data:audio/mpeg;base64,${base64Audio}`;
return NextResponse.json({
success: true,
audioUrl: audioDataUrl
});
} catch (error) {
console.error('Error generating audio:', error);
return NextResponse.json(
{
success: false,
error: '生成音频失败',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
五、前端核心逻辑
文件:components/PodcastGenerator.tsx
5.1 状态管理
typescript
const [apiKey, setApiKey] = useState('');
const [storyLimit, setStoryLimit] = useState(5);
const [loading, setLoading] = useState(false);
const [currentStep, setCurrentStep] = useState('');
const [podcast, setPodcast] = useState<Podcast | null>(null);
const [stories, setStories] = useState<HNStory[]>([]);
const [dialogue, setDialogue] = useState('');
5.2 API Key 持久化
typescript
const API_KEY_STORAGE_KEY = 'hackernews_podcast_api_key';
// 从 localStorage 加载
useEffect(() => {
const savedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
if (savedApiKey) {
setApiKey(savedApiKey);
}
}, []);
// 保存到 localStorage
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (value.trim()) {
localStorage.setItem(API_KEY_STORAGE_KEY, value.trim());
} else {
localStorage.removeItem(API_KEY_STORAGE_KEY);
}
};
5.3 一键生成播客流程
typescript
const handleGenerateAll = async () => {
if (!apiKey.trim()) {
setError('请输入硅基流动 API Key');
return;
}
setLoading(true);
setError('');
try {
// 步骤 1: 获取资讯
setCurrentStep('📰 正在获取 HackerNews 资讯...');
const storiesResponse = await fetch(`/api/stories?limit=${storyLimit}`);
const storiesData = await storiesResponse.json();
if (!storiesData.success) {
throw new Error(storiesData.error || '获取资讯失败');
}
setStories(storiesData.stories);
// 步骤 2: 生成对话文案
setCurrentStep('💬 正在生成播客对话文案...');
const dialogueResponse = await fetch('/api/generate-dialogue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiKey: apiKey.trim(),
stories: storiesData.stories,
}),
});
const dialogueData = await dialogueResponse.json();
if (!dialogueData.success) {
throw new Error(dialogueData.error || '生成对话失败');
}
setDialogue(dialogueData.dialogue);
// 步骤 3: 生成音频
setCurrentStep('🎵 正在生成播客音频...');
const audioResponse = await fetch('/api/generate-audio', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiKey: apiKey.trim(),
dialogue: dialogueData.dialogue,
}),
});
const audioData = await audioResponse.json();
if (!audioData.success) {
throw new Error(audioData.error || '生成音频失败');
}
// 创建播客对象
const newPodcast: Podcast = {
id: Date.now().toString(),
title: `HackerNews 播客 - ${new Date().toLocaleDateString('zh-CN')}`,
dialogue: dialogueData.dialogue,
audioUrl: audioData.audioUrl,
stories: storiesData.stories,
createdAt: new Date().toISOString(),
status: 'ready'
};
setPodcast(newPodcast);
setCurrentStep('✅ 播客生成完成!');
} catch (err) {
setError('❌ ' + (err instanceof Error ? err.message : '未知错误'));
} finally {
setLoading(false);
}
};
5.4 音频播放与下载
typescript
{/* 音频播放器 */}
<audio controls className="w-full" src={podcast.audioUrl}>
您的浏览器不支持音频播放
</audio>
{/* 下载按钮 */}
<button onClick={() => {
const link = document.createElement('a');
link.href = podcast.audioUrl;
link.download = `hackernews-podcast-${Date.now()}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}>
下载音频
</button>
六、项目配置
6.1 依赖包
文件:package.json
json
{
"dependencies": {
"next": "15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"axios": "^1.7.9",
"lucide-react": "^0.469.0"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5",
"tailwindcss": "^3.4.1",
"postcss": "^8.4.35",
"autoprefixer": "^10.4.17"
}
}
6.2 环境变量
文件:.env.local.example
env
# 硅基流动 API Key
# 请在 https://cloud.siliconflow.cn/account/ak 获取
SILICONFLOW_API_KEY=your_api_key_here
七、关键技术要点总结
1. API 设计模式
- 三层架构:API 路由 → 服务层 → 外部 API
- 统一的错误处理和响应格式
- 参数验证和类型安全
2. Base64 Data URL 方案
- 问题:Vercel 等平台是只读文件系统
- 解决:将音频转换为 Base64 编码的 Data URL
- 优势:无需文件存储,直接在浏览器播放和下载
- 代价:文件大小增加约 33%
3. AI Prompt 工程
- 明确角色定位(S1 技术专家、S2 提问者)
- 严格的长度控制(300字以内)
- 整合式对话而非逐条罗列
4. 用户体验优化
- API Key 本地持久化(localStorage)
- 实时步骤提示(获取资讯 → 生成文案 → 合成音频)
- 错误信息详细展示
- 支持单独生成文案或完整播客
5. 性能优化
- 并发获取多个 HackerNews 故事(Promise.all)
- 使用 DeepSeek-V3(推理速度快)
- 控制对话长度减少音频生成时间
八、快速复现步骤
1. 创建 Next.js 项目
bash
npx create-next-app@latest hackernews-podcast --typescript --tailwind --app
cd hackernews-podcast
2. 安装依赖
bash
npm install axios lucide-react
3. 创建目录结构
bash
mkdir -p lib components app/api/stories app/api/generate-dialogue app/api/generate-audio
4. 复制核心代码
按照本文档的代码片段,依次创建:
lib/types.ts
- 类型定义lib/hackernews.ts
- HackerNews API 封装lib/siliconflow.ts
- 硅基流动 API 封装app/api/stories/route.ts
- 获取资讯 APIapp/api/generate-dialogue/route.ts
- 生成对话 APIapp/api/generate-audio/route.ts
- 生成音频 APIcomponents/PodcastGenerator.tsx
- 主组件app/page.tsx
- 首页
5. 配置环境变量
bash
cp .env.local.example .env.local
# 编辑 .env.local,填入硅基流动 API Key
6. 启动开发服务器
bash
npm run dev
访问 http://localhost:3000 即可使用!
九、API 接口说明
硅基流动 API
Chat Completions(对话生成)
POST https://api.siliconflow.cn/v1/chat/completions
Authorization: Bearer {API_KEY}
{
"model": "deepseek-ai/DeepSeek-V3",
"messages": [{"role": "user", "content": "..."}],
"max_tokens": 512,
"temperature": 0.7
}
Audio Speech(语音合成)
POST https://api.siliconflow.cn/v1/audio/speech
Authorization: Bearer {API_KEY}
{
"model": "fnlp/MOSS-TTSD-v0.5",
"input": "对话文本",
"voice": "fnlp/MOSS-TTSD-v0.5:alex",
"response_format": "mp3"
}
HackerNews API
GET https://hacker-news.firebaseio.com/v0/topstories.json
GET https://hacker-news.firebaseio.com/v0/item/{id}.json
十、总结
这个项目展示了如何将多个 API 整合成一个完整的应用:
- 数据获取:HackerNews API
- 内容生成:DeepSeek-V3 AI 模型
- 语音合成:MOSS-TTSD TTS 模型
核心创新点:
- Base64 Data URL 方案解决无服务器环境的文件存储问题
- Prompt 工程实现多条资讯的智能整合
- 流式用户体验设计(步骤提示、错误处理)
通过本文档,您可以完整复现整个项目!🎉