feat: implement feed classification and podcast content generation

This commit is contained in:
2025-06-04 12:39:47 +09:00
parent 08b11ae549
commit 05105071f3
2 changed files with 119 additions and 66 deletions

View File

@ -1,5 +1,8 @@
import Parser from "rss-parser"; import Parser from "rss-parser";
import { openAI_GenerateScript } from "../services/llm"; import {
openAI_ClassifyFeed,
openAI_GeneratePodcastContent
} from "../services/llm";
import { generateTTS } from "../services/tts"; import { generateTTS } from "../services/tts";
import { saveEpisode, markAsProcessed } from "../services/database"; import { saveEpisode, markAsProcessed } from "../services/database";
import { updatePodcastRSS } from "../services/podcast"; import { updatePodcastRSS } from "../services/podcast";
@ -36,68 +39,81 @@ async function main() {
feedUrls = []; feedUrls = [];
} }
// フィードごとに処理
for (const url of feedUrls) { for (const url of feedUrls) {
const feed = await parser.parseURL(url); const feed = await parser.parseURL(url);
for (const item of feed.items) {
// フィードのカテゴリ分類
const feedTitle = feed.title || url;
const category = await openAI_ClassifyFeed(feedTitle);
console.log(`フィード分類完了: ${feedTitle} - ${category}`);
// 昨日の記事のみフィルタリング
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayItems = feed.items.filter(item => {
const pub = new Date(item.pubDate || ""); const pub = new Date(item.pubDate || "");
const today = new Date(); return (
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
if (
pub.getFullYear() === yesterday.getFullYear() && pub.getFullYear() === yesterday.getFullYear() &&
pub.getMonth() === yesterday.getMonth() && pub.getMonth() === yesterday.getMonth() &&
pub.getDate() === yesterday.getDate() pub.getDate() === yesterday.getDate()
) { );
// Use item.id if available, otherwise generate fallback ID from title or link });
const itemId = item["id"] as string | undefined;
const fallbackId = item.link || item.title || JSON.stringify(item); if (yesterdayItems.length === 0) {
const finalItemId = console.log(`昨日の記事が見つかりません: ${feedTitle}`);
itemId && typeof itemId === "string" && itemId.trim() !== "" continue;
? itemId }
: `fallback-${Buffer.from(fallbackId).toString("base64")}`;
// ポッドキャスト原稿生成
// Skip if even the fallback ID is missing (should be rare) console.log(`ポッドキャスト原稿生成開始: ${feedTitle}`);
if (!finalItemId || finalItemId.trim() === "") { const podcastContent = await openAI_GeneratePodcastContent(feedTitle, yesterdayItems);
console.warn(`フィードアイテムのIDを生成できませんでした`, {
feedUrl: url, // トピックごとの統合音声生成
itemTitle: item.title, const feedUrlHash = crypto.createHash("md5").update(url).digest("hex");
itemLink: item.link, const categoryHash = crypto.createHash("md5").update(category).digest("hex");
}); const uniqueFilename = `${feedUrlHash}-${categoryHash}.mp3`;
continue;
} const audioFilePath = await generateTTS(uniqueFilename, podcastContent);
console.log(`音声ファイル生成完了: ${audioFilePath}`);
const already = await markAsProcessed(url, finalItemId);
if (already) { // エピソードとして保存各フィードにつき1つの統合エピソード
console.log(`既に処理済み: ${finalItemId}`); const firstItem = yesterdayItems[0];
continue; const pub = new Date(firstItem.pubDate || "");
}
await saveEpisode({
console.log(`スクリプト生成開始: ${finalItemId}`); id: `topic-${categoryHash}`,
const scriptText = await openAI_GenerateScript({ title: `${category}: ${feedTitle}`,
title: item.title ?? "", pubDate: pub.toISOString(),
link: item.link ?? "", audioPath: audioFilePath,
contentSnippet: item.contentSnippet ?? "", sourceLink: url,
});
console.log(`エピソード保存完了: ${category} - ${feedTitle}`);
// 個別記事の処理記録
for (const item of yesterdayItems) {
const itemId = item["id"] as string | undefined;
const fallbackId = item.link || item.title || JSON.stringify(item);
const finalItemId =
itemId && typeof itemId === "string" && itemId.trim() !== ""
? itemId
: `fallback-${Buffer.from(fallbackId).toString("base64")}`;
if (!finalItemId || finalItemId.trim() === "") {
console.warn(`フィードアイテムのIDを生成できませんでした`, {
feedUrl: url,
itemTitle: item.title,
itemLink: item.link,
}); });
continue;
// Generate a unique filename using the feed URL hash and item ID }
const feedUrlHash = crypto.createHash("md5").update(url).digest("hex");
const itemIdHash = crypto.createHash("md5").update(finalItemId).digest("hex"); const already = await markAsProcessed(url, finalItemId);
const uniqueFilename = `${feedUrlHash}-${itemIdHash}.mp3`; if (already) {
console.log(`既に処理済み: ${finalItemId}`);
const audioFilePath = await generateTTS(uniqueFilename, scriptText); continue;
console.log(`音声ファイル生成完了: ${audioFilePath}`);
await saveEpisode({
id: finalItemId,
title: item.title ?? "",
pubDate: pub.toISOString(),
audioPath: audioFilePath,
sourceLink: item.link ?? "",
});
console.log(`エピソード保存完了: ${finalItemId}`);
} }
} }
} }

View File

@ -6,19 +6,56 @@ const clientOptions: ClientOptions = {
}; };
const openai = new OpenAI(clientOptions); const openai = new OpenAI(clientOptions);
export async function openAI_GenerateScript(item: { export async function openAI_ClassifyFeed(title: string): Promise<string> {
title: string;
link: string;
contentSnippet?: string;
}): Promise<string> {
const prompt = ` const prompt = `
あなたはポッドキャスターです。以下の情報をもとに、リスナー向けにわかりやすい日本語のポッドキャスト原稿を書いてください。 以下のRSSフィードのタイトルを見て、適切なトピックカテゴリに分類してください。
- 記事タイトル: ${item.title} フィードタイトル: ${title}
- 記事リンク: ${item.link}
- 記事概要: ${item.contentSnippet || "なし"}
「今日のニュース記事をご紹介します…」といった導入も含め、約300文字程度でまとめてください 以下のカテゴリから1つを選択してください:
- テクノロジー
- ビジネス
- エンターテインメント
- スポーツ
- 科学
- 健康
- 政治
- 環境
- 教育
- その他
分類結果を上記カテゴリのいずれか1つだけ返してください。
`;
const response = await openai.chat.completions.create({
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini",
messages: [{ role: "user", content: prompt.trim() }],
temperature: 0.3,
});
const category = response.choices[0]!.message?.content?.trim() || "その他";
return category;
}
export async function openAI_GeneratePodcastContent(
title: string,
items: Array<{ title: string; link: string }>
): Promise<string> {
const prompt = `
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。
フィードタイトル: ${title}
関連するニュース記事:
${items.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")}
以下の要件を満たしてください:
1. トピックの簡単なイントロダクションから始めてください
2. 各ニュース記事の内容を要約し、関連性を説明してください
3. 視聴者にとっての価値や興味ポイントを解説してください
4. 約1000文字〜1500文字程度の長さにしてください
5. 自然な日本語の口語表現を使ってください
6. トピック全体のまとめで締めくくってください
この構成でポッドキャスト原稿を書いてください。
`; `;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini", model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini",