feat: implement feed classification and podcast content generation
This commit is contained in:
		@@ -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 pub = new Date(item.pubDate || "");
 | 
					 | 
				
			||||||
      const today = new Date();
 | 
					 | 
				
			||||||
      const yesterday = new Date(today);
 | 
					 | 
				
			||||||
      yesterday.setDate(today.getDate() - 1);
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
      if (
 | 
					    // フィードのカテゴリ分類
 | 
				
			||||||
 | 
					    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 || "");
 | 
				
			||||||
 | 
					      return (
 | 
				
			||||||
        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);
 | 
					 | 
				
			||||||
        const finalItemId =
 | 
					 | 
				
			||||||
          itemId && typeof itemId === "string" && itemId.trim() !== ""
 | 
					 | 
				
			||||||
            ? itemId
 | 
					 | 
				
			||||||
            : `fallback-${Buffer.from(fallbackId).toString("base64")}`;
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
        // Skip if even the fallback ID is missing (should be rare)
 | 
					    if (yesterdayItems.length === 0) {
 | 
				
			||||||
        if (!finalItemId || finalItemId.trim() === "") {
 | 
					      console.log(`昨日の記事が見つかりません: ${feedTitle}`);
 | 
				
			||||||
          console.warn(`フィードアイテムのIDを生成できませんでした`, {
 | 
					      continue;
 | 
				
			||||||
            feedUrl: url,
 | 
					    }
 | 
				
			||||||
            itemTitle: item.title,
 | 
					 | 
				
			||||||
            itemLink: item.link,
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
          continue;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
        const already = await markAsProcessed(url, finalItemId);
 | 
					    // ポッドキャスト原稿生成
 | 
				
			||||||
        if (already) {
 | 
					    console.log(`ポッドキャスト原稿生成開始: ${feedTitle}`);
 | 
				
			||||||
          console.log(`既に処理済み: ${finalItemId}`);
 | 
					    const podcastContent = await openAI_GeneratePodcastContent(feedTitle, yesterdayItems);
 | 
				
			||||||
          continue;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
        console.log(`スクリプト生成開始: ${finalItemId}`);
 | 
					    // トピックごとの統合音声生成
 | 
				
			||||||
        const scriptText = await openAI_GenerateScript({
 | 
					    const feedUrlHash = crypto.createHash("md5").update(url).digest("hex");
 | 
				
			||||||
          title: item.title ?? "",
 | 
					    const categoryHash = crypto.createHash("md5").update(category).digest("hex");
 | 
				
			||||||
          link: item.link ?? "",
 | 
					    const uniqueFilename = `${feedUrlHash}-${categoryHash}.mp3`;
 | 
				
			||||||
          contentSnippet: item.contentSnippet ?? "",
 | 
					    
 | 
				
			||||||
 | 
					    const audioFilePath = await generateTTS(uniqueFilename, podcastContent);
 | 
				
			||||||
 | 
					    console.log(`音声ファイル生成完了: ${audioFilePath}`);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // エピソードとして保存(各フィードにつき1つの統合エピソード)
 | 
				
			||||||
 | 
					    const firstItem = yesterdayItems[0];
 | 
				
			||||||
 | 
					    const pub = new Date(firstItem.pubDate || "");
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    await saveEpisode({
 | 
				
			||||||
 | 
					      id: `topic-${categoryHash}`,
 | 
				
			||||||
 | 
					      title: `${category}: ${feedTitle}`,
 | 
				
			||||||
 | 
					      pubDate: pub.toISOString(),
 | 
				
			||||||
 | 
					      audioPath: audioFilePath,
 | 
				
			||||||
 | 
					      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 already = await markAsProcessed(url, finalItemId);
 | 
				
			||||||
        const feedUrlHash = crypto.createHash("md5").update(url).digest("hex");
 | 
					      if (already) {
 | 
				
			||||||
        const itemIdHash = crypto.createHash("md5").update(finalItemId).digest("hex");
 | 
					        console.log(`既に処理済み: ${finalItemId}`);
 | 
				
			||||||
        const uniqueFilename = `${feedUrlHash}-${itemIdHash}.mp3`;
 | 
					        continue;
 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        const audioFilePath = await generateTTS(uniqueFilename, scriptText);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        console.log(`音声ファイル生成完了: ${audioFilePath}`);
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        await saveEpisode({
 | 
					 | 
				
			||||||
          id: finalItemId,
 | 
					 | 
				
			||||||
          title: item.title ?? "",
 | 
					 | 
				
			||||||
          pubDate: pub.toISOString(),
 | 
					 | 
				
			||||||
          audioPath: audioFilePath,
 | 
					 | 
				
			||||||
          sourceLink: item.link ?? "",
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        console.log(`エピソード保存完了: ${finalItemId}`);
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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",
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user