Apply formatting

This commit is contained in:
2025-06-08 15:21:58 +09:00
parent b5ff912fcb
commit a728ebb66c
28 changed files with 1809 additions and 1137 deletions

View File

@ -13,6 +13,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Admin panel development**: `bun run dev:admin`
- **Manual batch process**: `bun run scripts/fetch_and_generate.ts`
- **Type checking**: `bunx tsc --noEmit`
- **Format code**: `bun run format`
- **Check format**: `bun run format:check`
- **Lint code**: `bun run lint`
- **Fix lint issues**: `bun run lint:fix`
- **Check all (format + lint)**: `bun run check`
- **Fix all issues**: `bun run check:fix`
## Architecture Overview

View File

@ -18,4 +18,4 @@
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
interface Feed {
id: string;
@ -38,7 +38,7 @@ interface FeedRequest {
url: string;
requestedBy?: string;
requestMessage?: string;
status: 'pending' | 'approved' | 'rejected';
status: "pending" | "approved" | "rejected";
createdAt: string;
reviewedAt?: string;
reviewedBy?: string;
@ -53,10 +53,16 @@ function App() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [newFeedUrl, setNewFeedUrl] = useState('');
const [requestFilter, setRequestFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
const [approvalNotes, setApprovalNotes] = useState<{[key: string]: string}>({});
const [activeTab, setActiveTab] = useState<'dashboard' | 'feeds' | 'env' | 'batch' | 'requests'>('dashboard');
const [newFeedUrl, setNewFeedUrl] = useState("");
const [requestFilter, setRequestFilter] = useState<
"all" | "pending" | "approved" | "rejected"
>("all");
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
{},
);
const [activeTab, setActiveTab] = useState<
"dashboard" | "feeds" | "env" | "batch" | "requests"
>("dashboard");
useEffect(() => {
loadData();
@ -66,21 +72,21 @@ function App() {
setLoading(true);
try {
const [feedsRes, statsRes, envRes, requestsRes] = await Promise.all([
fetch('/api/admin/feeds'),
fetch('/api/admin/stats'),
fetch('/api/admin/env'),
fetch('/api/admin/feed-requests')
fetch("/api/admin/feeds"),
fetch("/api/admin/stats"),
fetch("/api/admin/env"),
fetch("/api/admin/feed-requests"),
]);
if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok) {
throw new Error('Failed to load data');
throw new Error("Failed to load data");
}
const [feedsData, statsData, envData, requestsData] = await Promise.all([
feedsRes.json(),
statsRes.json(),
envRes.json(),
requestsRes.json()
requestsRes.json(),
]);
setFeeds(feedsData);
@ -89,8 +95,8 @@ function App() {
setFeedRequests(requestsData);
setError(null);
} catch (err) {
setError('データの読み込みに失敗しました');
console.error('Error loading data:', err);
setError("データの読み込みに失敗しました");
console.error("Error loading data:", err);
} finally {
setLoading(false);
}
@ -101,35 +107,39 @@ function App() {
if (!newFeedUrl.trim()) return;
try {
const res = await fetch('/api/admin/feeds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedUrl: newFeedUrl })
const res = await fetch("/api/admin/feeds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedUrl: newFeedUrl }),
});
const data = await res.json();
if (res.ok) {
setSuccess(data.message);
setNewFeedUrl('');
setNewFeedUrl("");
loadData();
} else {
setError(data.error || 'フィード追加に失敗しました');
setError(data.error || "フィード追加に失敗しました");
}
} catch (err) {
setError('フィード追加に失敗しました');
console.error('Error adding feed:', err);
setError("フィード追加に失敗しました");
console.error("Error adding feed:", err);
}
};
const deleteFeed = async (feedId: string) => {
if (!confirm('本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。')) {
if (
!confirm(
"本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。",
)
) {
return;
}
try {
const res = await fetch(`/api/admin/feeds/${feedId}`, {
method: 'DELETE'
method: "DELETE",
});
const data = await res.json();
@ -138,20 +148,20 @@ function App() {
setSuccess(data.message);
loadData();
} else {
setError(data.error || 'フィード削除に失敗しました');
setError(data.error || "フィード削除に失敗しました");
}
} catch (err) {
setError('フィード削除に失敗しました');
console.error('Error deleting feed:', err);
setError("フィード削除に失敗しました");
console.error("Error deleting feed:", err);
}
};
const toggleFeed = async (feedId: string, active: boolean) => {
try {
const res = await fetch(`/api/admin/feeds/${feedId}/toggle`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active })
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active }),
});
const data = await res.json();
@ -160,18 +170,18 @@ function App() {
setSuccess(data.message);
loadData();
} else {
setError(data.error || 'フィードステータス変更に失敗しました');
setError(data.error || "フィードステータス変更に失敗しました");
}
} catch (err) {
setError('フィードステータス変更に失敗しました');
console.error('Error toggling feed:', err);
setError("フィードステータス変更に失敗しました");
console.error("Error toggling feed:", err);
}
};
const triggerBatch = async () => {
try {
const res = await fetch('/api/admin/batch/trigger', {
method: 'POST'
const res = await fetch("/api/admin/batch/trigger", {
method: "POST",
});
const data = await res.json();
@ -180,47 +190,54 @@ function App() {
setSuccess(data.message);
loadData(); // Refresh data to update batch status
} else {
setError(data.error || 'バッチ処理開始に失敗しました');
setError(data.error || "バッチ処理開始に失敗しました");
}
} catch (err) {
setError('バッチ処理開始に失敗しました');
console.error('Error triggering batch:', err);
setError("バッチ処理開始に失敗しました");
console.error("Error triggering batch:", err);
}
};
const forceStopBatch = async () => {
if (!confirm('実行中のバッチ処理を強制停止しますか?\n\n進行中の処理が中断され、データの整合性に影響が出る可能性があります。')) {
if (
!confirm(
"実行中のバッチ処理を強制停止しますか?\n\n進行中の処理が中断され、データの整合性に影響が出る可能性があります。",
)
) {
return;
}
try {
const res = await fetch('/api/admin/batch/force-stop', {
method: 'POST'
const res = await fetch("/api/admin/batch/force-stop", {
method: "POST",
});
const data = await res.json();
if (res.ok) {
if (data.result === 'STOPPED') {
if (data.result === "STOPPED") {
setSuccess(data.message);
} else if (data.result === 'NO_PROCESS') {
} else if (data.result === "NO_PROCESS") {
setSuccess(data.message);
}
loadData(); // Refresh data to update batch status
} else {
setError(data.error || 'バッチ処理強制停止に失敗しました');
setError(data.error || "バッチ処理強制停止に失敗しました");
}
} catch (err) {
setError('バッチ処理強制停止に失敗しました');
console.error('Error force stopping batch:', err);
setError("バッチ処理強制停止に失敗しました");
console.error("Error force stopping batch:", err);
}
};
const toggleBatchScheduler = async (enable: boolean) => {
try {
const res = await fetch(`/api/admin/batch/${enable ? 'enable' : 'disable'}`, {
method: 'POST'
});
const res = await fetch(
`/api/admin/batch/${enable ? "enable" : "disable"}`,
{
method: "POST",
},
);
const data = await res.json();
@ -228,61 +245,61 @@ function App() {
setSuccess(data.message);
loadData(); // Refresh data to update batch status
} else {
setError(data.error || 'バッチスケジューラーの状態変更に失敗しました');
setError(data.error || "バッチスケジューラーの状態変更に失敗しました");
}
} catch (err) {
setError('バッチスケジューラーの状態変更に失敗しました');
console.error('Error toggling batch scheduler:', err);
setError("バッチスケジューラーの状態変更に失敗しました");
console.error("Error toggling batch scheduler:", err);
}
};
const approveFeedRequest = async (requestId: string, notes: string) => {
try {
const res = await fetch(`/api/admin/feed-requests/${requestId}/approve`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adminNotes: notes })
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNotes: notes }),
});
const data = await res.json();
if (res.ok) {
setSuccess(data.message || 'フィードリクエストを承認しました');
setApprovalNotes({ ...approvalNotes, [requestId]: '' });
setSuccess(data.message || "フィードリクエストを承認しました");
setApprovalNotes({ ...approvalNotes, [requestId]: "" });
loadData();
} else {
setError(data.error || 'フィードリクエストの承認に失敗しました');
setError(data.error || "フィードリクエストの承認に失敗しました");
}
} catch (err) {
setError('フィードリクエストの承認に失敗しました');
console.error('Error approving feed request:', err);
setError("フィードリクエストの承認に失敗しました");
console.error("Error approving feed request:", err);
}
};
const rejectFeedRequest = async (requestId: string, notes: string) => {
if (!confirm('このフィードリクエストを拒否しますか?')) {
if (!confirm("このフィードリクエストを拒否しますか?")) {
return;
}
try {
const res = await fetch(`/api/admin/feed-requests/${requestId}/reject`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adminNotes: notes })
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNotes: notes }),
});
const data = await res.json();
if (res.ok) {
setSuccess(data.message || 'フィードリクエストを拒否しました');
setApprovalNotes({ ...approvalNotes, [requestId]: '' });
setSuccess(data.message || "フィードリクエストを拒否しました");
setApprovalNotes({ ...approvalNotes, [requestId]: "" });
loadData();
} else {
setError(data.error || 'フィードリクエストの拒否に失敗しました');
setError(data.error || "フィードリクエストの拒否に失敗しました");
}
} catch (err) {
setError('フィードリクエストの拒否に失敗しました');
console.error('Error rejecting feed request:', err);
setError("フィードリクエストの拒否に失敗しました");
console.error("Error rejecting feed request:", err);
}
};
@ -290,20 +307,26 @@ function App() {
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
};
const filteredRequests = feedRequests.filter(request => {
if (requestFilter === 'all') return true;
const filteredRequests = feedRequests.filter((request) => {
if (requestFilter === "all") return true;
return request.status === requestFilter;
});
if (loading) {
return <div className="container"><div className="loading">...</div></div>;
return (
<div className="container">
<div className="loading">...</div>
</div>
);
}
return (
<div className="container">
<div className="header">
<h1></h1>
<p className="subtitle">RSS Podcast Manager - </p>
<p className="subtitle">
RSS Podcast Manager -
</p>
</div>
{error && <div className="error">{error}</div>}
@ -311,34 +334,34 @@ function App() {
<div className="card">
<div className="card-header">
<nav style={{ display: 'flex', gap: '16px' }}>
<button
className={`btn ${activeTab === 'dashboard' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('dashboard')}
<nav style={{ display: "flex", gap: "16px" }}>
<button
className={`btn ${activeTab === "dashboard" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("dashboard")}
>
</button>
<button
className={`btn ${activeTab === 'feeds' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('feeds')}
<button
className={`btn ${activeTab === "feeds" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("feeds")}
>
</button>
<button
className={`btn ${activeTab === 'batch' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('batch')}
<button
className={`btn ${activeTab === "batch" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("batch")}
>
</button>
<button
className={`btn ${activeTab === 'requests' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('requests')}
<button
className={`btn ${activeTab === "requests" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("requests")}
>
</button>
<button
className={`btn ${activeTab === 'env' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('env')}
<button
className={`btn ${activeTab === "env" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("env")}
>
</button>
@ -346,7 +369,7 @@ function App() {
</div>
<div className="card-content">
{activeTab === 'dashboard' && (
{activeTab === "dashboard" && (
<>
<div className="stats-grid">
<div className="stat-card">
@ -370,44 +393,62 @@ function App() {
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value" style={{ color: stats?.batchScheduler?.enabled ? '#28a745' : '#dc3545' }}>
{stats?.batchScheduler?.enabled ? 'ON' : 'OFF'}
<div
className="value"
style={{
color: stats?.batchScheduler?.enabled
? "#28a745"
: "#dc3545",
}}
>
{stats?.batchScheduler?.enabled ? "ON" : "OFF"}
</div>
<div className="label"></div>
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<button
className="btn btn-success"
<div style={{ marginBottom: "20px" }}>
<button
className="btn btn-success"
onClick={triggerBatch}
disabled={stats?.batchScheduler?.isRunning}
>
{stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'}
{stats?.batchScheduler?.isRunning
? "バッチ処理実行中..."
: "バッチ処理を手動実行"}
</button>
{stats?.batchScheduler?.canForceStop && (
<button
className="btn btn-danger"
<button
className="btn btn-danger"
onClick={forceStopBatch}
style={{ marginLeft: '8px' }}
style={{ marginLeft: "8px" }}
>
</button>
)}
<button className="btn btn-primary" onClick={loadData} style={{ marginLeft: '8px' }}>
<button
className="btn btn-primary"
onClick={loadData}
style={{ marginLeft: "8px" }}
>
</button>
</div>
<div style={{ fontSize: '14px', color: '#7f8c8d' }}>
<div style={{ fontSize: "14px", color: "#7f8c8d" }}>
<p>: {stats?.adminPort}</p>
<p>: {stats?.authEnabled ? '有効' : '無効'}</p>
<p>: {stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleString('ja-JP') : '不明'}</p>
<p>: {stats?.authEnabled ? "有効" : "無効"}</p>
<p>
:{" "}
{stats?.lastUpdated
? new Date(stats.lastUpdated).toLocaleString("ja-JP")
: "不明"}
</p>
</div>
</>
)}
{activeTab === 'feeds' && (
{activeTab === "feeds" && (
<>
<form onSubmit={addFeed} className="add-feed-form">
<div className="form-group">
@ -427,10 +468,16 @@ function App() {
</button>
</form>
<div style={{ marginTop: '24px' }}>
<div style={{ marginTop: "24px" }}>
<h3> ({feeds.length})</h3>
{feeds.length === 0 ? (
<p style={{ color: '#7f8c8d', textAlign: 'center', padding: '20px' }}>
<p
style={{
color: "#7f8c8d",
textAlign: "center",
padding: "20px",
}}
>
</p>
) : (
@ -438,18 +485,20 @@ function App() {
{feeds.map((feed) => (
<li key={feed.id} className="feed-item">
<div className="feed-info">
<h3>{feed.title || 'タイトル未設定'}</h3>
<h3>{feed.title || "タイトル未設定"}</h3>
<div className="url">{feed.url}</div>
<span className={`status ${feed.active ? 'active' : 'inactive'}`}>
{feed.active ? 'アクティブ' : '非アクティブ'}
<span
className={`status ${feed.active ? "active" : "inactive"}`}
>
{feed.active ? "アクティブ" : "非アクティブ"}
</span>
</div>
<div className="feed-actions">
<button
className={`btn ${feed.active ? 'btn-warning' : 'btn-success'}`}
className={`btn ${feed.active ? "btn-warning" : "btn-success"}`}
onClick={() => toggleFeed(feed.id, !feed.active)}
>
{feed.active ? '無効化' : '有効化'}
{feed.active ? "無効化" : "有効化"}
</button>
<button
className="btn btn-danger"
@ -466,55 +515,75 @@ function App() {
</>
)}
{activeTab === 'batch' && (
{activeTab === "batch" && (
<>
<h3></h3>
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}>
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
</p>
<div className="stats-grid" style={{ marginBottom: '24px' }}>
<div className="stats-grid" style={{ marginBottom: "24px" }}>
<div className="stat-card">
<div className="value" style={{ color: stats?.batchScheduler?.enabled ? '#28a745' : '#dc3545' }}>
{stats?.batchScheduler?.enabled ? '有効' : '無効'}
<div
className="value"
style={{
color: stats?.batchScheduler?.enabled
? "#28a745"
: "#dc3545",
}}
>
{stats?.batchScheduler?.enabled ? "有効" : "無効"}
</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value" style={{ color: stats?.batchScheduler?.isRunning ? '#ffc107' : '#6c757d' }}>
{stats?.batchScheduler?.isRunning ? '実行中' : '待機中'}
<div
className="value"
style={{
color: stats?.batchScheduler?.isRunning
? "#ffc107"
: "#6c757d",
}}
>
{stats?.batchScheduler?.isRunning ? "実行中" : "待機中"}
</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value" style={{ fontSize: '12px' }}>
{stats?.batchScheduler?.lastRun
? new Date(stats.batchScheduler.lastRun).toLocaleString('ja-JP')
: '未実行'}
<div className="value" style={{ fontSize: "12px" }}>
{stats?.batchScheduler?.lastRun
? new Date(stats.batchScheduler.lastRun).toLocaleString(
"ja-JP",
)
: "未実行"}
</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value" style={{ fontSize: '12px' }}>
{stats?.batchScheduler?.nextRun
? new Date(stats.batchScheduler.nextRun).toLocaleString('ja-JP')
: '未予定'}
<div className="value" style={{ fontSize: "12px" }}>
{stats?.batchScheduler?.nextRun
? new Date(stats.batchScheduler.nextRun).toLocaleString(
"ja-JP",
)
: "未予定"}
</div>
<div className="label"></div>
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{ marginBottom: "24px" }}>
<h4></h4>
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}>
<button
<div
style={{ display: "flex", gap: "12px", marginTop: "12px" }}
>
<button
className="btn btn-success"
onClick={() => toggleBatchScheduler(true)}
disabled={stats?.batchScheduler?.enabled}
>
</button>
<button
<button
className="btn btn-warning"
onClick={() => toggleBatchScheduler(false)}
disabled={!stats?.batchScheduler?.enabled}
@ -522,147 +591,248 @@ function App() {
</button>
</div>
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
<p
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
}}
>
</p>
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{ marginBottom: "24px" }}>
<h4></h4>
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}>
<button
<div
style={{ display: "flex", gap: "12px", marginTop: "12px" }}
>
<button
className="btn btn-primary"
onClick={triggerBatch}
disabled={stats?.batchScheduler?.isRunning}
>
{stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'}
{stats?.batchScheduler?.isRunning
? "バッチ処理実行中..."
: "バッチ処理を手動実行"}
</button>
{stats?.batchScheduler?.canForceStop && (
<button
className="btn btn-danger"
onClick={forceStopBatch}
>
<button className="btn btn-danger" onClick={forceStopBatch}>
</button>
)}
</div>
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
<p
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
}}
>
</p>
</div>
<div style={{ padding: '16px', background: '#f8f9fa', borderRadius: '4px' }}>
<div
style={{
padding: "16px",
background: "#f8f9fa",
borderRadius: "4px",
}}
>
<h4></h4>
<ul style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px', paddingLeft: '20px' }}>
<ul
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
paddingLeft: "20px",
}}
>
<li>6</li>
<li>RSS記事の取得</li>
<li></li>
<li></li>
<li><strong>:</strong> </li>
<li>
</li>
<li>
</li>
<li>
<strong>:</strong>{" "}
</li>
</ul>
</div>
</>
)}
{activeTab === 'requests' && (
{activeTab === "requests" && (
<>
<h3></h3>
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}>
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
</p>
<div style={{ marginBottom: '20px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className={`btn ${requestFilter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setRequestFilter('all')}
<div style={{ marginBottom: "20px" }}>
<div style={{ display: "flex", gap: "8px" }}>
<button
className={`btn ${requestFilter === "all" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter("all")}
>
({feedRequests.length})
</button>
<button
className={`btn ${requestFilter === 'pending' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setRequestFilter('pending')}
<button
className={`btn ${requestFilter === "pending" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter("pending")}
>
({feedRequests.filter(r => r.status === 'pending').length})
(
{feedRequests.filter((r) => r.status === "pending").length})
</button>
<button
className={`btn ${requestFilter === 'approved' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setRequestFilter('approved')}
<button
className={`btn ${requestFilter === "approved" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter("approved")}
>
({feedRequests.filter(r => r.status === 'approved').length})
(
{feedRequests.filter((r) => r.status === "approved").length}
)
</button>
<button
className={`btn ${requestFilter === 'rejected' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setRequestFilter('rejected')}
<button
className={`btn ${requestFilter === "rejected" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter("rejected")}
>
({feedRequests.filter(r => r.status === 'rejected').length})
(
{feedRequests.filter((r) => r.status === "rejected").length}
)
</button>
</div>
</div>
{filteredRequests.length === 0 ? (
<p style={{ color: '#7f8c8d', textAlign: 'center', padding: '20px' }}>
{requestFilter === 'all' ? 'フィードリクエストがありません' : `${requestFilter === 'pending' ? '保留中' : requestFilter === 'approved' ? '承認済み' : '拒否済み'}のリクエストがありません`}
<p
style={{
color: "#7f8c8d",
textAlign: "center",
padding: "20px",
}}
>
{requestFilter === "all"
? "フィードリクエストがありません"
: `${requestFilter === "pending" ? "保留中" : requestFilter === "approved" ? "承認済み" : "拒否済み"}のリクエストがありません`}
</p>
) : (
<div className="requests-list">
{filteredRequests.map((request) => (
<div key={request.id} className="feed-item" style={{ marginBottom: '16px' }}>
<div
key={request.id}
className="feed-item"
style={{ marginBottom: "16px" }}
>
<div className="feed-info">
<h4 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>{request.url}</h4>
<h4 style={{ margin: "0 0 8px 0", fontSize: "16px" }}>
{request.url}
</h4>
{request.requestMessage && (
<p style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#666' }}>
<p
style={{
margin: "0 0 8px 0",
fontSize: "14px",
color: "#666",
}}
>
: {request.requestMessage}
</p>
)}
<div style={{ fontSize: '12px', color: '#999' }}>
<span>: {request.requestedBy || '匿名'}</span>
<span style={{ margin: '0 8px' }}>|</span>
<span>: {new Date(request.createdAt).toLocaleString('ja-JP')}</span>
<div style={{ fontSize: "12px", color: "#999" }}>
<span>: {request.requestedBy || "匿名"}</span>
<span style={{ margin: "0 8px" }}>|</span>
<span>
:{" "}
{new Date(request.createdAt).toLocaleString(
"ja-JP",
)}
</span>
{request.reviewedAt && (
<>
<span style={{ margin: '0 8px' }}>|</span>
<span>: {new Date(request.reviewedAt).toLocaleString('ja-JP')}</span>
<span style={{ margin: "0 8px" }}>|</span>
<span>
:{" "}
{new Date(request.reviewedAt).toLocaleString(
"ja-JP",
)}
</span>
</>
)}
</div>
<span className={`status ${request.status === 'approved' ? 'active' : request.status === 'rejected' ? 'inactive' : ''}`}>
{request.status === 'pending' ? '保留中' : request.status === 'approved' ? '承認済み' : '拒否済み'}
<span
className={`status ${request.status === "approved" ? "active" : request.status === "rejected" ? "inactive" : ""}`}
>
{request.status === "pending"
? "保留中"
: request.status === "approved"
? "承認済み"
: "拒否済み"}
</span>
{request.adminNotes && (
<div style={{ marginTop: '8px', padding: '8px', background: '#f8f9fa', borderRadius: '4px', fontSize: '14px' }}>
<div
style={{
marginTop: "8px",
padding: "8px",
background: "#f8f9fa",
borderRadius: "4px",
fontSize: "14px",
}}
>
<strong>:</strong> {request.adminNotes}
</div>
)}
</div>
{request.status === 'pending' && (
<div className="feed-actions" style={{ flexDirection: 'column', gap: '8px', minWidth: '200px' }}>
{request.status === "pending" && (
<div
className="feed-actions"
style={{
flexDirection: "column",
gap: "8px",
minWidth: "200px",
}}
>
<textarea
placeholder="管理者メモ(任意)"
value={approvalNotes[request.id] || ''}
onChange={(e) => updateApprovalNotes(request.id, e.target.value)}
value={approvalNotes[request.id] || ""}
onChange={(e) =>
updateApprovalNotes(request.id, e.target.value)
}
style={{
width: '100%',
minHeight: '60px',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '12px',
resize: 'vertical'
width: "100%",
minHeight: "60px",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "12px",
resize: "vertical",
}}
/>
<div style={{ display: 'flex', gap: '4px' }}>
<div style={{ display: "flex", gap: "4px" }}>
<button
className="btn btn-success"
onClick={() => approveFeedRequest(request.id, approvalNotes[request.id] || '')}
style={{ fontSize: '12px', padding: '6px 12px' }}
onClick={() =>
approveFeedRequest(
request.id,
approvalNotes[request.id] || "",
)
}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
<button
className="btn btn-danger"
onClick={() => rejectFeedRequest(request.id, approvalNotes[request.id] || '')}
style={{ fontSize: '12px', padding: '6px 12px' }}
onClick={() =>
rejectFeedRequest(
request.id,
approvalNotes[request.id] || "",
)
}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
@ -676,27 +846,40 @@ function App() {
</>
)}
{activeTab === 'env' && (
{activeTab === "env" && (
<>
<h3></h3>
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}>
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
***SET***
</p>
<ul className="env-list">
{Object.entries(envVars).map(([key, value]) => (
<li key={key} className="env-item">
<div className="env-key">{key}</div>
<div className="env-value">
{value === undefined ? '未設定' : value}
{value === undefined ? "未設定" : value}
</div>
</li>
))}
</ul>
<div style={{ marginTop: '20px', padding: '16px', background: '#f8f9fa', borderRadius: '4px' }}>
<div
style={{
marginTop: "20px",
padding: "16px",
background: "#f8f9fa",
borderRadius: "4px",
}}
>
<h4></h4>
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
<p
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
}}
>
.envファイルを編集するか
</p>
@ -709,4 +892,4 @@ function App() {
);
}
export default App;
export default App;

View File

@ -5,8 +5,8 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@ -24,7 +24,7 @@ body {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
@ -41,7 +41,7 @@ body {
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
overflow: hidden;
}
@ -147,7 +147,7 @@ body {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
@ -275,4 +275,4 @@ body {
.add-feed-form .form-group {
flex: 1;
margin-bottom: 0;
}
}

View File

@ -1,10 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById('root')!).render(
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
);

View File

@ -18,4 +18,4 @@
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
}

View File

@ -7,4 +7,4 @@
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
}

View File

@ -1,9 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
outDir: "dist",
},
})
});

View File

@ -3,7 +3,7 @@ import { serve } from "@hono/node-server";
import { basicAuth } from "hono/basic-auth";
import path from "path";
import { config, validateConfig } from "./services/config.js";
import {
import {
getAllFeedsIncludingInactive,
deleteFeed,
toggleFeedActive,
@ -31,10 +31,13 @@ const app = new Hono();
// Basic Authentication middleware (if credentials are provided)
if (config.admin.username && config.admin.password) {
app.use("*", basicAuth({
username: config.admin.username,
password: config.admin.password,
}));
app.use(
"*",
basicAuth({
username: config.admin.username,
password: config.admin.password,
}),
);
console.log("🔐 Admin panel authentication enabled");
} else {
console.log("⚠️ Admin panel running without authentication");
@ -45,14 +48,16 @@ app.get("/api/admin/env", async (c) => {
try {
const envVars = {
// OpenAI Configuration
OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"] ? "***SET***" : undefined,
OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"]
? "***SET***"
: undefined,
OPENAI_API_ENDPOINT: import.meta.env["OPENAI_API_ENDPOINT"],
OPENAI_MODEL_NAME: import.meta.env["OPENAI_MODEL_NAME"],
// VOICEVOX Configuration
VOICEVOX_HOST: import.meta.env["VOICEVOX_HOST"],
VOICEVOX_STYLE_ID: import.meta.env["VOICEVOX_STYLE_ID"],
// Podcast Configuration
PODCAST_TITLE: import.meta.env["PODCAST_TITLE"],
PODCAST_LINK: import.meta.env["PODCAST_LINK"],
@ -62,16 +67,20 @@ app.get("/api/admin/env", async (c) => {
PODCAST_CATEGORIES: import.meta.env["PODCAST_CATEGORIES"],
PODCAST_TTL: import.meta.env["PODCAST_TTL"],
PODCAST_BASE_URL: import.meta.env["PODCAST_BASE_URL"],
// Admin Configuration
ADMIN_PORT: import.meta.env["ADMIN_PORT"],
ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"] ? "***SET***" : undefined,
ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"] ? "***SET***" : undefined,
ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"]
? "***SET***"
: undefined,
ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"]
? "***SET***"
: undefined,
// File Configuration
FEED_URLS_FILE: import.meta.env["FEED_URLS_FILE"],
};
return c.json(envVars);
} catch (error) {
console.error("Error fetching environment variables:", error);
@ -93,30 +102,34 @@ app.get("/api/admin/feeds", async (c) => {
app.post("/api/admin/feeds", async (c) => {
try {
const { feedUrl } = await c.req.json<{ feedUrl: string }>();
if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) {
if (
!feedUrl ||
typeof feedUrl !== "string" ||
!feedUrl.startsWith("http")
) {
return c.json({ error: "Valid feed URL is required" }, 400);
}
console.log(" Admin adding new feed URL:", feedUrl);
// Check if feed already exists
const existingFeed = await getFeedByUrl(feedUrl);
if (existingFeed) {
return c.json({
result: "EXISTS",
return c.json({
result: "EXISTS",
message: "Feed URL already exists",
feed: existingFeed
feed: existingFeed,
});
}
// Add new feed
await addNewFeedUrl(feedUrl);
return c.json({
result: "CREATED",
return c.json({
result: "CREATED",
message: "Feed URL added successfully",
feedUrl
feedUrl,
});
} catch (error) {
console.error("Error adding feed:", error);
@ -127,20 +140,20 @@ app.post("/api/admin/feeds", async (c) => {
app.delete("/api/admin/feeds/:id", async (c) => {
try {
const feedId = c.req.param("id");
if (!feedId || feedId.trim() === "") {
return c.json({ error: "Feed ID is required" }, 400);
}
console.log("🗑️ Admin deleting feed ID:", feedId);
const deleted = await deleteFeed(feedId);
if (deleted) {
return c.json({
result: "DELETED",
return c.json({
result: "DELETED",
message: "Feed deleted successfully",
feedId
feedId,
});
} else {
return c.json({ error: "Feed not found" }, 404);
@ -155,25 +168,27 @@ app.patch("/api/admin/feeds/:id/toggle", async (c) => {
try {
const feedId = c.req.param("id");
const { active } = await c.req.json<{ active: boolean }>();
if (!feedId || feedId.trim() === "") {
return c.json({ error: "Feed ID is required" }, 400);
}
if (typeof active !== "boolean") {
return c.json({ error: "Active status must be a boolean" }, 400);
}
console.log(`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`);
console.log(
`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`,
);
const updated = await toggleFeedActive(feedId, active);
if (updated) {
return c.json({
result: "UPDATED",
return c.json({
result: "UPDATED",
message: `Feed ${active ? "activated" : "deactivated"} successfully`,
feedId,
active
active,
});
} else {
return c.json({ error: "Feed not found" }, 404);
@ -209,62 +224,85 @@ app.get("/api/admin/episodes/simple", async (c) => {
app.get("/api/admin/db-diagnostic", async (c) => {
try {
const db = new Database(config.paths.dbPath);
// 1. Check episodes table
const episodeCount = db.prepare("SELECT COUNT(*) as count FROM episodes").get() as any;
const episodeCount = db
.prepare("SELECT COUNT(*) as count FROM episodes")
.get() as any;
// 2. Check articles table
const articleCount = db.prepare("SELECT COUNT(*) as count FROM articles").get() as any;
const articleCount = db
.prepare("SELECT COUNT(*) as count FROM articles")
.get() as any;
// 3. Check feeds table
const feedCount = db.prepare("SELECT COUNT(*) as count FROM feeds").get() as any;
const activeFeedCount = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1").get() as any;
const inactiveFeedCount = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 0 OR active IS NULL").get() as any;
const feedCount = db
.prepare("SELECT COUNT(*) as count FROM feeds")
.get() as any;
const activeFeedCount = db
.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1")
.get() as any;
const inactiveFeedCount = db
.prepare(
"SELECT COUNT(*) as count FROM feeds WHERE active = 0 OR active IS NULL",
)
.get() as any;
// 4. Check orphaned episodes
const orphanedEpisodes = db.prepare(`
const orphanedEpisodes = db
.prepare(`
SELECT e.id, e.title, e.article_id
FROM episodes e
LEFT JOIN articles a ON e.article_id = a.id
WHERE a.id IS NULL
`).all() as any[];
`)
.all() as any[];
// 5. Check orphaned articles
const orphanedArticles = db.prepare(`
const orphanedArticles = db
.prepare(`
SELECT a.id, a.title, a.feed_id
FROM articles a
LEFT JOIN feeds f ON a.feed_id = f.id
WHERE f.id IS NULL
`).all() as any[];
`)
.all() as any[];
// 6. Check episodes with articles but feeds are inactive
const episodesInactiveFeeds = db.prepare(`
const episodesInactiveFeeds = db
.prepare(`
SELECT e.id, e.title, f.active, f.title as feed_title
FROM episodes e
JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id
WHERE f.active = 0 OR f.active IS NULL
`).all() as any[];
`)
.all() as any[];
// 7. Test the JOIN query
const joinResult = db.prepare(`
const joinResult = db
.prepare(`
SELECT COUNT(*) as count
FROM episodes e
JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id
WHERE f.active = 1
`).get() as any;
`)
.get() as any;
// 8. Sample feed details
const sampleFeeds = db.prepare(`
const sampleFeeds = db
.prepare(`
SELECT id, title, url, active, created_at
FROM feeds
ORDER BY created_at DESC
LIMIT 5
`).all() as any[];
`)
.all() as any[];
// 9. Sample episode-article-feed chain
const sampleChain = db.prepare(`
const sampleChain = db
.prepare(`
SELECT
e.id as episode_id, e.title as episode_title,
a.id as article_id, a.title as article_title,
@ -274,10 +312,11 @@ app.get("/api/admin/db-diagnostic", async (c) => {
LEFT JOIN feeds f ON a.feed_id = f.id
ORDER BY e.created_at DESC
LIMIT 5
`).all() as any[];
`)
.all() as any[];
db.close();
const diagnosticResult = {
counts: {
episodes: episodeCount.count,
@ -305,11 +344,14 @@ app.get("/api/admin/db-diagnostic", async (c) => {
},
timestamp: new Date().toISOString(),
};
return c.json(diagnosticResult);
} catch (error) {
console.error("Error running database diagnostic:", error);
return c.json({ error: "Failed to run database diagnostic", details: error.message }, 500);
return c.json(
{ error: "Failed to run database diagnostic", details: error.message },
500,
);
}
});
@ -330,37 +372,37 @@ app.patch("/api/admin/feed-requests/:id/approve", async (c) => {
const requestId = c.req.param("id");
const body = await c.req.json();
const { adminNotes } = body;
// First get the request to get the URL
const requests = await getFeedRequests();
const request = requests.find(r => r.id === requestId);
const request = requests.find((r) => r.id === requestId);
if (!request) {
return c.json({ error: "Feed request not found" }, 404);
}
if (request.status !== 'pending') {
if (request.status !== "pending") {
return c.json({ error: "Feed request already processed" }, 400);
}
// Add the feed
await addNewFeedUrl(request.url);
// Update request status
const updated = await updateFeedRequestStatus(
requestId,
'approved',
'admin',
adminNotes
requestId,
"approved",
"admin",
adminNotes,
);
if (!updated) {
return c.json({ error: "Failed to update request status" }, 500);
}
return c.json({
success: true,
message: "Feed request approved and feed added successfully"
return c.json({
success: true,
message: "Feed request approved and feed added successfully",
});
} catch (error) {
console.error("Error approving feed request:", error);
@ -373,21 +415,21 @@ app.patch("/api/admin/feed-requests/:id/reject", async (c) => {
const requestId = c.req.param("id");
const body = await c.req.json();
const { adminNotes } = body;
const updated = await updateFeedRequestStatus(
requestId,
'rejected',
'admin',
adminNotes
requestId,
"rejected",
"admin",
adminNotes,
);
if (!updated) {
return c.json({ error: "Feed request not found" }, 404);
}
return c.json({
success: true,
message: "Feed request rejected successfully"
return c.json({
success: true,
message: "Feed request rejected successfully",
});
} catch (error) {
console.error("Error rejecting feed request:", error);
@ -409,10 +451,10 @@ app.get("/api/admin/batch/status", async (c) => {
app.post("/api/admin/batch/enable", async (c) => {
try {
batchScheduler.enable();
return c.json({
success: true,
return c.json({
success: true,
message: "Batch scheduler enabled successfully",
status: batchScheduler.getStatus()
status: batchScheduler.getStatus(),
});
} catch (error) {
console.error("Error enabling batch scheduler:", error);
@ -423,10 +465,10 @@ app.post("/api/admin/batch/enable", async (c) => {
app.post("/api/admin/batch/disable", async (c) => {
try {
batchScheduler.disable();
return c.json({
success: true,
return c.json({
success: true,
message: "Batch scheduler disabled successfully",
status: batchScheduler.getStatus()
status: batchScheduler.getStatus(),
});
} catch (error) {
console.error("Error disabling batch scheduler:", error);
@ -441,13 +483,14 @@ app.get("/api/admin/stats", async (c) => {
const episodes = await fetchAllEpisodes();
const feedRequests = await getFeedRequests();
const batchStatus = batchScheduler.getStatus();
const stats = {
totalFeeds: feeds.length,
activeFeeds: feeds.filter(f => f.active).length,
inactiveFeeds: feeds.filter(f => !f.active).length,
activeFeeds: feeds.filter((f) => f.active).length,
inactiveFeeds: feeds.filter((f) => !f.active).length,
totalEpisodes: episodes.length,
pendingRequests: feedRequests.filter(r => r.status === 'pending').length,
pendingRequests: feedRequests.filter((r) => r.status === "pending")
.length,
totalRequests: feedRequests.length,
batchScheduler: {
enabled: batchStatus.enabled,
@ -459,7 +502,7 @@ app.get("/api/admin/stats", async (c) => {
adminPort: config.admin.port,
authEnabled: !!(config.admin.username && config.admin.password),
};
return c.json(stats);
} catch (error) {
console.error("Error fetching admin stats:", error);
@ -470,16 +513,16 @@ app.get("/api/admin/stats", async (c) => {
app.post("/api/admin/batch/trigger", async (c) => {
try {
console.log("🚀 Manual batch process triggered via admin panel");
// Use the batch scheduler's manual trigger method
batchScheduler.triggerManualRun().catch(error => {
batchScheduler.triggerManualRun().catch((error) => {
console.error("❌ Manual admin batch process failed:", error);
});
return c.json({
return c.json({
result: "TRIGGERED",
message: "Batch process started in background",
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("Error triggering admin batch process:", error);
@ -490,21 +533,24 @@ app.post("/api/admin/batch/trigger", async (c) => {
app.post("/api/admin/batch/force-stop", async (c) => {
try {
console.log("🛑 Force stop batch process requested via admin panel");
const stopped = batchScheduler.forceStop();
if (stopped) {
return c.json({
return c.json({
result: "STOPPED",
message: "Batch process force stop signal sent",
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
} else {
return c.json({
result: "NO_PROCESS",
message: "No batch process is currently running",
timestamp: new Date().toISOString()
}, 200);
return c.json(
{
result: "NO_PROCESS",
message: "No batch process is currently running",
timestamp: new Date().toISOString(),
},
200,
);
}
} catch (error) {
console.error("Error force stopping batch process:", error);
@ -517,7 +563,7 @@ app.get("/assets/*", async (c) => {
try {
const filePath = path.join(config.paths.adminBuildDir, c.req.path);
const file = Bun.file(filePath);
if (await file.exists()) {
const contentType = filePath.endsWith(".js")
? "application/javascript"
@ -539,12 +585,12 @@ async function serveAdminIndex(c: any) {
try {
const indexPath = path.join(config.paths.adminBuildDir, "index.html");
const file = Bun.file(indexPath);
if (await file.exists()) {
const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
}
// Fallback to simple HTML if admin panel is not built
return c.html(`
<!DOCTYPE html>
@ -606,7 +652,9 @@ serve(
},
(info) => {
console.log(`🔧 Admin panel running on http://localhost:${info.port}`);
console.log(`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`);
console.log(
`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`,
);
console.log(`🗄️ Database: ${config.paths.dbPath}`);
},
);
);

77
biome.json Normal file
View File

@ -0,0 +1,77 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": [
"node_modules",
"dist",
"build",
"data",
"public/podcast_audio",
"frontend/dist",
"admin-panel/dist"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"recommended": true
},
"complexity": {
"recommended": true
},
"correctness": {
"recommended": true
},
"performance": {
"recommended": true
},
"security": {
"recommended": true
},
"style": {
"recommended": true
},
"suspicious": {
"recommended": true
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

View File

@ -7,6 +7,7 @@
"@aws-sdk/client-polly": "^3.823.0",
"@hono/node-server": "^1.14.3",
"@types/xml2js": "^0.4.14",
"cheerio": "^1.0.0",
"ffmpeg-static": "^5.2.0",
"hono": "^4.7.11",
"openai": "^4.104.0",
@ -17,7 +18,9 @@
"xml2js": "^0.6.2",
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest",
"@types/cheerio": "^1.0.0",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
@ -123,6 +126,24 @@
"@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
@ -317,6 +338,8 @@
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
"@types/cheerio": ["@types/cheerio@1.0.0", "", { "dependencies": { "cheerio": "*" } }, "sha512-zAaImHWoh5RY2CLgU2mvg3bl2k3F65B0N5yphuII3ythFLPmJhL7sj1RDu6gSxcgqHlETbr/lhA2OBY+WF1fXQ=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="],
@ -339,6 +362,8 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="],
"browserslist": ["browserslist@4.25.0", "", { "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA=="],
@ -353,6 +378,10 @@
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
@ -361,16 +390,30 @@
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"css-select": ["css-select@5.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg=="],
"css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.162", "", {}, "sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA=="],
"encoding-sniffer": ["encoding-sniffer@0.2.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg=="],
"entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@ -423,12 +466,16 @@
"hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="],
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@ -455,10 +502,18 @@
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
@ -485,6 +540,8 @@
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
@ -509,6 +566,8 @@
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
@ -523,6 +582,10 @@
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
@ -539,10 +602,16 @@
"bun-types/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
"openai/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
"parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
"rss-parser/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],

View File

@ -1,28 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ['dist'] },
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
)
);

View File

@ -1,13 +1,15 @@
import { Routes, Route, Link, useLocation } from 'react-router-dom'
import EpisodeList from './components/EpisodeList'
import FeedManager from './components/FeedManager'
import FeedList from './components/FeedList'
import FeedDetail from './components/FeedDetail'
import EpisodeDetail from './components/EpisodeDetail'
import { Routes, Route, Link, useLocation } from "react-router-dom";
import EpisodeList from "./components/EpisodeList";
import FeedManager from "./components/FeedManager";
import FeedList from "./components/FeedList";
import FeedDetail from "./components/FeedDetail";
import EpisodeDetail from "./components/EpisodeDetail";
function App() {
const location = useLocation()
const isMainPage = ['/', '/feeds', '/feed-requests'].includes(location.pathname)
const location = useLocation();
const isMainPage = ["/", "/feeds", "/feed-requests"].includes(
location.pathname,
);
return (
<div className="container">
@ -15,25 +17,27 @@ function App() {
<>
<div className="header">
<div className="title">Voice RSS Summary</div>
<div className="subtitle">RSS </div>
<div className="subtitle">
RSS
</div>
</div>
<div className="tabs">
<Link
to="/"
className={`tab ${location.pathname === '/' ? 'active' : ''}`}
className={`tab ${location.pathname === "/" ? "active" : ""}`}
>
</Link>
<Link
to="/feeds"
className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
className={`tab ${location.pathname === "/feeds" ? "active" : ""}`}
>
</Link>
<Link
to="/feed-requests"
className={`tab ${location.pathname === '/feed-requests' ? 'active' : ''}`}
className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
>
</Link>
@ -51,7 +55,7 @@ function App() {
</Routes>
</div>
</div>
)
);
}
export default App
export default App;

View File

@ -1,59 +1,58 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
interface EpisodeWithFeedInfo {
id: string
title: string
description?: string
audioPath: string
duration?: number
fileSize?: number
createdAt: string
articleId: string
articleTitle: string
articleLink: string
articlePubDate: string
feedId: string
feedTitle?: string
feedUrl: string
id: string;
title: string;
description?: string;
audioPath: string;
duration?: number;
fileSize?: number;
createdAt: string;
articleId: string;
articleTitle: string;
articleLink: string;
articlePubDate: string;
feedId: string;
feedTitle?: string;
feedUrl: string;
}
function EpisodeDetail() {
const { episodeId } = useParams<{ episodeId: string }>()
const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [useDatabase, setUseDatabase] = useState(true)
const { episodeId } = useParams<{ episodeId: string }>();
const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [useDatabase, setUseDatabase] = useState(true);
useEffect(() => {
fetchEpisode()
}, [episodeId, useDatabase])
fetchEpisode();
}, [episodeId, useDatabase]);
const fetchEpisode = async () => {
if (!episodeId) return
if (!episodeId) return;
try {
setLoading(true)
setLoading(true);
if (useDatabase) {
// Try to fetch from database with source info first
const response = await fetch(`/api/episode-with-source/${episodeId}`)
const response = await fetch(`/api/episode-with-source/${episodeId}`);
if (!response.ok) {
throw new Error('データベースからの取得に失敗しました')
throw new Error("データベースからの取得に失敗しました");
}
const data = await response.json()
setEpisode(data.episode)
const data = await response.json();
setEpisode(data.episode);
} else {
// Fallback to XML parsing (existing functionality)
const response = await fetch(`/api/episode/${episodeId}`)
const response = await fetch(`/api/episode/${episodeId}`);
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
const errorData = await response.json();
throw new Error(errorData.error || "エピソードの取得に失敗しました");
}
const data = await response.json()
const xmlEpisode = data.episode
const data = await response.json();
const xmlEpisode = data.episode;
// Convert XML episode to EpisodeWithFeedInfo format
const convertedEpisode: EpisodeWithFeedInfo = {
id: xmlEpisode.id,
@ -65,128 +64,146 @@ function EpisodeDetail() {
articleTitle: xmlEpisode.title,
articleLink: xmlEpisode.link,
articlePubDate: xmlEpisode.pubDate,
feedId: '',
feedTitle: 'RSS Feed',
feedUrl: ''
}
setEpisode(convertedEpisode)
feedId: "",
feedTitle: "RSS Feed",
feedUrl: "",
};
setEpisode(convertedEpisode);
}
} catch (err) {
console.error('Episode fetch error:', err)
console.error("Episode fetch error:", err);
if (useDatabase) {
// Fallback to XML if database fails
console.log('Falling back to XML parsing...')
setUseDatabase(false)
return
console.log("Falling back to XML parsing...");
setUseDatabase(false);
return;
}
setError(err instanceof Error ? err.message : 'エラーが発生しました')
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false)
setLoading(false);
}
}
};
const shareEpisode = () => {
const shareUrl = window.location.href
navigator.clipboard.writeText(shareUrl).then(() => {
alert('エピソードリンクをクリップボードにコピーしました')
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
})
}
const shareUrl = window.location.href;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
alert("エピソードリンクをクリップボードにコピーしました");
})
.catch(() => {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert("エピソードリンクをクリップボードにコピーしました");
});
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP')
}
return new Date(dateString).toLocaleString("ja-JP");
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const units = ['B', 'KB', 'MB', 'GB']
let unitIndex = 0
let fileSize = bytes
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let unitIndex = 0;
let fileSize = bytes;
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024
unitIndex++
fileSize /= 1024;
unitIndex++;
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
};
if (loading) {
return (
<div className="container">
<div className="loading">...</div>
</div>
)
);
}
if (error) {
return (
<div className="container">
<div className="error">{error}</div>
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
<Link
to="/"
className="btn btn-secondary"
style={{ marginTop: "20px" }}
>
</Link>
</div>
)
);
}
if (!episode) {
return (
<div className="container">
<div className="error"></div>
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
<Link
to="/"
className="btn btn-secondary"
style={{ marginTop: "20px" }}
>
</Link>
</div>
)
);
}
return (
<div className="container">
<div className="header">
<Link to="/" className="btn btn-secondary" style={{ marginBottom: '20px' }}>
<Link
to="/"
className="btn btn-secondary"
style={{ marginBottom: "20px" }}
>
</Link>
<div className="title" style={{ fontSize: '28px', marginBottom: '10px' }}>
<div
className="title"
style={{ fontSize: "28px", marginBottom: "10px" }}
>
{episode.title}
</div>
<div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
<div
className="subtitle"
style={{ color: "#666", marginBottom: "20px" }}
>
: {formatDate(episode.createdAt)}
</div>
</div>
<div className="content">
<div style={{ marginBottom: '30px' }}>
<div style={{ marginBottom: "30px" }}>
<audio
controls
className="audio-player"
src={episode.audioPath}
style={{ width: '100%', height: '60px' }}
style={{ width: "100%", height: "60px" }}
>
使
</audio>
</div>
<div style={{ display: 'flex', gap: '15px', marginBottom: '30px' }}>
<button
className="btn btn-primary"
onClick={shareEpisode}
>
<div style={{ display: "flex", gap: "15px", marginBottom: "30px" }}>
<button className="btn btn-primary" onClick={shareEpisode}>
</button>
{episode.articleLink && (
<a
href={episode.articleLink}
target="_blank"
<a
href={episode.articleLink}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
>
@ -194,62 +211,77 @@ function EpisodeDetail() {
</a>
)}
{episode.feedId && (
<Link
to={`/feeds/${episode.feedId}`}
className="btn btn-secondary"
>
<Link to={`/feeds/${episode.feedId}`} className="btn btn-secondary">
</Link>
)}
</div>
<div style={{ marginBottom: '30px' }}>
<h3 style={{ marginBottom: '15px' }}></h3>
<div style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
fontSize: '14px'
}}>
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginBottom: "15px" }}></h3>
<div
style={{
backgroundColor: "#f8f9fa",
padding: "20px",
borderRadius: "8px",
fontSize: "14px",
}}
>
{episode.feedTitle && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong>
<Link to={`/feeds/${episode.feedId}`} style={{ marginLeft: '5px', color: '#007bff' }}>
<div style={{ marginBottom: "10px" }}>
<strong>:</strong>
<Link
to={`/feeds/${episode.feedId}`}
style={{ marginLeft: "5px", color: "#007bff" }}
>
{episode.feedTitle}
</Link>
</div>
)}
{episode.feedUrl && (
<div style={{ marginBottom: '10px' }}>
<strong>URL:</strong>
<a href={episode.feedUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
<div style={{ marginBottom: "10px" }}>
<strong>URL:</strong>
<a
href={episode.feedUrl}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: "5px" }}
>
{episode.feedUrl}
</a>
</div>
)}
{episode.articleTitle && episode.articleTitle !== episode.title && (
<div style={{ marginBottom: '10px' }}>
<div style={{ marginBottom: "10px" }}>
<strong>:</strong> {episode.articleTitle}
</div>
)}
{episode.articlePubDate && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {formatDate(episode.articlePubDate)}
<div style={{ marginBottom: "10px" }}>
<strong>:</strong>{" "}
{formatDate(episode.articlePubDate)}
</div>
)}
{episode.fileSize && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {formatFileSize(episode.fileSize)}
<div style={{ marginBottom: "10px" }}>
<strong>:</strong>{" "}
{formatFileSize(episode.fileSize)}
</div>
)}
{episode.duration && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {Math.floor(episode.duration / 60)}{episode.duration % 60}
<div style={{ marginBottom: "10px" }}>
<strong>:</strong> {Math.floor(episode.duration / 60)}
{episode.duration % 60}
</div>
)}
<div>
<strong>URL:</strong>
<a href={episode.audioPath} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
<strong>URL:</strong>
<a
href={episode.audioPath}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: "5px" }}
>
</a>
</div>
@ -258,21 +290,23 @@ function EpisodeDetail() {
{episode.description && (
<div>
<h3 style={{ marginBottom: '15px' }}></h3>
<div style={{
backgroundColor: '#fff',
padding: '20px',
border: '1px solid #e9ecef',
borderRadius: '8px',
lineHeight: '1.6'
}}>
<h3 style={{ marginBottom: "15px" }}></h3>
<div
style={{
backgroundColor: "#fff",
padding: "20px",
border: "1px solid #e9ecef",
borderRadius: "8px",
lineHeight: "1.6",
}}
>
{episode.description}
</div>
</div>
)}
</div>
</div>
)
);
}
export default EpisodeDetail
export default EpisodeDetail;

View File

@ -1,186 +1,202 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
interface Episode {
id: string
title: string
description: string
pubDate: string
audioUrl: string
audioLength: string
guid: string
link: string
id: string;
title: string;
description: string;
pubDate: string;
audioUrl: string;
audioLength: string;
guid: string;
link: string;
}
interface EpisodeWithFeedInfo {
id: string
title: string
description?: string
audioPath: string
duration?: number
fileSize?: number
createdAt: string
articleId: string
articleTitle: string
articleLink: string
articlePubDate: string
feedId: string
feedTitle?: string
feedUrl: string
id: string;
title: string;
description?: string;
audioPath: string;
duration?: number;
fileSize?: number;
createdAt: string;
articleId: string;
articleTitle: string;
articleLink: string;
articlePubDate: string;
feedId: string;
feedTitle?: string;
feedUrl: string;
}
function EpisodeList() {
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentAudio, setCurrentAudio] = useState<string | null>(null)
const [useDatabase, setUseDatabase] = useState(true)
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
const [useDatabase, setUseDatabase] = useState(true);
useEffect(() => {
fetchEpisodes()
}, [useDatabase])
fetchEpisodes();
}, [useDatabase]);
const fetchEpisodes = async () => {
try {
setLoading(true)
setError(null)
setLoading(true);
setError(null);
if (useDatabase) {
// Try to fetch from database first
const response = await fetch('/api/episodes-with-feed-info')
const response = await fetch("/api/episodes-with-feed-info");
if (!response.ok) {
throw new Error('データベースからの取得に失敗しました')
throw new Error("データベースからの取得に失敗しました");
}
const data = await response.json()
const dbEpisodes = data.episodes || []
const data = await response.json();
const dbEpisodes = data.episodes || [];
if (dbEpisodes.length === 0) {
// Database is empty, fallback to XML
console.log('Database is empty, falling back to XML parsing...')
setUseDatabase(false)
return
console.log("Database is empty, falling back to XML parsing...");
setUseDatabase(false);
return;
}
setEpisodes(dbEpisodes)
setEpisodes(dbEpisodes);
} else {
// Use XML parsing as primary source
const response = await fetch('/api/episodes-from-xml')
const response = await fetch("/api/episodes-from-xml");
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
const errorData = await response.json();
throw new Error(errorData.error || "エピソードの取得に失敗しました");
}
const data = await response.json()
console.log('Fetched episodes from XML:', data)
const data = await response.json();
console.log("Fetched episodes from XML:", data);
// Convert XML episodes to EpisodeWithFeedInfo format
const xmlEpisodes = data.episodes || []
const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map((episode: Episode) => ({
id: episode.id,
title: episode.title,
description: episode.description,
audioPath: episode.audioUrl,
createdAt: episode.pubDate,
articleId: episode.guid,
articleTitle: episode.title,
articleLink: episode.link,
articlePubDate: episode.pubDate,
feedId: '',
feedTitle: 'RSS Feed',
feedUrl: ''
}))
setEpisodes(convertedEpisodes)
const xmlEpisodes = data.episodes || [];
const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map(
(episode: Episode) => ({
id: episode.id,
title: episode.title,
description: episode.description,
audioPath: episode.audioUrl,
createdAt: episode.pubDate,
articleId: episode.guid,
articleTitle: episode.title,
articleLink: episode.link,
articlePubDate: episode.pubDate,
feedId: "",
feedTitle: "RSS Feed",
feedUrl: "",
}),
);
setEpisodes(convertedEpisodes);
}
} catch (err) {
console.error('Episode fetch error:', err)
console.error("Episode fetch error:", err);
if (useDatabase) {
// Fallback to XML if database fails
console.log('Falling back to XML parsing...')
setUseDatabase(false)
return
console.log("Falling back to XML parsing...");
setUseDatabase(false);
return;
}
setError(err instanceof Error ? err.message : 'エラーが発生しました')
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false)
setLoading(false);
}
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP')
}
return new Date(dateString).toLocaleString("ja-JP");
};
const playAudio = (audioPath: string) => {
if (currentAudio) {
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
const currentPlayer = document.getElementById(
currentAudio,
) as HTMLAudioElement;
if (currentPlayer) {
currentPlayer.pause()
currentPlayer.currentTime = 0
currentPlayer.pause();
currentPlayer.currentTime = 0;
}
}
setCurrentAudio(audioPath)
}
setCurrentAudio(audioPath);
};
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
const shareUrl = `${window.location.origin}/episode/${episode.id}`
navigator.clipboard.writeText(shareUrl).then(() => {
alert('エピソードリンクをクリップボードにコピーしました')
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
})
}
const shareUrl = `${window.location.origin}/episode/${episode.id}`;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
alert("エピソードリンクをクリップボードにコピーしました");
})
.catch(() => {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert("エピソードリンクをクリップボードにコピーしました");
});
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const units = ['B', 'KB', 'MB', 'GB']
let unitIndex = 0
let fileSize = bytes
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let unitIndex = 0;
let fileSize = bytes;
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024
unitIndex++
fileSize /= 1024;
unitIndex++;
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
};
if (loading) {
return <div className="loading">...</div>
return <div className="loading">...</div>;
}
if (error) {
return <div className="error">{error}</div>
return <div className="error">{error}</div>;
}
if (episodes.length === 0) {
return (
<div className="empty-state">
<p></p>
<p>RSSフィードをリクエストするか</p>
<button
className="btn btn-secondary"
<p>
RSSフィードをリクエストするか
</p>
<button
className="btn btn-secondary"
onClick={fetchEpisodes}
style={{ marginTop: '10px' }}
style={{ marginTop: "10px" }}
>
</button>
</div>
)
);
}
return (
<div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({episodes.length})</h2>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: '#666' }}>
: {useDatabase ? 'データベース' : 'XML'}
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<span style={{ fontSize: "12px", color: "#666" }}>
: {useDatabase ? "データベース" : "XML"}
</span>
<button className="btn btn-secondary" onClick={fetchEpisodes}>
@ -191,74 +207,107 @@ function EpisodeList() {
<table className="table">
<thead>
<tr>
<th style={{ width: '35%' }}></th>
<th style={{ width: '25%' }}></th>
<th style={{ width: '15%' }}></th>
<th style={{ width: '25%' }}></th>
<th style={{ width: "35%" }}></th>
<th style={{ width: "25%" }}></th>
<th style={{ width: "15%" }}></th>
<th style={{ width: "25%" }}></th>
</tr>
</thead>
<tbody>
{episodes.map((episode) => (
<tr key={episode.id}>
<td>
<div style={{ marginBottom: '8px' }}>
<div style={{ marginBottom: "8px" }}>
<strong>
<Link
<Link
to={`/episode/${episode.id}`}
style={{ textDecoration: 'none', color: '#007bff' }}
style={{ textDecoration: "none", color: "#007bff" }}
>
{episode.title}
</Link>
</strong>
</div>
{episode.feedTitle && (
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
: <Link to={`/feeds/${episode.feedId}`} style={{ color: '#007bff' }}>{episode.feedTitle}</Link>
</div>
)}
{episode.articleTitle && episode.articleTitle !== episode.title && (
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
: <strong>{episode.articleTitle}</strong>
<div
style={{
fontSize: "12px",
color: "#666",
marginBottom: "4px",
}}
>
:{" "}
<Link
to={`/feeds/${episode.feedId}`}
style={{ color: "#007bff" }}
>
{episode.feedTitle}
</Link>
</div>
)}
{episode.articleTitle &&
episode.articleTitle !== episode.title && (
<div
style={{
fontSize: "12px",
color: "#666",
marginBottom: "4px",
}}
>
: <strong>{episode.articleTitle}</strong>
</div>
)}
{episode.articleLink && (
<a
href={episode.articleLink}
target="_blank"
<a
href={episode.articleLink}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px', color: '#666' }}
style={{ fontSize: "12px", color: "#666" }}
>
</a>
)}
</td>
<td>
<div style={{
fontSize: '14px',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{episode.description || 'No description'}
<div
style={{
fontSize: "14px",
maxWidth: "200px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{episode.description || "No description"}
</div>
{episode.fileSize && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
<div
style={{
fontSize: "12px",
color: "#666",
marginTop: "4px",
}}
>
{formatFileSize(episode.fileSize)}
</div>
)}
</td>
<td>{formatDate(episode.createdAt)}</td>
<td>
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
<div
style={{
display: "flex",
gap: "8px",
flexDirection: "column",
}}
>
<div style={{ display: "flex", gap: "8px" }}>
<button
className="btn btn-primary"
onClick={() => playAudio(episode.audioPath)}
>
</button>
<button
<button
className="btn btn-secondary"
onClick={() => shareEpisode(episode)}
>
@ -283,7 +332,7 @@ function EpisodeList() {
</tbody>
</table>
</div>
)
);
}
export default EpisodeList
export default EpisodeList;

View File

@ -1,180 +1,198 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
interface Feed {
id: string
url: string
title?: string
description?: string
lastUpdated?: string
createdAt: string
active: boolean
id: string;
url: string;
title?: string;
description?: string;
lastUpdated?: string;
createdAt: string;
active: boolean;
}
interface EpisodeWithFeedInfo {
id: string
title: string
description?: string
audioPath: string
duration?: number
fileSize?: number
createdAt: string
articleId: string
articleTitle: string
articleLink: string
articlePubDate: string
feedId: string
feedTitle?: string
feedUrl: string
id: string;
title: string;
description?: string;
audioPath: string;
duration?: number;
fileSize?: number;
createdAt: string;
articleId: string;
articleTitle: string;
articleLink: string;
articlePubDate: string;
feedId: string;
feedTitle?: string;
feedUrl: string;
}
function FeedDetail() {
const { feedId } = useParams<{ feedId: string }>()
const [feed, setFeed] = useState<Feed | null>(null)
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentAudio, setCurrentAudio] = useState<string | null>(null)
const { feedId } = useParams<{ feedId: string }>();
const [feed, setFeed] = useState<Feed | null>(null);
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
useEffect(() => {
if (feedId) {
fetchFeedAndEpisodes()
fetchFeedAndEpisodes();
}
}, [feedId])
}, [feedId]);
const fetchFeedAndEpisodes = async () => {
try {
setLoading(true)
setLoading(true);
// Fetch feed info and episodes in parallel
const [feedResponse, episodesResponse] = await Promise.all([
fetch(`/api/feeds/${feedId}`),
fetch(`/api/feeds/${feedId}/episodes`)
])
fetch(`/api/feeds/${feedId}/episodes`),
]);
if (!feedResponse.ok) {
const errorData = await feedResponse.json()
throw new Error(errorData.error || 'フィード情報の取得に失敗しました')
const errorData = await feedResponse.json();
throw new Error(errorData.error || "フィード情報の取得に失敗しました");
}
if (!episodesResponse.ok) {
const errorData = await episodesResponse.json()
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
const errorData = await episodesResponse.json();
throw new Error(errorData.error || "エピソードの取得に失敗しました");
}
const feedData = await feedResponse.json()
const episodesData = await episodesResponse.json()
const feedData = await feedResponse.json();
const episodesData = await episodesResponse.json();
setFeed(feedData.feed)
setEpisodes(episodesData.episodes || [])
setFeed(feedData.feed);
setEpisodes(episodesData.episodes || []);
} catch (err) {
console.error('Feed detail fetch error:', err)
setError(err instanceof Error ? err.message : 'エラーが発生しました')
console.error("Feed detail fetch error:", err);
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false)
setLoading(false);
}
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP')
}
return new Date(dateString).toLocaleString("ja-JP");
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const units = ['B', 'KB', 'MB', 'GB']
let unitIndex = 0
let fileSize = bytes
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let unitIndex = 0;
let fileSize = bytes;
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024
unitIndex++
fileSize /= 1024;
unitIndex++;
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
};
const playAudio = (audioPath: string) => {
if (currentAudio) {
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
const currentPlayer = document.getElementById(
currentAudio,
) as HTMLAudioElement;
if (currentPlayer) {
currentPlayer.pause()
currentPlayer.currentTime = 0
currentPlayer.pause();
currentPlayer.currentTime = 0;
}
}
setCurrentAudio(audioPath)
}
setCurrentAudio(audioPath);
};
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
const shareUrl = `${window.location.origin}/episode/${episode.id}`
navigator.clipboard.writeText(shareUrl).then(() => {
alert('エピソードリンクをクリップボードにコピーしました')
}).catch(() => {
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
})
}
const shareUrl = `${window.location.origin}/episode/${episode.id}`;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
alert("エピソードリンクをクリップボードにコピーしました");
})
.catch(() => {
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert("エピソードリンクをクリップボードにコピーしました");
});
};
if (loading) {
return <div className="loading">...</div>
return <div className="loading">...</div>;
}
if (error) {
return (
<div className="error">
{error}
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
<Link
to="/feeds"
className="btn btn-secondary"
style={{ marginTop: "20px", display: "block" }}
>
</Link>
</div>
)
);
}
if (!feed) {
return (
<div className="error">
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
<Link
to="/feeds"
className="btn btn-secondary"
style={{ marginTop: "20px", display: "block" }}
>
</Link>
</div>
)
);
}
return (
<div>
<div style={{ marginBottom: '20px' }}>
<div style={{ marginBottom: "20px" }}>
<Link to="/feeds" className="btn btn-secondary">
</Link>
</div>
<div className="feed-header" style={{ marginBottom: '30px' }}>
<h1 style={{ marginBottom: '10px' }}>
{feed.title || feed.url}
</h1>
<div style={{ color: '#666', marginBottom: '10px' }}>
<div className="feed-header" style={{ marginBottom: "30px" }}>
<h1 style={{ marginBottom: "10px" }}>{feed.title || feed.url}</h1>
<div style={{ color: "#666", marginBottom: "10px" }}>
<a href={feed.url} target="_blank" rel="noopener noreferrer">
{feed.url}
</a>
</div>
{feed.description && (
<div style={{ marginBottom: '15px', color: '#333' }}>
<div style={{ marginBottom: "15px", color: "#333" }}>
{feed.description}
</div>
)}
<div style={{ fontSize: '14px', color: '#666' }}>
<div style={{ fontSize: "14px", color: "#666" }}>
: {formatDate(feed.createdAt)}
{feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`}
</div>
</div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({episodes.length})</h2>
<button className="btn btn-secondary" onClick={fetchFeedAndEpisodes}>
@ -190,67 +208,87 @@ function FeedDetail() {
<table className="table">
<thead>
<tr>
<th style={{ width: '35%' }}></th>
<th style={{ width: '25%' }}></th>
<th style={{ width: '15%' }}></th>
<th style={{ width: '25%' }}></th>
<th style={{ width: "35%" }}></th>
<th style={{ width: "25%" }}></th>
<th style={{ width: "15%" }}></th>
<th style={{ width: "25%" }}></th>
</tr>
</thead>
<tbody>
{episodes.map((episode) => (
<tr key={episode.id}>
<td>
<div style={{ marginBottom: '8px' }}>
<div style={{ marginBottom: "8px" }}>
<strong>
<Link
<Link
to={`/episode/${episode.id}`}
style={{ textDecoration: 'none', color: '#007bff' }}
style={{ textDecoration: "none", color: "#007bff" }}
>
{episode.title}
</Link>
</strong>
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
<div
style={{
fontSize: "12px",
color: "#666",
marginBottom: "4px",
}}
>
: <strong>{episode.articleTitle}</strong>
</div>
{episode.articleLink && (
<a
href={episode.articleLink}
target="_blank"
<a
href={episode.articleLink}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px', color: '#666' }}
style={{ fontSize: "12px", color: "#666" }}
>
</a>
)}
</td>
<td>
<div style={{
fontSize: '14px',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{episode.description || 'No description'}
<div
style={{
fontSize: "14px",
maxWidth: "200px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{episode.description || "No description"}
</div>
{episode.fileSize && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
<div
style={{
fontSize: "12px",
color: "#666",
marginTop: "4px",
}}
>
{formatFileSize(episode.fileSize)}
</div>
)}
</td>
<td>{formatDate(episode.createdAt)}</td>
<td>
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
<div
style={{
display: "flex",
gap: "8px",
flexDirection: "column",
}}
>
<div style={{ display: "flex", gap: "8px" }}>
<button
className="btn btn-primary"
onClick={() => playAudio(episode.audioPath)}
>
</button>
<button
<button
className="btn btn-secondary"
onClick={() => shareEpisode(episode)}
>
@ -276,7 +314,7 @@ function FeedDetail() {
</table>
)}
</div>
)
);
}
export default FeedDetail
export default FeedDetail;

View File

@ -1,74 +1,83 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
interface Feed {
id: string
url: string
title?: string
description?: string
lastUpdated?: string
createdAt: string
active: boolean
id: string;
url: string;
title?: string;
description?: string;
lastUpdated?: string;
createdAt: string;
active: boolean;
}
function FeedList() {
const [feeds, setFeeds] = useState<Feed[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [feeds, setFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchFeeds()
}, [])
fetchFeeds();
}, []);
const fetchFeeds = async () => {
try {
setLoading(true)
const response = await fetch('/api/feeds')
setLoading(true);
const response = await fetch("/api/feeds");
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'フィードの取得に失敗しました')
const errorData = await response.json();
throw new Error(errorData.error || "フィードの取得に失敗しました");
}
const data = await response.json()
setFeeds(data.feeds || [])
const data = await response.json();
setFeeds(data.feeds || []);
} catch (err) {
console.error('Feed fetch error:', err)
setError(err instanceof Error ? err.message : 'エラーが発生しました')
console.error("Feed fetch error:", err);
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false)
setLoading(false);
}
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP')
}
return new Date(dateString).toLocaleString("ja-JP");
};
if (loading) {
return <div className="loading">...</div>
return <div className="loading">...</div>;
}
if (error) {
return <div className="error">{error}</div>
return <div className="error">{error}</div>;
}
if (feeds.length === 0) {
return (
<div className="empty-state">
<p></p>
<p>RSSフィードをリクエストするか</p>
<button
className="btn btn-secondary"
<p>
RSSフィードをリクエストするか
</p>
<button
className="btn btn-secondary"
onClick={fetchFeeds}
style={{ marginTop: '10px' }}
style={{ marginTop: "10px" }}
>
</button>
</div>
)
);
}
return (
<div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({feeds.length})</h2>
<button className="btn btn-secondary" onClick={fetchFeeds}>
@ -90,13 +99,11 @@ function FeedList() {
</a>
</div>
</div>
{feed.description && (
<div className="feed-description">
{feed.description}
</div>
<div className="feed-description">{feed.description}</div>
)}
<div className="feed-meta">
<div>: {formatDate(feed.createdAt)}</div>
{feed.lastUpdated && (
@ -182,7 +189,7 @@ function FeedList() {
}
`}</style>
</div>
)
);
}
export default FeedList
export default FeedList;

View File

@ -1,58 +1,60 @@
import { useState } from 'react'
import { useState } from "react";
function FeedManager() {
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [newFeedUrl, setNewFeedUrl] = useState('')
const [requestMessage, setRequestMessage] = useState('')
const [requesting, setRequesting] = useState(false)
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [newFeedUrl, setNewFeedUrl] = useState("");
const [requestMessage, setRequestMessage] = useState("");
const [requesting, setRequesting] = useState(false);
const submitRequest = async (e: React.FormEvent) => {
e.preventDefault()
if (!newFeedUrl.trim()) return
e.preventDefault();
if (!newFeedUrl.trim()) return;
try {
setRequesting(true)
setError(null)
setSuccess(null)
setRequesting(true);
setError(null);
setSuccess(null);
const response = await fetch('/api/feed-requests', {
method: 'POST',
const response = await fetch("/api/feed-requests", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
body: JSON.stringify({
url: newFeedUrl.trim(),
requestMessage: requestMessage.trim() || undefined
requestMessage: requestMessage.trim() || undefined,
}),
})
});
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'リクエストの送信に失敗しました')
const errorData = await response.json();
throw new Error(errorData.error || "リクエストの送信に失敗しました");
}
setSuccess('フィードリクエストを送信しました。管理者の承認をお待ちください。')
setNewFeedUrl('')
setRequestMessage('')
setSuccess(
"フィードリクエストを送信しました。管理者の承認をお待ちください。",
);
setNewFeedUrl("");
setRequestMessage("");
} catch (err) {
setError(err instanceof Error ? err.message : 'エラーが発生しました')
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setRequesting(false)
setRequesting(false);
}
}
};
return (
<div>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<div style={{ marginBottom: '30px' }}>
<div style={{ marginBottom: "30px" }}>
<h2></h2>
<p style={{ color: '#666', marginBottom: '20px' }}>
<p style={{ color: "#666", marginBottom: "20px" }}>
RSSフィードのURLを送信してください
</p>
<form onSubmit={submitRequest}>
<div className="form-group">
<label className="form-label">RSS URL *</label>
@ -65,7 +67,7 @@ function FeedManager() {
required
/>
</div>
<div className="form-group">
<label className="form-label"></label>
<textarea
@ -74,31 +76,39 @@ function FeedManager() {
onChange={(e) => setRequestMessage(e.target.value)}
placeholder="このフィードについての説明や追加理由があれば記載してください"
rows={3}
style={{ resize: 'vertical', minHeight: '80px' }}
style={{ resize: "vertical", minHeight: "80px" }}
/>
</div>
<button
type="submit"
<button
type="submit"
className="btn btn-primary"
disabled={requesting}
>
{requesting ? 'リクエスト送信中...' : 'フィードをリクエスト'}
{requesting ? "リクエスト送信中..." : "フィードをリクエスト"}
</button>
</form>
</div>
<div style={{ backgroundColor: '#f8f9fa', padding: '20px', borderRadius: '8px' }}>
<h3 style={{ marginBottom: '15px' }}></h3>
<ul style={{ paddingLeft: '20px', color: '#666' }}>
<div
style={{
backgroundColor: "#f8f9fa",
padding: "20px",
borderRadius: "8px",
}}
>
<h3 style={{ marginBottom: "15px" }}></h3>
<ul style={{ paddingLeft: "20px", color: "#666" }}>
<li></li>
<li>RSSフィードと判断された場合</li>
<li>
RSSフィードと判断された場合
</li>
<li></li>
<li></li>
</ul>
</div>
</div>
)
);
}
export default FeedManager
export default FeedManager;

View File

@ -1,13 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import './styles.css'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
import "./styles.css";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
);

View File

@ -22,7 +22,7 @@ body {
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.title {
@ -41,7 +41,7 @@ body {
background-color: #fff;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tab {
@ -75,7 +75,7 @@ body {
background-color: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.table {
@ -159,7 +159,7 @@ body {
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.audio-player {
@ -222,4 +222,4 @@ body {
.feed-actions {
display: flex;
gap: 10px;
}
}

View File

@ -7,12 +7,19 @@
"build:frontend": "cd frontend && bun vite build",
"build:admin": "cd admin-panel && bun install && bun run build",
"dev:frontend": "cd frontend && bun vite dev",
"dev:admin": "cd admin-panel && bun vite dev"
"dev:admin": "cd admin-panel && bun vite dev",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"check": "biome check .",
"check:fix": "biome check --write ."
},
"dependencies": {
"@aws-sdk/client-polly": "^3.823.0",
"@hono/node-server": "^1.14.3",
"@types/xml2js": "^0.4.14",
"cheerio": "^1.0.0",
"ffmpeg-static": "^5.2.0",
"hono": "^4.7.11",
"openai": "^4.104.0",
@ -24,7 +31,9 @@
},
"type": "module",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest",
"@types/cheerio": "^1.0.0",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",

View File

@ -39,7 +39,7 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
// Check for cancellation at start
if (abortSignal?.aborted) {
throw new Error('Batch process was cancelled before starting');
throw new Error("Batch process was cancelled before starting");
}
// Load feed URLs from file
@ -55,14 +55,17 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
for (const url of feedUrls) {
// Check for cancellation before processing each feed
if (abortSignal?.aborted) {
throw new Error('Batch process was cancelled during feed processing');
throw new Error("Batch process was cancelled during feed processing");
}
try {
await processFeedUrl(url, abortSignal);
} catch (error) {
// Re-throw cancellation errors
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
throw error;
}
console.error(`❌ Failed to process feed ${url}:`, error);
@ -72,7 +75,7 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
// Check for cancellation before processing articles
if (abortSignal?.aborted) {
throw new Error('Batch process was cancelled before article processing');
throw new Error("Batch process was cancelled before article processing");
}
// Process unprocessed articles and generate podcasts
@ -83,10 +86,13 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
new Date().toISOString(),
);
} catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log("🛑 Batch process was cancelled");
const abortError = new Error('Batch process was cancelled');
abortError.name = 'AbortError';
const abortError = new Error("Batch process was cancelled");
abortError.name = "AbortError";
throw abortError;
}
console.error("💥 Batch process failed:", error);
@ -116,14 +122,17 @@ async function loadFeedUrls(): Promise<string[]> {
/**
* Process a single feed URL and discover new articles
*/
async function processFeedUrl(url: string, abortSignal?: AbortSignal): Promise<void> {
async function processFeedUrl(
url: string,
abortSignal?: AbortSignal,
): Promise<void> {
if (!url || !url.startsWith("http")) {
throw new Error(`Invalid feed URL: ${url}`);
}
// Check for cancellation
if (abortSignal?.aborted) {
throw new Error('Feed processing was cancelled');
throw new Error("Feed processing was cancelled");
}
console.log(`🔍 Processing feed: ${url}`);
@ -132,10 +141,10 @@ async function processFeedUrl(url: string, abortSignal?: AbortSignal): Promise<v
// Parse RSS feed
const parser = new Parser<FeedItem>();
const feed = await parser.parseURL(url);
// Check for cancellation after parsing
if (abortSignal?.aborted) {
throw new Error('Feed processing was cancelled');
throw new Error("Feed processing was cancelled");
}
// Get or create feed record
@ -225,13 +234,15 @@ async function discoverNewArticles(
/**
* Process unprocessed articles and generate podcasts
*/
async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<void> {
async function processUnprocessedArticles(
abortSignal?: AbortSignal,
): Promise<void> {
console.log("🎧 Processing unprocessed articles...");
try {
// Check for cancellation
if (abortSignal?.aborted) {
throw new Error('Article processing was cancelled');
throw new Error("Article processing was cancelled");
}
// Process retry queue first
@ -239,7 +250,7 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
// Check for cancellation after retry queue
if (abortSignal?.aborted) {
throw new Error('Article processing was cancelled');
throw new Error("Article processing was cancelled");
}
// Get unprocessed articles (limit to prevent overwhelming)
@ -260,31 +271,44 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
for (const article of unprocessedArticles) {
// Check for cancellation before processing each article
if (abortSignal?.aborted) {
throw new Error('Article processing was cancelled');
throw new Error("Article processing was cancelled");
}
try {
const episodeCreated = await generatePodcastForArticle(article, abortSignal);
const episodeCreated = await generatePodcastForArticle(
article,
abortSignal,
);
// Only mark as processed and update RSS if episode was actually created
if (episodeCreated) {
await markArticleAsProcessed(article.id);
console.log(`✅ Podcast generated for: ${article.title}`);
successfullyGeneratedCount++;
// Update RSS immediately after each successful episode creation
console.log(`📻 Updating podcast RSS after successful episode creation...`);
console.log(
`📻 Updating podcast RSS after successful episode creation...`,
);
try {
await updatePodcastRSS();
console.log(`📻 RSS updated successfully for: ${article.title}`);
} catch (rssError) {
console.error(`❌ Failed to update RSS after episode creation for: ${article.title}`, rssError);
console.error(
`❌ Failed to update RSS after episode creation for: ${article.title}`,
rssError,
);
}
} else {
console.warn(`⚠️ Episode creation failed for: ${article.title} - not marking as processed`);
console.warn(
`⚠️ Episode creation failed for: ${article.title} - not marking as processed`,
);
}
} catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log(`🛑 Article processing cancelled, stopping batch`);
throw error; // Re-throw to propagate cancellation
}
@ -296,7 +320,9 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
}
}
console.log(`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`);
console.log(
`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`,
);
if (successfullyGeneratedCount === 0) {
console.log(` No episodes were successfully created in this batch`);
}
@ -310,15 +336,16 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
* Process retry queue for failed TTS generation
*/
async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js");
const { getQueueItems, updateQueueItemStatus, removeFromQueue } =
await import("../services/database.js");
const { Database } = await import("bun:sqlite");
const db = new Database(config.paths.dbPath);
console.log("🔄 Processing TTS retry queue...");
try {
const queueItems = await getQueueItems(5); // Process 5 items at a time
if (queueItems.length === 0) {
return;
}
@ -328,53 +355,75 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
for (const item of queueItems) {
// Check for cancellation before processing each retry item
if (abortSignal?.aborted) {
throw new Error('Retry queue processing was cancelled');
throw new Error("Retry queue processing was cancelled");
}
try {
console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`);
console.log(
`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`,
);
// Mark as processing
await updateQueueItemStatus(item.id, 'processing');
await updateQueueItemStatus(item.id, "processing");
// Attempt TTS generation without re-queuing on failure
const audioFilePath = await generateTTSWithoutQueue(item.itemId, item.scriptText, item.retryCount);
const audioFilePath = await generateTTSWithoutQueue(
item.itemId,
item.scriptText,
item.retryCount,
);
// Success - remove from queue and update RSS
await removeFromQueue(item.id);
console.log(`✅ TTS retry successful for: ${item.itemId}`);
// Update RSS immediately after successful retry
console.log(`📻 Updating podcast RSS after successful retry...`);
try {
await updatePodcastRSS();
console.log(`📻 RSS updated successfully after retry for: ${item.itemId}`);
console.log(
`📻 RSS updated successfully after retry for: ${item.itemId}`,
);
} catch (rssError) {
console.error(`❌ Failed to update RSS after retry for: ${item.itemId}`, rssError);
console.error(
`❌ Failed to update RSS after retry for: ${item.itemId}`,
rssError,
);
}
} catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log(`🛑 TTS retry processing cancelled for: ${item.itemId}`);
throw error; // Re-throw cancellation errors
}
console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
try {
if (item.retryCount >= 2) {
// Max retries reached, mark as failed
await updateQueueItemStatus(item.id, 'failed');
console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`);
await updateQueueItemStatus(item.id, "failed");
console.log(
`💀 Max retries reached for: ${item.itemId}, marking as failed`,
);
} else {
// Increment retry count and reset to pending for next retry
const updatedRetryCount = item.retryCount + 1;
const stmt = db.prepare("UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?");
const stmt = db.prepare(
"UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?",
);
stmt.run(updatedRetryCount, item.id);
console.log(`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`);
console.log(
`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`,
);
}
} catch (dbError) {
console.error(`❌ Failed to update queue status for: ${item.itemId}`, dbError);
console.error(
`❌ Failed to update queue status for: ${item.itemId}`,
dbError,
);
}
}
}
@ -386,7 +435,10 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
try {
db.close();
} catch (closeError) {
console.warn("⚠️ Warning: Failed to close database connection:", closeError);
console.warn(
"⚠️ Warning: Failed to close database connection:",
closeError,
);
}
}
}
@ -395,13 +447,16 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
* Generate podcast for a single article
* Returns true if episode was successfully created, false otherwise
*/
async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal): Promise<boolean> {
async function generatePodcastForArticle(
article: any,
abortSignal?: AbortSignal,
): Promise<boolean> {
console.log(`🎤 Generating podcast for: ${article.title}`);
try {
// Check for cancellation
if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled');
throw new Error("Podcast generation was cancelled");
}
// Get feed information for context
@ -410,7 +465,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
// Check for cancellation before classification
if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled');
throw new Error("Podcast generation was cancelled");
}
// Classify the article/feed
@ -421,7 +476,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
// Check for cancellation before content generation
if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled');
throw new Error("Podcast generation was cancelled");
}
// Enhance article content with web scraping if needed
@ -430,7 +485,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
article.title,
article.link,
article.content,
article.description
article.description,
);
// Generate podcast content for this single article
@ -442,10 +497,10 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
description: enhancedContent.description,
},
]);
// Check for cancellation before TTS
if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled');
throw new Error("Podcast generation was cancelled");
}
// Generate unique ID for the episode
@ -458,15 +513,20 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
console.log(`🔊 Audio generated: ${audioFilePath}`);
} catch (ttsError) {
console.error(`❌ TTS generation failed for ${article.title}:`, ttsError);
// Check if error indicates item was added to retry queue
const errorMessage = ttsError instanceof Error ? ttsError.message : String(ttsError);
if (errorMessage.includes('added to retry queue')) {
console.log(`📋 Article will be retried later via TTS queue: ${article.title}`);
const errorMessage =
ttsError instanceof Error ? ttsError.message : String(ttsError);
if (errorMessage.includes("added to retry queue")) {
console.log(
`📋 Article will be retried later via TTS queue: ${article.title}`,
);
// Don't mark as processed - leave it for retry
return false;
} else {
console.error(`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`);
console.error(
`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`,
);
// Max retries exceeded, don't create episode but mark as processed to avoid infinite retry
return false;
}
@ -476,11 +536,16 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
try {
const audioStats = await getAudioFileStats(audioFilePath);
if (audioStats.size === 0) {
console.error(`❌ Audio file is empty for ${article.title}: ${audioFilePath}`);
console.error(
`❌ Audio file is empty for ${article.title}: ${audioFilePath}`,
);
return false;
}
} catch (statsError) {
console.error(`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`, statsError);
console.error(
`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`,
statsError,
);
return false;
}
@ -502,11 +567,17 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
console.log(`💾 Episode saved for article: ${article.title}`);
return true;
} catch (saveError) {
console.error(`❌ Failed to save episode for ${article.title}:`, saveError);
console.error(
`❌ Failed to save episode for ${article.title}:`,
saveError,
);
return false;
}
} catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) {
if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log(`🛑 Podcast generation cancelled for: ${article.title}`);
throw error; // Re-throw cancellation errors to stop the batch
}

133
server.ts
View File

@ -22,7 +22,7 @@ app.get("/assets/*", async (c) => {
try {
const filePath = path.join(config.paths.frontendBuildDir, c.req.path);
const file = Bun.file(filePath);
if (await file.exists()) {
const contentType = filePath.endsWith(".js")
? "application/javascript"
@ -42,15 +42,18 @@ app.get("/assets/*", async (c) => {
app.get("/podcast_audio/*", async (c) => {
try {
const audioFileName = c.req.path.substring("/podcast_audio/".length);
// Basic security check
if (audioFileName.includes("..") || audioFileName.includes("/")) {
return c.notFound();
}
const audioFilePath = path.join(config.paths.podcastAudioDir, audioFileName);
const audioFilePath = path.join(
config.paths.podcastAudioDir,
audioFileName,
);
const file = Bun.file(audioFilePath);
if (await file.exists()) {
const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "audio/mpeg" });
@ -66,7 +69,7 @@ app.get("/podcast.xml", async (c) => {
try {
const filePath = path.join(config.paths.publicDir, "podcast.xml");
const file = Bun.file(filePath);
if (await file.exists()) {
const blob = await file.arrayBuffer();
return c.body(blob, 200, {
@ -74,7 +77,7 @@ app.get("/podcast.xml", async (c) => {
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
});
}
console.warn("podcast.xml not found");
return c.notFound();
} catch (error) {
@ -83,18 +86,17 @@ app.get("/podcast.xml", async (c) => {
}
});
// Frontend fallback routes
async function serveIndex(c: any) {
try {
const indexPath = path.join(config.paths.frontendBuildDir, "index.html");
const file = Bun.file(indexPath);
if (await file.exists()) {
const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
}
console.error(`index.html not found at ${indexPath}`);
return c.text("Frontend not built. Run 'bun run build:frontend'", 404);
} catch (error) {
@ -110,7 +112,9 @@ app.get("/index.html", serveIndex);
// API endpoints for frontend
app.get("/api/episodes", async (c) => {
try {
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js");
const { fetchEpisodesWithFeedInfo } = await import(
"./services/database.js"
);
const episodes = await fetchEpisodesWithFeedInfo();
return c.json({ episodes });
} catch (error) {
@ -124,34 +128,34 @@ app.get("/api/episodes-from-xml", async (c) => {
const xml2js = await import("xml2js");
const fs = await import("fs");
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
// Check if podcast.xml exists
if (!fs.existsSync(podcastXmlPath)) {
return c.json({ episodes: [], message: "podcast.xml not found" });
}
// Read and parse XML
const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8');
const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(xmlContent);
const episodes = [];
const items = result?.rss?.channel?.[0]?.item || [];
for (const item of items) {
const episode = {
id: generateEpisodeId(item),
title: item.title?.[0] || 'Untitled',
description: item.description?.[0] || '',
pubDate: item.pubDate?.[0] || '',
audioUrl: item.enclosure?.[0]?.$?.url || '',
audioLength: item.enclosure?.[0]?.$?.length || '0',
guid: item.guid?.[0] || '',
link: item.link?.[0] || ''
title: item.title?.[0] || "Untitled",
description: item.description?.[0] || "",
pubDate: item.pubDate?.[0] || "",
audioUrl: item.enclosure?.[0]?.$?.url || "",
audioLength: item.enclosure?.[0]?.$?.length || "0",
guid: item.guid?.[0] || "",
link: item.link?.[0] || "",
};
episodes.push(episode);
}
return c.json({ episodes });
} catch (error) {
console.error("Error parsing podcast XML:", error);
@ -163,19 +167,20 @@ app.get("/api/episodes-from-xml", async (c) => {
function generateEpisodeId(item: any): string {
// Use GUID if available, otherwise generate from title and audio URL
if (item.guid?.[0]) {
return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, '-'));
return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, "-"));
}
const title = item.title?.[0] || '';
const audioUrl = item.enclosure?.[0]?.$?.url || '';
const titleSlug = title.toLowerCase()
.replace(/[^a-zA-Z0-9\s]/g, '')
.replace(/\s+/g, '-')
const title = item.title?.[0] || "";
const audioUrl = item.enclosure?.[0]?.$?.url || "";
const titleSlug = title
.toLowerCase()
.replace(/[^a-zA-Z0-9\s]/g, "")
.replace(/\s+/g, "-")
.substring(0, 50);
// Extract filename from audio URL as fallback
const audioFilename = audioUrl.split('/').pop()?.split('.')[0] || 'episode';
const audioFilename = audioUrl.split("/").pop()?.split(".")[0] || "episode";
return titleSlug || audioFilename;
}
@ -185,33 +190,35 @@ app.get("/api/episode/:episodeId", async (c) => {
const xml2js = await import("xml2js");
const fs = await import("fs");
const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
if (!fs.existsSync(podcastXmlPath)) {
return c.json({ error: "podcast.xml not found" }, 404);
}
const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8');
const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(xmlContent);
const items = result?.rss?.channel?.[0]?.item || [];
const targetItem = items.find((item: any) => generateEpisodeId(item) === episodeId);
const targetItem = items.find(
(item: any) => generateEpisodeId(item) === episodeId,
);
if (!targetItem) {
return c.json({ error: "Episode not found" }, 404);
}
const episode = {
id: episodeId,
title: targetItem.title?.[0] || 'Untitled',
description: targetItem.description?.[0] || '',
pubDate: targetItem.pubDate?.[0] || '',
audioUrl: targetItem.enclosure?.[0]?.$?.url || '',
audioLength: targetItem.enclosure?.[0]?.$?.length || '0',
guid: targetItem.guid?.[0] || '',
link: targetItem.link?.[0] || ''
title: targetItem.title?.[0] || "Untitled",
description: targetItem.description?.[0] || "",
pubDate: targetItem.pubDate?.[0] || "",
audioUrl: targetItem.enclosure?.[0]?.$?.url || "",
audioLength: targetItem.enclosure?.[0]?.$?.length || "0",
guid: targetItem.guid?.[0] || "",
link: targetItem.link?.[0] || "",
};
return c.json({ episode });
} catch (error) {
console.error("Error fetching episode:", error);
@ -235,11 +242,11 @@ app.get("/api/feeds/:feedId", async (c) => {
const feedId = c.req.param("feedId");
const { getFeedById } = await import("./services/database.js");
const feed = await getFeedById(feedId);
if (!feed) {
return c.json({ error: "Feed not found" }, 404);
}
return c.json({ feed });
} catch (error) {
console.error("Error fetching feed:", error);
@ -261,7 +268,9 @@ app.get("/api/feeds/:feedId/episodes", async (c) => {
app.get("/api/episodes-with-feed-info", async (c) => {
try {
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js");
const { fetchEpisodesWithFeedInfo } = await import(
"./services/database.js"
);
const episodes = await fetchEpisodesWithFeedInfo();
return c.json({ episodes });
} catch (error) {
@ -273,13 +282,15 @@ app.get("/api/episodes-with-feed-info", async (c) => {
app.get("/api/episode-with-source/:episodeId", async (c) => {
try {
const episodeId = c.req.param("episodeId");
const { fetchEpisodeWithSourceInfo } = await import("./services/database.js");
const { fetchEpisodeWithSourceInfo } = await import(
"./services/database.js"
);
const episode = await fetchEpisodeWithSourceInfo(episodeId);
if (!episode) {
return c.json({ error: "Episode not found" }, 404);
}
return c.json({ episode });
} catch (error) {
console.error("Error fetching episode with source info:", error);
@ -291,8 +302,8 @@ app.post("/api/feed-requests", async (c) => {
try {
const body = await c.req.json();
const { url, requestMessage } = body;
if (!url || typeof url !== 'string') {
if (!url || typeof url !== "string") {
return c.json({ error: "URL is required" }, 400);
}
@ -301,11 +312,11 @@ app.post("/api/feed-requests", async (c) => {
url,
requestMessage,
});
return c.json({
success: true,
return c.json({
success: true,
message: "Feed request submitted successfully",
requestId
requestId,
});
} catch (error) {
console.error("Error submitting feed request:", error);
@ -329,6 +340,8 @@ serve(
console.log(`🌟 Server is running on http://localhost:${info.port}`);
console.log(`📡 Using configuration from: ${config.paths.projectRoot}`);
console.log(`🗄️ Database: ${config.paths.dbPath}`);
console.log(`🔄 Batch scheduler is active and will manage automatic processing`);
console.log(
`🔄 Batch scheduler is active and will manage automatic processing`,
);
},
);

View File

@ -16,7 +16,7 @@ class BatchScheduler {
isRunning: false,
canForceStop: false,
};
private currentAbortController?: AbortController;
private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
@ -58,7 +58,7 @@ class BatchScheduler {
this.state.nextRun = new Date(nextRunTime).toISOString();
console.log(
`🕕 Next batch process scheduled for: ${new Date(nextRunTime).toLocaleString()}`
`🕕 Next batch process scheduled for: ${new Date(nextRunTime).toLocaleString()}`,
);
this.state.intervalId = setTimeout(async () => {
@ -78,7 +78,7 @@ class BatchScheduler {
this.state.isRunning = true;
this.state.canForceStop = true;
this.state.lastRun = new Date().toISOString();
// Create new AbortController for this batch run
this.currentAbortController = new AbortController();
@ -87,7 +87,7 @@ class BatchScheduler {
await batchProcess(this.currentAbortController.signal);
console.log("✅ Scheduled batch process completed");
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (error instanceof Error && error.name === "AbortError") {
console.log("🛑 Batch process was forcefully stopped");
} else {
console.error("❌ Error during scheduled batch process:", error);
@ -162,4 +162,4 @@ class BatchScheduler {
// Export singleton instance
export const batchScheduler = new BatchScheduler();
export type { BatchSchedulerState };
export type { BatchSchedulerState };

View File

@ -7,13 +7,13 @@ interface Config {
endpoint: string;
modelName: string;
};
// VOICEVOX Configuration
voicevox: {
host: string;
styleId: number;
};
// Podcast Configuration
podcast: {
title: string;
@ -25,19 +25,19 @@ interface Config {
ttl: string;
baseUrl: string;
};
// Admin Panel Configuration
admin: {
port: number;
username?: string;
password?: string;
};
// Batch Processing Configuration
batch: {
disableInitialRun: boolean;
};
// File paths
paths: {
projectRoot: string;
@ -64,43 +64,52 @@ function getOptionalEnv(key: string, defaultValue: string): string {
}
function createConfig(): Config {
const projectRoot = import.meta.dirname ? path.dirname(import.meta.dirname) : process.cwd();
const projectRoot = import.meta.dirname
? path.dirname(import.meta.dirname)
: process.cwd();
const dataDir = path.join(projectRoot, "data");
const publicDir = path.join(projectRoot, "public");
return {
openai: {
apiKey: getRequiredEnv("OPENAI_API_KEY"),
endpoint: getOptionalEnv("OPENAI_API_ENDPOINT", "https://api.openai.com/v1"),
endpoint: getOptionalEnv(
"OPENAI_API_ENDPOINT",
"https://api.openai.com/v1",
),
modelName: getOptionalEnv("OPENAI_MODEL_NAME", "gpt-4o-mini"),
},
voicevox: {
host: getOptionalEnv("VOICEVOX_HOST", "http://localhost:50021"),
styleId: parseInt(getOptionalEnv("VOICEVOX_STYLE_ID", "0")),
},
podcast: {
title: getOptionalEnv("PODCAST_TITLE", "自動生成ポッドキャスト"),
link: getOptionalEnv("PODCAST_LINK", "https://your-domain.com/podcast"),
description: getOptionalEnv("PODCAST_DESCRIPTION", "RSSフィードから自動生成された音声ポッドキャスト"),
description: getOptionalEnv(
"PODCAST_DESCRIPTION",
"RSSフィードから自動生成された音声ポッドキャスト",
),
language: getOptionalEnv("PODCAST_LANGUAGE", "ja"),
author: getOptionalEnv("PODCAST_AUTHOR", "管理者"),
categories: getOptionalEnv("PODCAST_CATEGORIES", "Technology"),
ttl: getOptionalEnv("PODCAST_TTL", "60"),
baseUrl: getOptionalEnv("PODCAST_BASE_URL", "https://your-domain.com"),
},
admin: {
port: parseInt(getOptionalEnv("ADMIN_PORT", "3001")),
username: import.meta.env["ADMIN_USERNAME"],
password: import.meta.env["ADMIN_PASSWORD"],
},
batch: {
disableInitialRun: getOptionalEnv("DISABLE_INITIAL_BATCH", "false") === "true",
disableInitialRun:
getOptionalEnv("DISABLE_INITIAL_BATCH", "false") === "true",
},
paths: {
projectRoot,
dataDir,
@ -109,7 +118,10 @@ function createConfig(): Config {
podcastAudioDir: path.join(publicDir, "podcast_audio"),
frontendBuildDir: path.join(projectRoot, "frontend", "dist"),
adminBuildDir: path.join(projectRoot, "admin-panel", "dist"),
feedUrlsFile: path.join(projectRoot, getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt")),
feedUrlsFile: path.join(
projectRoot,
getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt"),
),
},
};
}
@ -121,21 +133,21 @@ export function validateConfig(): void {
if (!config.openai.apiKey) {
throw new Error("OPENAI_API_KEY is required");
}
if (isNaN(config.voicevox.styleId)) {
throw new Error("VOICEVOX_STYLE_ID must be a valid number");
}
// Validate URLs
try {
new URL(config.voicevox.host);
} catch {
throw new Error("VOICEVOX_HOST must be a valid URL");
}
try {
new URL(config.openai.endpoint);
} catch {
throw new Error("OPENAI_API_ENDPOINT must be a valid URL");
}
}
}

View File

@ -1,4 +1,4 @@
import * as cheerio from 'cheerio';
import * as cheerio from "cheerio";
export interface ExtractedContent {
title?: string;
@ -8,17 +8,21 @@ export interface ExtractedContent {
error?: string;
}
export async function extractArticleContent(url: string): Promise<ExtractedContent> {
export async function extractArticleContent(
url: string,
): Promise<ExtractedContent> {
try {
// Fetch the HTML content
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "ja,en-US;q=0.7,en;q=0.3",
"Accept-Encoding": "gzip, deflate",
Connection: "keep-alive",
"Upgrade-Insecure-Requests": "1",
},
signal: AbortSignal.timeout(30000), // 30 second timeout
});
@ -31,52 +35,56 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
const $ = cheerio.load(html);
// Remove unwanted elements
$('script, style, nav, header, footer, aside, .advertisement, .ads, .sidebar, .menu, .navigation, .social-share, .comments').remove();
$(
"script, style, nav, header, footer, aside, .advertisement, .ads, .sidebar, .menu, .navigation, .social-share, .comments",
).remove();
let content = '';
let title = '';
let description = '';
let content = "";
let title = "";
let description = "";
// Extract title
title = $('title').text().trim() ||
$('h1').first().text().trim() ||
$('meta[property="og:title"]').attr('content') ||
'';
title =
$("title").text().trim() ||
$("h1").first().text().trim() ||
$('meta[property="og:title"]').attr("content") ||
"";
// Extract description
description = $('meta[name="description"]').attr('content') ||
$('meta[property="og:description"]').attr('content') ||
'';
description =
$('meta[name="description"]').attr("content") ||
$('meta[property="og:description"]').attr("content") ||
"";
// Try multiple content extraction strategies
const contentSelectors = [
// Common article selectors
'article',
"article",
'[role="main"]',
'.article-content',
'.post-content',
'.entry-content',
'.content',
'.main-content',
'.article-body',
'.post-body',
'.story-body',
'.news-content',
".article-content",
".post-content",
".entry-content",
".content",
".main-content",
".article-body",
".post-body",
".story-body",
".news-content",
// Japanese news site specific selectors
'.article',
'.news-article',
'.post',
'.entry',
'#content',
'#main',
'.main',
".article",
".news-article",
".post",
".entry",
"#content",
"#main",
".main",
// Fallback to common containers
'.container',
'#container',
'main',
'body'
".container",
"#container",
"main",
"body",
];
for (const selector of contentSelectors) {
@ -84,11 +92,11 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
if (element.length > 0) {
// Get text content and clean it up
let extractedText = element.text().trim();
// Remove extra whitespace and normalize
extractedText = extractedText
.replace(/\s+/g, ' ')
.replace(/\n\s*\n/g, '\n')
.replace(/\s+/g, " ")
.replace(/\n\s*\n/g, "\n")
.trim();
// Only use if we found substantial content
@ -101,50 +109,49 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
// If still no content, try paragraph extraction
if (!content) {
const paragraphs = $('p').map((_, el) => $(el).text().trim()).get();
const paragraphs = $("p")
.map((_, el) => $(el).text().trim())
.get();
content = paragraphs
.filter(p => p.length > 50) // Filter out short paragraphs
.join('\n\n');
.filter((p) => p.length > 50) // Filter out short paragraphs
.join("\n\n");
}
// Final fallback: use body text
if (!content || content.length < 100) {
content = $('body').text()
.replace(/\s+/g, ' ')
.trim();
content = $("body").text().replace(/\s+/g, " ").trim();
}
// Validate extracted content
if (!content || content.length < 50) {
return {
title,
content: '',
content: "",
description,
success: false,
error: 'Insufficient content extracted'
error: "Insufficient content extracted",
};
}
// Limit content length to avoid token limits
const maxLength = 5000;
if (content.length > maxLength) {
content = content.substring(0, maxLength) + '...';
content = content.substring(0, maxLength) + "...";
}
return {
title,
content,
description,
success: true
success: true,
};
} catch (error) {
return {
title: '',
content: '',
description: '',
title: "",
content: "",
description: "",
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
error: error instanceof Error ? error.message : "Unknown error occurred",
};
}
}
@ -153,30 +160,30 @@ export async function enhanceArticleContent(
originalTitle: string,
originalLink: string,
originalContent?: string,
originalDescription?: string
originalDescription?: string,
): Promise<{ content?: string; description?: string }> {
// If we already have substantial content, use it
const existingContent = originalContent || originalDescription || '';
const existingContent = originalContent || originalDescription || "";
if (existingContent.length > 500) {
return {
content: originalContent,
description: originalDescription
description: originalDescription,
};
}
// Try to extract content from the URL
const extracted = await extractArticleContent(originalLink);
if (extracted.success && extracted.content) {
return {
content: extracted.content,
description: extracted.description || originalDescription
description: extracted.description || originalDescription,
};
}
// Return original content if extraction failed
return {
content: originalContent,
description: originalDescription
description: originalDescription,
};
}
}

View File

@ -59,7 +59,12 @@ export async function openAI_ClassifyFeed(title: string): Promise<string> {
export async function openAI_GeneratePodcastContent(
title: string,
items: Array<{ title: string; link: string; content?: string; description?: string }>,
items: Array<{
title: string;
link: string;
content?: string;
description?: string;
}>,
): Promise<string> {
if (!title || title.trim() === "") {
throw new Error("Feed title is required for podcast content generation");
@ -78,22 +83,25 @@ export async function openAI_GeneratePodcastContent(
}
// Build detailed article information including content
const articleDetails = validItems.map((item, i) => {
let articleInfo = `${i + 1}. タイトル: ${item.title}\nURL: ${item.link}`;
// Add content if available
const content = item.content || item.description;
if (content && content.trim()) {
// Limit content length to avoid token limits
const maxContentLength = 2000;
const truncatedContent = content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
articleInfo += `\n内容: ${truncatedContent}`;
}
return articleInfo;
}).join("\n\n");
const articleDetails = validItems
.map((item, i) => {
let articleInfo = `${i + 1}. タイトル: ${item.title}\nURL: ${item.link}`;
// Add content if available
const content = item.content || item.description;
if (content && content.trim()) {
// Limit content length to avoid token limits
const maxContentLength = 2000;
const truncatedContent =
content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
articleInfo += `\n内容: ${truncatedContent}`;
}
return articleInfo;
})
.join("\n\n");
const prompt = `
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。

View File

@ -17,18 +17,18 @@ function splitTextIntoChunks(text: string, maxLength: number = 50): string[] {
// Split by sentences first (Japanese periods and line breaks)
const sentences = text.split(/([。!?\n])/);
for (let i = 0; i < sentences.length; i++) {
const sentence = sentences[i];
if (!sentence) continue;
if (currentChunk.length + sentence.length <= maxLength) {
currentChunk += sentence;
} else {
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
// If single sentence is too long, split further
if (sentence.length > maxLength) {
const subChunks = splitLongSentence(sentence, maxLength);
@ -39,12 +39,12 @@ function splitTextIntoChunks(text: string, maxLength: number = 50): string[] {
}
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks.filter(chunk => chunk.length > 0);
return chunks.filter((chunk) => chunk.length > 0);
}
/**
@ -57,10 +57,10 @@ function splitLongSentence(sentence: string, maxLength: number): string[] {
const chunks: string[] = [];
let currentChunk = "";
// Split by commas and common Japanese particles
const parts = sentence.split(/([、,,]|[はがでをにと])/);
for (const part of parts) {
if (currentChunk.length + part.length <= maxLength) {
currentChunk += part;
@ -71,11 +71,11 @@ function splitLongSentence(sentence: string, maxLength: number): string[] {
currentChunk = part;
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
// If still too long, force split by character limit
const finalChunks: string[] = [];
for (const chunk of chunks) {
@ -87,8 +87,8 @@ function splitLongSentence(sentence: string, maxLength: number): string[] {
finalChunks.push(chunk);
}
}
return finalChunks.filter(chunk => chunk.length > 0);
return finalChunks.filter((chunk) => chunk.length > 0);
}
interface VoiceStyle {
@ -112,7 +112,9 @@ async function generateAudioForChunk(
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
console.log(`チャンク${chunkIndex + 1}の音声クエリ開始: ${itemId} (${chunkText.length}文字)`);
console.log(
`チャンク${chunkIndex + 1}の音声クエリ開始: ${itemId} (${chunkText.length}文字)`,
);
const queryResponse = await fetch(queryUrl, {
method: "POST",
@ -157,11 +159,16 @@ async function generateAudioForChunk(
fs.mkdirSync(outputDir, { recursive: true });
}
const chunkWavPath = path.resolve(outputDir, `${itemId}_chunk_${chunkIndex}.wav`);
const chunkWavPath = path.resolve(
outputDir,
`${itemId}_chunk_${chunkIndex}.wav`,
);
fs.writeFileSync(chunkWavPath, audioBuffer);
console.log(`チャンク${chunkIndex + 1}のWAVファイル保存完了: ${chunkWavPath}`);
console.log(
`チャンク${chunkIndex + 1}のWAVファイル保存完了: ${chunkWavPath}`,
);
return chunkWavPath;
}
@ -173,25 +180,34 @@ async function concatenateAudioFiles(
outputMp3Path: string,
): Promise<void> {
const ffmpegCmd = ffmpegPath || "ffmpeg";
// Create a temporary file list for FFmpeg concat
const tempDir = config.paths.podcastAudioDir;
const listFilePath = path.resolve(tempDir, `concat_list_${Date.now()}.txt`);
try {
// Write file list in FFmpeg concat format
const fileList = wavFiles.map(file => `file '${path.resolve(file)}'`).join('\n');
const fileList = wavFiles
.map((file) => `file '${path.resolve(file)}'`)
.join("\n");
fs.writeFileSync(listFilePath, fileList);
console.log(`音声ファイル結合開始: ${wavFiles.length}個のファイルを結合 -> ${outputMp3Path}`);
console.log(
`音声ファイル結合開始: ${wavFiles.length}個のファイルを結合 -> ${outputMp3Path}`,
);
const result = Bun.spawnSync([
ffmpegCmd,
"-f", "concat",
"-safe", "0",
"-i", listFilePath,
"-codec:a", "libmp3lame",
"-qscale:a", "2",
"-f",
"concat",
"-safe",
"0",
"-i",
listFilePath,
"-codec:a",
"libmp3lame",
"-qscale:a",
"2",
"-y", // Overwrite output file
outputMp3Path,
]);
@ -209,7 +225,7 @@ async function concatenateAudioFiles(
if (fs.existsSync(listFilePath)) {
fs.unlinkSync(listFilePath);
}
// Clean up individual WAV files
for (const wavFile of wavFiles) {
if (fs.existsSync(wavFile)) {
@ -236,12 +252,14 @@ export async function generateTTSWithoutQueue(
throw new Error("Script text is required for TTS generation");
}
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}, ${scriptText.length}文字)`);
console.log(
`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}, ${scriptText.length}文字)`,
);
// Split text into chunks
const chunks = splitTextIntoChunks(scriptText.trim());
console.log(`テキストを${chunks.length}個のチャンクに分割: ${itemId}`);
if (chunks.length === 0) {
throw new Error("No valid text chunks generated");
}
@ -259,8 +277,10 @@ export async function generateTTSWithoutQueue(
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (!chunk) continue;
console.log(`チャンク${i + 1}/${chunks.length}処理中: "${chunk.substring(0, 30)}${chunk.length > 30 ? '...' : ''}"`);
console.log(
`チャンク${i + 1}/${chunks.length}処理中: "${chunk.substring(0, 30)}${chunk.length > 30 ? "..." : ""}"`,
);
const wavPath = await generateAudioForChunk(chunk, i, itemId);
generatedWavFiles.push(wavPath);
}
@ -273,12 +293,15 @@ export async function generateTTSWithoutQueue(
if (!firstWavFile) {
throw new Error("No WAV files generated");
}
const result = Bun.spawnSync([
ffmpegCmd,
"-i", firstWavFile,
"-codec:a", "libmp3lame",
"-qscale:a", "2",
"-i",
firstWavFile,
"-codec:a",
"libmp3lame",
"-qscale:a",
"2",
"-y",
mp3FilePath,
]);
@ -289,7 +312,7 @@ export async function generateTTSWithoutQueue(
: "Unknown error";
throw new Error(`FFmpeg conversion failed: ${stderr}`);
}
// Clean up WAV file
fs.unlinkSync(firstWavFile);
} else {
@ -299,7 +322,6 @@ export async function generateTTSWithoutQueue(
console.log(`TTS生成完了: ${itemId} (${chunks.length}チャンク)`);
return path.basename(mp3FilePath);
} catch (error) {
// Clean up any generated files on error
for (const wavFile of generatedWavFiles) {
@ -307,7 +329,7 @@ export async function generateTTSWithoutQueue(
fs.unlinkSync(wavFile);
}
}
throw error;
}
}
@ -318,19 +340,24 @@ export async function generateTTS(
retryCount: number = 0,
): Promise<string> {
const maxRetries = 2;
try {
return await generateTTSWithoutQueue(itemId, scriptText, retryCount);
} catch (error) {
console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
console.error(
`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`,
error,
);
if (retryCount < maxRetries) {
// Add to queue for retry only on initial failure
const { addToQueue } = await import("../services/database.js");
await addToQueue(itemId, scriptText, retryCount);
throw new Error(`TTS generation failed, added to retry queue: ${error}`);
} else {
throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
throw new Error(
`TTS generation failed after ${maxRetries + 1} attempts: ${error}`,
);
}
}
}