Initial commit

This commit is contained in:
2025-06-04 08:14:55 +09:00
commit 4fe300d5d6
17 changed files with 486 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.log
.env

19
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ポッドキャスト管理画面</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/index.tsx"></script>
</body>
</html>

14
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from "react";
import FeedList from "./components/FeedList";
import EpisodeList from "./components/EpisodeList";
export default function App() {
return (
<div style={{ padding: "20px", fontFamily: "sans-serif" }}>
<h1> </h1>
<FeedList />
<hr style={{ margin: "20px 0" }} />
<EpisodeList />
</div>
);
}

View File

@ -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<Episode[]>([]);
useEffect(() => {
fetch("/api/episodes")
.then((res) => res.json())
.then((data) => setEpisodes(data));
}, []);
return (
<div>
<h2></h2>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ border: "1px solid #ccc", padding: "8px" }}>
</th>
<th style={{ border: "1px solid #ccc", padding: "8px" }}>
</th>
<th style={{ border: "1px solid #ccc", padding: "8px" }}>
</th>
</tr>
</thead>
<tbody>
{episodes.map((ep) => (
<tr key={ep.id}>
<td style={{ border: "1px solid #ccc", padding: "8px" }}>
{ep.title}
</td>
<td style={{ border: "1px solid #ccc", padding: "8px" }}>
{new Date(ep.pubDate).toLocaleString("ja-JP")}
</td>
<td style={{ border: "1px solid #ccc", padding: "8px" }}>
<audio controls preload="none">
<source
src={`/podcast_audio/${ep.id}.mp3`}
type="audio/mpeg"
/>
使 audio
</audio>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,45 @@
import React, { useEffect, useState } from "react";
export default function FeedList() {
const [feeds, setFeeds] = useState<string[]>([]);
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 (
<div>
<h2>RSSフィード管理</h2>
<ul>
{feeds.map((url) => (
<li key={url}>{url}</li>
))}
</ul>
<input
type="text"
placeholder="RSSフィードURLを入力"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
style={{ width: "300px" }}
/>
<button onClick={addFeed} style={{ marginLeft: "8px" }}>
</button>
</div>
);
}

6
frontend/src/index.tsx Normal file
View File

@ -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(<App />);

14
frontend/tsconfig.json Normal file
View File

@ -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"]
}

15
package.json Normal file
View File

@ -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"
}

15
schema.sql Normal file
View File

@ -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
);

View File

@ -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<FeedItem>();
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);
});

48
server.ts Normal file
View File

@ -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");

48
services/database.ts Normal file
View 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
View 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
View 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
View 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;
}

14
tsconfig.json Normal file
View File

@ -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"]
}