Apply formatting
This commit is contained in:
@ -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
|
||||
|
||||
|
@ -18,4 +18,4 @@
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>,
|
||||
)
|
||||
);
|
||||
|
@ -18,4 +18,4 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
}
|
||||
|
@ -7,4 +7,4 @@
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
302
admin-server.ts
302
admin-server.ts
@ -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
77
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
69
bun.lock
69
bun.lock
@ -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=="],
|
||||
|
@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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>,
|
||||
)
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
11
package.json
11
package.json
@ -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",
|
||||
|
@ -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
133
server.ts
@ -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`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -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 };
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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 = `
|
||||
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。
|
||||
|
111
services/tts.ts
111
services/tts.ts
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user