HackerNews 播客生成器

HackerNews 播客生成器 - 核心技术实现

项目概述

这是一个基于 Next.js 15 的自动化播客生成系统,核心功能是:

  1. 抓取 HackerNews 热门资讯
  2. 使用 AI 将多条资讯整合成简短的双人播客对话(300字以内)
  3. 将对话文本转换为语音播客

技术栈: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 - 获取资讯 API
  • app/api/generate-dialogue/route.ts - 生成对话 API
  • app/api/generate-audio/route.ts - 生成音频 API
  • components/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 整合成一个完整的应用:

  1. 数据获取:HackerNews API
  2. 内容生成:DeepSeek-V3 AI 模型
  3. 语音合成:MOSS-TTSD TTS 模型

核心创新点:

  • Base64 Data URL 方案解决无服务器环境的文件存储问题
  • Prompt 工程实现多条资讯的智能整合
  • 流式用户体验设计(步骤提示、错误处理)

通过本文档,您可以完整复现整个项目!🎉

相关推荐
岁月宁静5 小时前
Node.js 核心模块详解:fs 模块原理与应用
前端·人工智能·node.js
San305 小时前
JavaScript 标准库完全指南:从基础到实战
前端·javascript·node.js
tryCbest5 小时前
Node.js使用Express+SQLite实现登录认证
sqlite·node.js·express
Never_Satisfied5 小时前
在JavaScript / Node.js中,Web服务器参数处理与编码指南
前端·javascript·node.js
努力搬砖的咸鱼5 小时前
Node.js 和 Java 项目怎么写 Dockerfile
java·开发语言·docker·云原生·容器·node.js
百味瓶6 小时前
nodejs调用C++动态库
c++·node.js
jiangzhihao051514 小时前
前端自动翻译插件webpack-auto-i18n-plugin的使用
前端·webpack·node.js
一碗饭特稀1 天前
NestJS入门(2)——数据库、用户、备忘录模块初始化
node.js·nestjs
你的电影很有趣1 天前
lesson72:Node.js 安全实战:Crypto-Js 4.2.0 与 Express 加密体系构建指南
javascript·安全·node.js