Initial commit
This commit is contained in:
		
							
								
								
									
										48
									
								
								services/database.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								services/database.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
import Database from "better-sqlite3";
 | 
			
		||||
import path from "path";
 | 
			
		||||
 | 
			
		||||
const dbPath = path.join(__dirname, "../data/podcast.db");
 | 
			
		||||
const db = new Database(dbPath);
 | 
			
		||||
 | 
			
		||||
// Ensure schema is set up
 | 
			
		||||
db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items (
 | 
			
		||||
  feed_url TEXT NOT NULL,
 | 
			
		||||
  item_id   TEXT NOT NULL,
 | 
			
		||||
  processed_at TEXT NOT NULL,
 | 
			
		||||
  PRIMARY KEY(feed_url, item_id)
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE IF NOT EXISTS episodes (
 | 
			
		||||
  id TEXT PRIMARY KEY,
 | 
			
		||||
  title TEXT NOT NULL,
 | 
			
		||||
  pubDate TEXT NOT NULL,
 | 
			
		||||
  audioPath TEXT NOT NULL,
 | 
			
		||||
  sourceLink TEXT NOT NULL
 | 
			
		||||
);`);
 | 
			
		||||
 | 
			
		||||
export interface Episode {
 | 
			
		||||
  id: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  pubDate: string;
 | 
			
		||||
  audioPath: string;
 | 
			
		||||
  sourceLink: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function markAsProcessed(feedUrl: string, itemId: string): Promise<boolean> {
 | 
			
		||||
  const stmt = db.prepare("SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?");
 | 
			
		||||
  const row = stmt.get(feedUrl, itemId);
 | 
			
		||||
  if (row) return true;
 | 
			
		||||
  const insert = db.prepare("INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)");
 | 
			
		||||
  insert.run(feedUrl, itemId, new Date().toISOString());
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function saveEpisode(ep: Episode): Promise<void> {
 | 
			
		||||
  const stmt = db.prepare("INSERT OR IGNORE INTO episodes (id, title, pubDate, audioPath, sourceLink) VALUES (?, ?, ?, ?, ?)");
 | 
			
		||||
  stmt.run(ep.id, ep.title, ep.pubDate, ep.audioPath, ep.sourceLink);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function fetchAllEpisodes(): Promise<Episode[]> {
 | 
			
		||||
  const stmt = db.prepare("SELECT * FROM episodes ORDER BY pubDate DESC");
 | 
			
		||||
  return stmt.all();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								services/llm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								services/llm.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
import { Configuration, OpenAIApi } from "openai";
 | 
			
		||||
 | 
			
		||||
const configuration = new Configuration({
 | 
			
		||||
  apiKey: process.env.OPENAI_API_KEY,
 | 
			
		||||
});
 | 
			
		||||
const openai = new OpenAIApi(configuration);
 | 
			
		||||
 | 
			
		||||
export async function openAI_GenerateScript(item: {
 | 
			
		||||
  title: string;
 | 
			
		||||
  link: string;
 | 
			
		||||
  contentSnippet?: string;
 | 
			
		||||
}): Promise<string> {
 | 
			
		||||
  const prompt = \`
 | 
			
		||||
あなたはポッドキャスターです。以下の情報をもとに、リスナー向けにわかりやすい日本語のポッドキャスト原稿を書いてください。
 | 
			
		||||
 | 
			
		||||
- 記事タイトル: \${item.title}
 | 
			
		||||
- 記事リンク: \${item.link}
 | 
			
		||||
- 記事概要: \${item.contentSnippet || "なし"}
 | 
			
		||||
 | 
			
		||||
「今日のニュース記事をご紹介します…」といった導入も含め、約300文字程度でまとめてください。
 | 
			
		||||
\`;
 | 
			
		||||
  const response = await openai.createChatCompletion({
 | 
			
		||||
    model: "gpt-4o-mini",
 | 
			
		||||
    messages: [{ role: "user", content: prompt.trim() }],
 | 
			
		||||
    temperature: 0.7,
 | 
			
		||||
  });
 | 
			
		||||
  const scriptText = response.data.choices[0].message?.content?.trim() || "";
 | 
			
		||||
  return scriptText;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										44
									
								
								services/podcast.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								services/podcast.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
import fs from "fs";
 | 
			
		||||
import path from "path";
 | 
			
		||||
import { Episode, fetchAllEpisodes } from "./database";
 | 
			
		||||
 | 
			
		||||
export async function updatePodcastRSS() {
 | 
			
		||||
  const episodes: Episode[] = await fetchAllEpisodes();
 | 
			
		||||
 | 
			
		||||
  const channelTitle = "自動生成ポッドキャスト";
 | 
			
		||||
  const channelLink = "https://your-domain.com/podcast";
 | 
			
		||||
  const channelDescription = "RSSフィードから自動生成されたポッドキャストです。";
 | 
			
		||||
  const lastBuildDate = new Date().toUTCString();
 | 
			
		||||
 | 
			
		||||
  let itemsXml = "";
 | 
			
		||||
  for (const ep of episodes) {
 | 
			
		||||
    const fileUrl = `https://your-domain.com/podcast_audio/${path.basename(
 | 
			
		||||
      ep.audioPath
 | 
			
		||||
    )}`;
 | 
			
		||||
    const pubDate = new Date(ep.pubDate).toUTCString();
 | 
			
		||||
    itemsXml += \`
 | 
			
		||||
      <item>
 | 
			
		||||
        <title><![CDATA[\${ep.title}]]></title>
 | 
			
		||||
        <description><![CDATA[この記事を元に自動生成したポッドキャストです]]></description>
 | 
			
		||||
        <enclosure url="\${fileUrl}" length="\${fs.statSync(ep.audioPath).size}" type="audio/mpeg" />
 | 
			
		||||
        <guid>\${fileUrl}</guid>
 | 
			
		||||
        <pubDate>\${pubDate}</pubDate>
 | 
			
		||||
      </item>
 | 
			
		||||
    \`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const rssXml = \`<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
  <rss version="2.0">
 | 
			
		||||
    <channel>
 | 
			
		||||
      <title><![CDATA[\${channelTitle}]]></title>
 | 
			
		||||
      <link>\${channelLink}</link>
 | 
			
		||||
      <description><![CDATA[\${channelDescription}]]></description>
 | 
			
		||||
      <lastBuildDate>\${lastBuildDate}</lastBuildDate>
 | 
			
		||||
      \${itemsXml}
 | 
			
		||||
    </channel>
 | 
			
		||||
  </rss>
 | 
			
		||||
  \`;
 | 
			
		||||
 | 
			
		||||
  const outputPath = path.join(__dirname, "../public/podcast.xml");
 | 
			
		||||
  fs.writeFileSync(outputPath, rssXml.trim());
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								services/tts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								services/tts.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
import fs from "fs";
 | 
			
		||||
import path from "path";
 | 
			
		||||
import {
 | 
			
		||||
  PollyClient,
 | 
			
		||||
  SynthesizeSpeechCommand,
 | 
			
		||||
} from "@aws-sdk/client-polly";
 | 
			
		||||
 | 
			
		||||
const polly = new PollyClient({ region: "ap-northeast-1" });
 | 
			
		||||
 | 
			
		||||
export async function generateTTS(
 | 
			
		||||
  itemId: string,
 | 
			
		||||
  scriptText: string
 | 
			
		||||
): Promise<string> {
 | 
			
		||||
  const params = {
 | 
			
		||||
    OutputFormat: "mp3",
 | 
			
		||||
    Text: scriptText,
 | 
			
		||||
    VoiceId: "Mizuki",
 | 
			
		||||
    LanguageCode: "ja-JP",
 | 
			
		||||
  };
 | 
			
		||||
  const command = new SynthesizeSpeechCommand(params);
 | 
			
		||||
  const response = await polly.send(command);
 | 
			
		||||
 | 
			
		||||
  if (!response.AudioStream) {
 | 
			
		||||
    throw new Error("TTSのAudioStreamが空です");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const outputDir = path.join(__dirname, "../static/podcast_audio");
 | 
			
		||||
  if (!fs.existsSync(outputDir)) {
 | 
			
		||||
    fs.mkdirSync(outputDir, { recursive: true });
 | 
			
		||||
  }
 | 
			
		||||
  const filePath = path.join(outputDir, \`\${itemId}.mp3\`);
 | 
			
		||||
 | 
			
		||||
  const chunks: Uint8Array[] = [];
 | 
			
		||||
  for await (const chunk of response.AudioStream as any) {
 | 
			
		||||
    chunks.push(chunk);
 | 
			
		||||
  }
 | 
			
		||||
  const buffer = Buffer.concat(chunks);
 | 
			
		||||
  fs.writeFileSync(filePath, buffer);
 | 
			
		||||
  return filePath;
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user