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