From 4fe300d5d604d49c6f8edca421c71a5da565f3f7 Mon Sep 17 00:00:00 2001 From: Satsuki Akiba Date: Wed, 4 Jun 2025 08:14:55 +0900 Subject: [PATCH] Initial commit --- .gitignore | 4 ++ frontend/package.json | 19 ++++++++ frontend/public/index.html | 12 +++++ frontend/src/App.tsx | 14 ++++++ frontend/src/components/EpisodeList.tsx | 61 +++++++++++++++++++++++++ frontend/src/components/FeedList.tsx | 45 ++++++++++++++++++ frontend/src/index.tsx | 6 +++ frontend/tsconfig.json | 14 ++++++ package.json | 15 ++++++ schema.sql | 15 ++++++ scripts/fetch_and_generate.ts | 58 +++++++++++++++++++++++ server.ts | 48 +++++++++++++++++++ services/database.ts | 48 +++++++++++++++++++ services/llm.ts | 29 ++++++++++++ services/podcast.ts | 44 ++++++++++++++++++ services/tts.ts | 40 ++++++++++++++++ tsconfig.json | 14 ++++++ 17 files changed, 486 insertions(+) create mode 100644 .gitignore create mode 100644 frontend/package.json create mode 100644 frontend/public/index.html create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/EpisodeList.tsx create mode 100644 frontend/src/components/FeedList.tsx create mode 100644 frontend/src/index.tsx create mode 100644 frontend/tsconfig.json create mode 100644 package.json create mode 100644 schema.sql create mode 100644 scripts/fetch_and_generate.ts create mode 100644 server.ts create mode 100644 services/database.ts create mode 100644 services/llm.ts create mode 100644 services/podcast.ts create mode 100644 services/tts.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e537e12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.env diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b4cfec5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "podcast-frontend", + "version": "1.0.0", + "scripts": { + "dev": "bun dev", + "build": "bun build", + "start": "bun start" + }, + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "typescript": "^4.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "bun-types": "^0.1.0" + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..8fac2f5 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,12 @@ + + + + + + ポッドキャスト管理画面 + + +
+ + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6320e78 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import FeedList from "./components/FeedList"; +import EpisodeList from "./components/EpisodeList"; + +export default function App() { + return ( +
+

ポッドキャスト自動生成サービス 管理画面

+ +
+ +
+ ); +} diff --git a/frontend/src/components/EpisodeList.tsx b/frontend/src/components/EpisodeList.tsx new file mode 100644 index 0000000..a4a37db --- /dev/null +++ b/frontend/src/components/EpisodeList.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from "react"; + +interface Episode { + id: string; + title: string; + pubDate: string; + audioPath: string; + sourceLink: string; +} + +export default function EpisodeList() { + const [episodes, setEpisodes] = useState([]); + + useEffect(() => { + fetch("/api/episodes") + .then((res) => res.json()) + .then((data) => setEpisodes(data)); + }, []); + + return ( +
+

エピソード一覧

+ + + + + + + + + + {episodes.map((ep) => ( + + + + + + ))} + +
+ タイトル + + 公開日時 + + プレビュー +
+ {ep.title} + + {new Date(ep.pubDate).toLocaleString("ja-JP")} + + +
+
+ ); +} diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx new file mode 100644 index 0000000..d49d057 --- /dev/null +++ b/frontend/src/components/FeedList.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from "react"; + +export default function FeedList() { + const [feeds, setFeeds] = useState([]); + const [newUrl, setNewUrl] = useState(""); + + useEffect(() => { + fetch("/api/feeds") + .then((res) => res.json()) + .then((data) => setFeeds(data)); + }, []); + + const addFeed = async () => { + if (!newUrl) return; + await fetch("/api/feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feedUrl: newUrl }), + }); + setNewUrl(""); + const updated = await fetch("/api/feeds").then((res) => res.json()); + setFeeds(updated); + }; + + return ( +
+

RSSフィード管理

+
    + {feeds.map((url) => ( +
  • {url}
  • + ))} +
+ setNewUrl(e.target.value)} + style={{ width: "300px" }} + /> + +
+ ); +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..ef039be --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +const root = ReactDOM.createRoot(document.getElementById("root")!); +root.render(); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..298ff3d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "jsx": "react-jsx", + "strict": true, + "moduleResolution": "Node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build" + }, + "include": ["src"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a447c49 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "podcast-generator", + "version": "1.0.0", + "scripts": { + "start": "bun run server.ts" + }, + "dependencies": { + "rss-parser": "^3.12.0", + "openai": "^4.0.0", + "@aws-sdk/client-polly": "^3.0.0", + "better-sqlite3": "^8.0.0", + "bun-router": "^0.1.0" + }, + "type": "module" +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..8c23c66 --- /dev/null +++ b/schema.sql @@ -0,0 +1,15 @@ +-- schema.sql +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 +); diff --git a/scripts/fetch_and_generate.ts b/scripts/fetch_and_generate.ts new file mode 100644 index 0000000..7251e36 --- /dev/null +++ b/scripts/fetch_and_generate.ts @@ -0,0 +1,58 @@ +import Parser from "rss-parser"; +import { openAI_GenerateScript } from "../services/llm"; +import { generateTTS } from "../services/tts"; +import { saveEpisode, markAsProcessed } from "../services/database"; +import { updatePodcastRSS } from "../services/podcast"; + +interface FeedItem { + id: string; + title: string; + link: string; + pubDate: string; + contentSnippet?: string; +} + +async function main() { + const parser = new Parser(); + const feedUrls = [ + "https://example.com/feed1.rss", + ]; + + for (const url of feedUrls) { + 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 ( + pub.getFullYear() === yesterday.getFullYear() && + pub.getMonth() === yesterday.getMonth() && + pub.getDate() === yesterday.getDate() + ) { + const already = await markAsProcessed(url, item.id); + if (already) continue; + + const scriptText = await openAI_GenerateScript(item); + const audioFilePath = await generateTTS(item.id, scriptText); + + await saveEpisode({ + id: item.id, + title: item.title, + pubDate: pub.toISOString(), + audioPath: audioFilePath, + sourceLink: item.link, + }); + } + } + } + + await updatePodcastRSS(); + console.log("処理完了:", new Date().toISOString()); +} + +main().catch((err) => { + console.error("エラー発生:", err); + process.exit(1); +}); diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..123017f --- /dev/null +++ b/server.ts @@ -0,0 +1,48 @@ +import { serve } from "bun"; +import fs from "fs"; +import path from "path"; +import Database from "better-sqlite3"; +import Router from "bun-router"; + +const db = new Database("./data/podcast.db"); +db.exec(fs.readFileSync("./schema.sql", "utf-8")); + +const router = new Router(); + +router.get("/api/feeds", (ctx) => { + const rows = db.prepare("SELECT feed_url FROM processed_feed_items GROUP BY feed_url").all(); + return new Response(JSON.stringify(rows.map(r => r.feed_url)), { status: 200 }); +}); + +router.post("/api/feeds", async (ctx) => { + const { feedUrl }: { feedUrl: string } = await ctx.json(); + return new Response(JSON.stringify({ result: "OK" }), { status: 200 }); +}); + +router.get("/api/episodes", (ctx) => { + const episodes = db.prepare("SELECT * FROM episodes ORDER BY pubDate DESC").all(); + return new Response(JSON.stringify(episodes), { status: 200 }); +}); + +router.post("/api/episodes/:id/regenerate", (ctx) => { + const { id } = ctx.params; + return new Response(JSON.stringify({ result: `Regeneration requested for \${id}` }), { status: 200 }); +}); + +router.get("/*", (ctx) => { + const filePath = path.join(__dirname, "public", ctx.request.url.pathname); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + return new Response(fs.readFileSync(filePath), { status: 200 }); + } + return new Response(fs.readFileSync(path.join(__dirname, "public", "index.html")), { + status: 200, + headers: { "Content-Type": "text/html" }, + }); +}); + +serve({ + port: 3000, + fetch: router.fetch, +}); + +console.log("Server is running on http://localhost:3000"); diff --git a/services/database.ts b/services/database.ts new file mode 100644 index 0000000..dc7270f --- /dev/null +++ b/services/database.ts @@ -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 { + 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 { + 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 { + const stmt = db.prepare("SELECT * FROM episodes ORDER BY pubDate DESC"); + return stmt.all(); +} diff --git a/services/llm.ts b/services/llm.ts new file mode 100644 index 0000000..71217bd --- /dev/null +++ b/services/llm.ts @@ -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 { + 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; +} diff --git a/services/podcast.ts b/services/podcast.ts new file mode 100644 index 0000000..c2811d7 --- /dev/null +++ b/services/podcast.ts @@ -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 += \` + + <![CDATA[\${ep.title}]]> + + + \${fileUrl} + \${pubDate} + + \`; + } + + const rssXml = \` + + + <![CDATA[\${channelTitle}]]> + \${channelLink} + + \${lastBuildDate} + \${itemsXml} + + + \`; + + const outputPath = path.join(__dirname, "../public/podcast.xml"); + fs.writeFileSync(outputPath, rssXml.trim()); +} diff --git a/services/tts.ts b/services/tts.ts new file mode 100644 index 0000000..abf9b5a --- /dev/null +++ b/services/tts.ts @@ -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 { + 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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ad2a18d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["scripts", "services", "server.ts"] +}