Initial commit
This commit is contained in:
		
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
				
			|||||||
 | 
					node_modules
 | 
				
			||||||
 | 
					dist
 | 
				
			||||||
 | 
					*.log
 | 
				
			||||||
 | 
					.env
 | 
				
			||||||
							
								
								
									
										19
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								frontend/package.json
									
									
									
									
									
										Normal 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"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										12
									
								
								frontend/public/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/public/index.html
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										14
									
								
								frontend/src/App.tsx
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										61
									
								
								frontend/src/components/EpisodeList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								frontend/src/components/EpisodeList.tsx
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										45
									
								
								frontend/src/components/FeedList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								frontend/src/components/FeedList.tsx
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										6
									
								
								frontend/src/index.tsx
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										14
									
								
								frontend/tsconfig.json
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										15
									
								
								package.json
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										15
									
								
								schema.sql
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
							
								
								
									
										58
									
								
								scripts/fetch_and_generate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								scripts/fetch_and_generate.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										48
									
								
								server.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										14
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								tsconfig.json
									
									
									
									
									
										Normal 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"]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user