refactor: migrate project from Flask to Next.js with TypeScript, Prisma, and Edge TTS integration

This commit is contained in:
2026-09-03 00:16:04 -07:00
parent 84f79dc5f9
commit f033a6045c
60 changed files with 5340 additions and 2179 deletions
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { storage } from "@/lib/storage/storage";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const episode = await prisma.episode.findUnique({
where: { id },
include: { stages: true },
});
if (!episode) {
return NextResponse.json(
{ success: false, error: "Episode not found" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
episode: {
...episode,
timings: episode.timingsJson ? JSON.parse(episode.timingsJson) : [],
},
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || "Failed to fetch episode" },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const episode = await prisma.episode.findUnique({ where: { id } });
if (!episode) {
return NextResponse.json(
{ success: false, error: "Episode not found" },
{ status: 404 }
);
}
// Delete audio file from storage
await storage.deleteAudio(id);
// Delete record from DB
await prisma.episode.delete({ where: { id } });
return NextResponse.json({ success: true, message: "Episode deleted" });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || "Failed to delete episode" },
{ status: 500 }
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { audioService } from "@/lib/tts/audio-service";
import { cleanHtmlTags } from "@/lib/tts/chunker";
export async function GET() {
try {
const episodes = await prisma.episode.findMany({
orderBy: { createdAt: "desc" },
include: { stages: true },
});
return NextResponse.json({ success: true, episodes });
} catch (error: any) {
console.error("Failed to list episodes:", error);
return NextResponse.json(
{ success: false, error: error.message || "Failed to list episodes" },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { title, content, mode = "VERBATIM", provider = "edge", voice } = body;
if (!title || typeof title !== "string" || !title.trim()) {
return NextResponse.json(
{ success: false, error: "Title is required." },
{ status: 400 }
);
}
if (!content || typeof content !== "string" || !content.trim()) {
return NextResponse.json(
{ success: false, error: "Content is required." },
{ status: 400 }
);
}
const sanitizedTitle = cleanHtmlTags(title.trim()) || "Untitled Episode";
// 1. Create episode record in PENDING state
const episode = await prisma.episode.create({
data: {
title: sanitizedTitle,
originalContent: content.trim(),
mode,
status: "PROCESSING",
},
});
// 2. Synthesize audio asynchronously or inline
try {
const audioResult = await audioService.processAndSaveEpisode(
episode.id,
content.trim(),
{ provider, voice }
);
const updatedEpisode = await prisma.episode.update({
where: { id: episode.id },
data: {
status: "READY",
audioUrl: audioResult.audioUrl,
durationSeconds: audioResult.durationSeconds,
timingsJson: JSON.stringify(audioResult.timings),
},
});
return NextResponse.json({
success: true,
episode: updatedEpisode,
});
} catch (synthError: any) {
console.error(`Audio synthesis failed for episode ${episode.id}:`, synthError);
await prisma.episode.update({
where: { id: episode.id },
data: {
status: "FAILED",
errorMessage: synthError.message || "Audio synthesis failed",
},
});
return NextResponse.json(
{
success: false,
error: synthError.message || "Failed to synthesize audio",
episodeId: episode.id,
},
{ status: 500 }
);
}
} catch (error: any) {
console.error("Episode creation error:", error);
return NextResponse.json(
{ success: false, error: error.message || "Internal server error" },
{ status: 500 }
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { POPULAR_EDGE_VOICES } from "@/lib/tts/edge-provider";
export async function GET() {
return NextResponse.json({
success: true,
voices: POPULAR_EDGE_VOICES,
});
}
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #090d16;
--foreground: #f1f5f9;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Be My Anchor - Listen to Any Article",
description:
"Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased">
{children}
</body>
</html>
);
}
+468
View File
@@ -0,0 +1,468 @@
"use client";
import React, { useEffect, useState } from "react";
import {
Radio,
PlusCircle,
Headphones,
Trash2,
FileText,
Sparkles,
Loader2,
Clock,
ExternalLink,
Volume2,
} from "lucide-react";
import { AudioPlayer } from "@/components/AudioPlayer";
import { TranscriptViewer } from "@/components/TranscriptViewer";
import { TimingCue, VoiceOption } from "@/lib/tts/types";
interface Episode {
id: string;
title: string;
originalContent: string;
mode: string;
status: string;
durationSeconds?: number | null;
audioUrl?: string | null;
timingsJson?: string | null;
createdAt: string;
}
export default function Home() {
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [activeEpisode, setActiveEpisode] = useState<Episode | null>(null);
const [activeTimings, setActiveTimings] = useState<TimingCue[]>([]);
const [currentTime, setCurrentTime] = useState<number>(0);
const [seekTime, setSeekTime] = useState<number | null>(null);
// Form state
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [voice, setVoice] = useState("zh-CN-XiaoxiaoNeural");
const [voices, setVoices] = useState<VoiceOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [showCreateForm, setShowCreateForm] = useState(false);
const handleContentChange = (val: string) => {
setContent(val);
if (/[\u4e00-\u9fa5]/.test(val) && voice.startsWith("en-")) {
setVoice("zh-CN-XiaoxiaoNeural");
}
};
// Fetch episodes on mount
const fetchEpisodes = async () => {
try {
const res = await fetch("/api/episodes");
const data = await res.json();
if (data.success && data.episodes) {
setEpisodes(data.episodes);
// Default to first episode if none selected
if (!activeEpisode && data.episodes.length > 0) {
selectEpisode(data.episodes[0]);
}
}
} catch (err) {
console.error("Failed to load episodes:", err);
}
};
// Fetch voices on mount
const fetchVoices = async () => {
try {
const res = await fetch("/api/voices");
const data = await res.json();
if (data.success && data.voices) {
setVoices(data.voices);
}
} catch (err) {
console.error("Failed to load voices:", err);
}
};
useEffect(() => {
fetchEpisodes();
fetchVoices();
}, []);
const selectEpisode = (ep: Episode) => {
setActiveEpisode(ep);
if (ep.timingsJson) {
try {
setActiveTimings(JSON.parse(ep.timingsJson));
} catch {
setActiveTimings([]);
}
} else {
setActiveTimings([]);
}
setCurrentTime(0);
};
const handleCreateEpisode = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !content.trim()) {
setFormError("Please provide both a title and article content.");
return;
}
setIsSubmitting(true);
setFormError("");
try {
const res = await fetch("/api/episodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
content,
mode: "VERBATIM",
provider: "edge",
voice,
}),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Synthesis failed");
}
// Reset form
setTitle("");
setContent("");
setShowCreateForm(false);
// Refresh list & select newly created episode
await fetchEpisodes();
if (data.episode) {
selectEpisode(data.episode);
}
} catch (err: any) {
setFormError(err.message || "Failed to generate audio.");
} finally {
setIsSubmitting(false);
}
};
const handleDeleteEpisode = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this episode?")) return;
try {
await fetch(`/api/episodes/${id}`, { method: "DELETE" });
if (activeEpisode?.id === id) {
setActiveEpisode(null);
setActiveTimings([]);
}
await fetchEpisodes();
} catch (err) {
console.error("Failed to delete episode:", err);
}
};
const formatDuration = (secs?: number | null) => {
if (!secs) return "0:00";
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div className="flex-1 flex flex-col pb-32">
{/* Top Navbar */}
<header className="border-b border-slate-800 bg-slate-900/60 backdrop-blur-md sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-white shadow-md shadow-blue-500/20">
<Radio className="w-5 h-5" />
</div>
<div>
<span className="font-bold text-lg text-slate-100 tracking-tight">
Be My Anchor
</span>
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-mono">
v2.0 M1
</span>
</div>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-2 px-3.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition shadow-sm shadow-blue-600/20"
>
<PlusCircle className="w-4 h-4" />
<span>New Episode</span>
</button>
</div>
</header>
{/* Main Content Area */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6 w-full flex-1">
{/* Ingest Form (Collapsible) */}
{showCreateForm && (
<div className="mb-8 p-6 bg-slate-900 border border-slate-800 rounded-2xl shadow-xl animate-in fade-in slide-in-from-top-4 duration-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold text-slate-100">
Convert Content to Podcast Episode
</h2>
</div>
<button
onClick={() => setShowCreateForm(false)}
className="text-xs text-slate-400 hover:text-slate-200"
>
Close
</button>
</div>
{formError && (
<div className="mb-4 p-3 bg-red-950/40 border border-red-800 text-red-300 text-sm rounded-lg">
{formError}
</div>
)}
<form onSubmit={handleCreateEpisode} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
Article or Episode Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Understanding Kubernetes Ingress and Service Meshes"
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
required
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
TTS Voice Engine (Edge Neural)
</label>
<select
value={voice}
onChange={(e) => setVoice(e.target.value)}
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 focus:outline-none focus:border-blue-500"
>
<optgroup label="🇨🇳 中文 / Chinese (支持中英双语混合)">
{voices
.filter((v) => v.locale.startsWith("zh"))
.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</optgroup>
<optgroup label="🇺🇸 English (US & UK)">
{voices
.filter((v) => !v.locale.startsWith("zh"))
.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</optgroup>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
Narration Mode
</label>
<select
disabled
className="w-full px-3.5 py-2.5 bg-slate-850 border border-slate-700/50 rounded-lg text-sm text-slate-400 cursor-not-allowed"
>
<option value="VERBATIM">Verbatim Reader (M1 Active)</option>
<option value="FEYNMAN" disabled>Feynman Tutor (M2 Preview)</option>
<option value="DUAL_HOST" disabled>Dual-Host Dialogue (M2 Preview)</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
Article Text or Markdown
</label>
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Paste your technical article, newsletter, blog post, or notes here..."
rows={6}
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono text-xs leading-relaxed"
required
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 text-sm text-slate-400 hover:text-slate-200"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 text-white text-sm font-medium rounded-lg shadow-md shadow-blue-600/30 transition"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Synthesizing Audio...</span>
</>
) : (
<>
<Headphones className="w-4 h-4" />
<span>Synthesize Episode</span>
</>
)}
</button>
</div>
</form>
</div>
)}
{/* Dashboard Columns */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left: Episode Library (5 cols) */}
<section className="lg:col-span-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<FileText className="w-4 h-4" />
<span>Episodes ({episodes.length})</span>
</h2>
</div>
{episodes.length === 0 ? (
<div className="p-8 text-center bg-slate-900/50 border border-dashed border-slate-800 rounded-2xl">
<Headphones className="w-10 h-10 text-slate-600 mx-auto mb-3" />
<h3 className="text-slate-200 font-medium text-sm mb-1">No episodes generated yet</h3>
<p className="text-xs text-slate-500 mb-4">
Paste a blog post or article to generate your first podcast episode!
</p>
<button
onClick={() => setShowCreateForm(true)}
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-500"
>
<PlusCircle className="w-3.5 h-3.5" />
<span>Generate Episode</span>
</button>
</div>
) : (
<div className="space-y-2.5">
{episodes.map((ep) => {
const isSelected = activeEpisode?.id === ep.id;
return (
<div
key={ep.id}
onClick={() => selectEpisode(ep)}
className={`p-4 rounded-xl cursor-pointer transition-all border ${
isSelected
? "bg-slate-850 border-blue-500/60 shadow-md shadow-blue-500/5"
: "bg-slate-900/70 border-slate-800 hover:border-slate-700 hover:bg-slate-850/60"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className={`text-sm font-medium truncate ${isSelected ? "text-blue-200" : "text-slate-200"}`}>
{ep.title}
</h3>
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500">
<span className="flex items-center gap-1 font-mono">
<Clock className="w-3 h-3" />
{formatDuration(ep.durationSeconds)}
</span>
<span>•</span>
<span className="capitalize">{ep.mode.toLowerCase()}</span>
<span>•</span>
<span
className={`px-1.5 py-0.2 rounded text-[10px] font-medium uppercase ${
ep.status === "READY"
? "bg-emerald-500/10 text-emerald-400"
: ep.status === "PROCESSING"
? "bg-amber-500/10 text-amber-400"
: "bg-red-500/10 text-red-400"
}`}
>
{ep.status}
</span>
</div>
</div>
<button
onClick={(e) => handleDeleteEpisode(ep.id, e)}
className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-950/30 rounded-lg transition"
title="Delete episode"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)}
</section>
{/* Right: Active Episode & Transcript (7 cols) */}
<section className="lg:col-span-7 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 min-h-[480px] flex flex-col">
{activeEpisode ? (
<div className="flex flex-col flex-1">
{/* Header */}
<div className="border-b border-slate-800 pb-4 mb-4">
<div className="flex items-center justify-between">
<span className="text-xs font-mono uppercase tracking-wider text-blue-400 bg-blue-950/60 px-2.5 py-1 rounded border border-blue-900">
Now Playing
</span>
<span className="text-xs text-slate-500">
Created {new Date(activeEpisode.createdAt).toLocaleDateString()}
</span>
</div>
<h1 className="text-xl font-bold text-slate-100 mt-2">
{activeEpisode.title}
</h1>
</div>
{/* Synchronized Transcript */}
<div className="flex-1 flex flex-col">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Volume2 className="w-3.5 h-3.5 text-blue-400" />
<span>Synchronized Transcript</span>
</h3>
<TranscriptViewer
timings={activeTimings}
currentTime={currentTime}
onSeek={(t) => setSeekTime(t)}
/>
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-500 py-16">
<Radio className="w-12 h-12 mb-3 opacity-30 text-blue-400" />
<p className="text-sm">Select an episode from the library to listen and follow along.</p>
</div>
)}
</section>
</div>
</main>
{/* Persistent Bottom Audio Player */}
{activeEpisode && activeEpisode.audioUrl && (
<AudioPlayer
title={activeEpisode.title}
audioUrl={activeEpisode.audioUrl}
durationSeconds={activeEpisode.durationSeconds}
onTimeUpdate={(t) => setCurrentTime(t)}
seekTime={seekTime}
/>
)}
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import {
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
Gauge,
Radio,
} from "lucide-react";
interface AudioPlayerProps {
title: string;
audioUrl: string;
durationSeconds?: number | null;
onTimeUpdate?: (currentTime: number) => void;
onSeek?: (seekTo: number) => void;
seekTime?: number | null;
}
export function AudioPlayer({
title,
audioUrl,
durationSeconds,
onTimeUpdate,
seekTime,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(durationSeconds || 0);
const [playbackRate, setPlaybackRate] = useState(1);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// Handle external seek requests (e.g. clicking transcript)
useEffect(() => {
if (
seekTime !== undefined &&
seekTime !== null &&
audioRef.current &&
Math.abs(audioRef.current.currentTime - seekTime) > 0.5
) {
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
if (!isPlaying) {
audioRef.current.play().catch(console.error);
setIsPlaying(true);
}
}
}, [seekTime]);
// Handle source changes
useEffect(() => {
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
setCurrentTime(0);
audioRef.current.load();
}
}, [audioUrl]);
const togglePlay = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current.play().then(() => setIsPlaying(true)).catch(console.error);
}
};
const jump = (seconds: number) => {
if (!audioRef.current) return;
const newTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration || 999999));
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
};
const handleTimeUpdate = () => {
if (!audioRef.current) return;
const time = audioRef.current.currentTime;
setCurrentTime(time);
onTimeUpdate?.(time);
};
const handleLoadedMetadata = () => {
if (!audioRef.current) return;
if (audioRef.current.duration && isFinite(audioRef.current.duration)) {
setDuration(audioRef.current.duration);
}
};
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
setCurrentTime(time);
if (audioRef.current) {
audioRef.current.currentTime = time;
}
};
const togglePlaybackRate = () => {
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
const newRate = rates[nextIdx];
setPlaybackRate(newRate);
if (audioRef.current) {
audioRef.current.playbackRate = newRate;
}
};
const toggleMute = () => {
if (!audioRef.current) return;
const newMuted = !isMuted;
setIsMuted(newMuted);
audioRef.current.muted = newMuted;
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (audioRef.current) {
audioRef.current.volume = val;
if (val === 0) {
setIsMuted(true);
} else if (isMuted) {
setIsMuted(false);
}
}
};
const formatTime = (secs: number) => {
if (isNaN(secs) || !isFinite(secs)) return "00:00";
const minutes = Math.floor(secs / 60);
const seconds = Math.floor(secs % 60);
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-900/95 backdrop-blur-md border-t border-slate-800 shadow-2xl px-4 py-3 sm:px-6">
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
preload="metadata"
/>
<div className="max-w-6xl mx-auto flex flex-col gap-2">
{/* Scrubber and Timestamps */}
<div className="flex items-center gap-3 w-full">
<span className="text-xs font-mono text-slate-400 w-12 text-right">
{formatTime(currentTime)}
</span>
<div className="relative flex-1 group">
<input
type="range"
min="0"
max={duration || 100}
step="0.1"
value={currentTime}
onChange={handleScrub}
className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:h-2 transition-all"
/>
</div>
<span className="text-xs font-mono text-slate-400 w-12">
{formatTime(duration)}
</span>
</div>
{/* Controls Bar */}
<div className="flex items-center justify-between">
{/* Track Info */}
<div className="flex items-center gap-3 w-1/3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{title}</p>
<p className="text-xs text-slate-400 truncate">Be My Anchor • Episode</p>
</div>
</div>
{/* Main Playback Buttons */}
<div className="flex items-center gap-3 sm:gap-4 justify-center">
<button
onClick={() => jump(-15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump backward 15s"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg shadow-blue-600/30 transition-transform active:scale-95"
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
</button>
<button
onClick={() => jump(15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump forward 15s"
>
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Aux Controls: Speed & Volume */}
<div className="flex items-center gap-3 w-1/3 justify-end">
<button
onClick={togglePlaybackRate}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded border border-slate-700 transition"
title="Adjust Speed"
>
<Gauge className="w-3.5 h-3.5 text-blue-400" />
<span>{playbackRate}x</span>
</button>
<div className="hidden sm:flex items-center gap-2">
<button
onClick={toggleMute}
className="text-slate-400 hover:text-slate-200 transition"
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? (
<VolumeX className="w-4 h-4 text-red-400" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-slate-700 rounded appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import React, { useEffect, useRef } from "react";
import { TimingCue } from "@/lib/tts/types";
import { Clock, PlayCircle } from "lucide-react";
interface TranscriptViewerProps {
timings: TimingCue[];
currentTime: number;
onSeek: (time: number) => void;
}
export function TranscriptViewer({
timings,
currentTime,
onSeek,
}: TranscriptViewerProps) {
const activeRef = useRef<HTMLDivElement | null>(null);
// Auto-scroll active cue into view smoothly
useEffect(() => {
if (activeRef.current) {
activeRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [currentTime]);
const formatSeconds = (secs: number) => {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
if (!timings || timings.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-slate-500 border border-dashed border-slate-800 rounded-xl">
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No synchronized transcript available for this episode.</p>
</div>
);
}
return (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2">
{timings.map((cue, idx) => {
const isActive = currentTime >= cue.startTime && currentTime <= cue.endTime;
return (
<div
key={idx}
ref={isActive ? activeRef : null}
onClick={() => onSeek(cue.startTime)}
className={`group flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 border ${
isActive
? "bg-blue-950/40 border-blue-500/40 text-blue-100 shadow-sm"
: "bg-slate-900/30 border-slate-800/60 hover:bg-slate-850 hover:border-slate-700 text-slate-300"
}`}
>
{/* Timestamp badge */}
<span
className={`text-xs font-mono px-2 py-0.5 rounded shrink-0 flex items-center gap-1 ${
isActive
? "bg-blue-600 text-white font-semibold"
: "bg-slate-800 text-slate-400 group-hover:bg-slate-700 group-hover:text-slate-200"
}`}
>
<PlayCircle className="w-3 h-3" />
{formatSeconds(cue.startTime)}
</span>
{/* Cue text */}
<p className="text-sm leading-relaxed flex-1">
{cue.text}
</p>
</div>
);
})}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
+66
View File
@@ -0,0 +1,66 @@
import fs from "fs";
import path from "path";
export interface IStorageProvider {
saveAudio(key: string, buffer: Buffer): Promise<string>;
getAudioUrl(key: string): string;
deleteAudio(key: string): Promise<boolean>;
exists(key: string): Promise<boolean>;
}
/**
* Local filesystem storage provider.
* Saves audio files directly inside public/audio/episodes so Next.js
* can serve them with native HTTP Range requests (scrubbing/seeking).
*/
export class LocalStorageProvider implements IStorageProvider {
private baseDir: string;
private publicUrlPrefix: string;
constructor() {
this.baseDir = path.join(process.cwd(), "public", "audio", "episodes");
this.publicUrlPrefix = "/audio/episodes";
this.ensureDirectory();
}
private ensureDirectory(): void {
if (!fs.existsSync(this.baseDir)) {
fs.mkdirSync(this.baseDir, { recursive: true });
}
}
async saveAudio(key: string, buffer: Buffer): Promise<string> {
this.ensureDirectory();
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
await fs.promises.writeFile(filepath, buffer);
return this.getAudioUrl(filename);
}
getAudioUrl(key: string): string {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
return `${this.publicUrlPrefix}/${filename}`;
}
async deleteAudio(key: string): Promise<boolean> {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
try {
if (fs.existsSync(filepath)) {
await fs.promises.unlink(filepath);
}
return true;
} catch {
return false;
}
}
async exists(key: string): Promise<boolean> {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
return fs.existsSync(filepath);
}
}
// Global default storage provider instance
export const storage: IStorageProvider = new LocalStorageProvider();
+148
View File
@@ -0,0 +1,148 @@
import { EdgeTTSProvider } from "./edge-provider";
import { OpenAITTSProvider } from "./openai-provider";
import { chunkText } from "./chunker";
import { ITextToSpeechProvider, SynthesizeResult, TimingCue, TTSOptions } from "./types";
import { storage } from "../storage/storage";
/**
* Accurately estimate duration of an MP3 buffer based on frame count or 48kbps bitrate fallback.
*/
export function estimateMp3Duration(buffer: Buffer): number {
if (!buffer || buffer.length === 0) return 0;
let frames = 0;
let sampleRate = 24000;
let foundHeaders = false;
for (let i = 0; i < buffer.length - 4; i++) {
// Look for MP3 frame sync: 11 bits set (0xFF followed by 0xE0 or higher)
if (buffer[i] === 0xff && (buffer[i + 1] & 0xe0) === 0xe0) {
const versionBits = (buffer[i + 1] >> 3) & 0x03; // 0=MPEG2.5, 2=MPEG2, 3=MPEG1
const layerBits = (buffer[i + 1] >> 1) & 0x03; // 1=Layer3
const sampleRateBits = (buffer[i + 2] >> 2) & 0x03;
if (layerBits === 1) { // Layer III
foundHeaders = true;
frames++;
// Determine sample rate
if (versionBits === 3) {
// MPEG 1
sampleRate = [44100, 48000, 32000, 44100][sampleRateBits];
} else {
// MPEG 2 or 2.5
sampleRate = [22050, 24000, 16000, 24000][sampleRateBits];
}
i += 100; // Skip ahead inside frame
}
}
}
if (foundHeaders && frames > 0) {
// MPEG2/2.5 Layer 3 has 576 samples per frame; MPEG1 Layer 3 has 1152 samples per frame
const samplesPerFrame = sampleRate <= 24000 ? 576 : 1152;
return (frames * samplesPerFrame) / sampleRate;
}
// Fallback based on 48 kbps mono MP3 (48000 bits/s = 6000 bytes/s)
return buffer.length / 6000;
}
export class AudioService {
private providers: Map<string, ITextToSpeechProvider>;
constructor() {
this.providers = new Map();
this.providers.set("edge", new EdgeTTSProvider());
this.providers.set("openai", new OpenAITTSProvider());
}
getProvider(name: string = "edge"): ITextToSpeechProvider {
const provider = this.providers.get(name.toLowerCase());
if (!provider) {
throw new Error(`Unsupported TTS provider: ${name}`);
}
return provider;
}
async getAvailableVoices(providerName: string = "edge") {
const provider = this.getProvider(providerName);
return provider.getAvailableVoices();
}
/**
* Synthesize long text into a single stitched MP3 with timing cues
*/
async synthesizeEpisode(
content: string,
options?: {
provider?: string;
voice?: string;
rate?: string;
}
): Promise<SynthesizeResult> {
const provider = this.getProvider(options?.provider || "edge");
const chunks = chunkText(content);
if (chunks.length === 0) {
throw new Error("No readable text content found to synthesize.");
}
const audioBuffers: Buffer[] = [];
const timings: TimingCue[] = [];
let currentTime = 0;
const ttsOptions: TTSOptions = {
voice: options?.voice,
rate: options?.rate,
};
for (const chunk of chunks) {
const chunkBuffer = await provider.synthesizeChunk(chunk.text, ttsOptions);
const chunkDuration = estimateMp3Duration(chunkBuffer);
timings.push({
text: chunk.text,
startTime: Number(currentTime.toFixed(2)),
endTime: Number((currentTime + chunkDuration).toFixed(2)),
stageCue: chunk.stageCue,
});
audioBuffers.push(chunkBuffer);
currentTime += chunkDuration;
}
const fullAudioBuffer = Buffer.concat(audioBuffers);
const totalDurationSeconds = Number(currentTime.toFixed(2));
return {
audioBuffer: fullAudioBuffer,
totalDurationSeconds,
timings,
};
}
/**
* Synthesizes and saves audio to storage, returning audioUrl and timings
*/
async processAndSaveEpisode(
episodeId: string,
content: string,
options?: {
provider?: string;
voice?: string;
rate?: string;
}
) {
const result = await this.synthesizeEpisode(content, options);
const audioUrl = await storage.saveAudio(episodeId, result.audioBuffer);
return {
audioUrl,
durationSeconds: result.totalDurationSeconds,
timings: result.timings,
};
}
}
export const audioService = new AudioService();
+161
View File
@@ -0,0 +1,161 @@
export interface TextChunk {
index: number;
text: string;
originalIndex?: number;
speaker?: string;
stageCue?: string;
}
/**
* Remove HTML tags from any string.
* When preserveNewlines is true, paragraph structure and linebreaks are maintained.
*/
export function cleanHtmlTags(raw: string, preserveNewlines: boolean = false): string {
if (!raw) return "";
let text = raw
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#\d+;/g, "");
if (preserveNewlines) {
return text
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.join("\n");
} else {
return text.replace(/\s+/g, " ").trim();
}
}
/**
* Detects if text contains Chinese characters (CJK unified ideographs)
*/
export function hasChineseCharacters(text: string): boolean {
return /[\u4e00-\u9fa5]/.test(text);
}
/**
* Common web boilerplate / navigation noise lines to discard
*/
const NOISE_LINE_PATTERNS = [
/^(previous|next|share|tweet|follow|subscribe|read more|continue reading|back to top|related)$/i,
/^(上一页|下一页|上一篇|下一篇|分享|点赞|在看|关注|阅读原文|返回顶部|相关推荐|版权声明|目录)$/,
/^https?:\/\/\S+$/i, // lone URLs
];
/**
* Clean markdown and HTML formatting to make text speech-friendly
*/
export function cleanTextForSpeech(raw: string): string {
if (!raw) return "";
// 1. Remove HTML tags while preserving line breaks for noise filtering
let text = cleanHtmlTags(raw, true);
// 2. Remove markdown image syntax ![alt](url)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "");
// 3. Convert markdown links [text](url) to just text
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1");
// 4. Remove headers (# Header)
text = text.replace(/^#{1,6}\s+/gm, "");
// 5. Remove bold/italic (* or _)
text = text.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, "$1");
// 6. Remove inline code ticks & code blocks
text = text.replace(/```[\s\S]*?```/g, "");
text = text.replace(/`([^`]+)`/g, "$1");
// 7. Remove blockquotes and list bullet markers
text = text.replace(/^\s*>\s+/gm, "");
text = text.replace(/^[\s*-•–]+\s+/gm, "");
// 8. Filter out common boilerplate / noise lines
const lines = text.split("\n").map((l) => l.trim());
const filteredLines = lines.filter((line) => {
if (!line) return false;
for (const pattern of NOISE_LINE_PATTERNS) {
if (pattern.test(line)) return false;
}
return true;
});
// 9. Normalize excessive newlines
return filteredLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
/**
* Splits long text into natural sentence or paragraph chunks.
* Handles English (. ? !) with spaces, AND Chinese / CJK (。!?;) without spaces.
* Chinese defaults to ~120 chars; English defaults to ~350 chars.
*/
export function chunkText(rawText: string, maxChunkLength?: number): TextChunk[] {
const cleaned = cleanTextForSpeech(rawText);
if (!cleaned) return [];
const isChinese = hasChineseCharacters(cleaned);
const targetLength = maxChunkLength || (isChinese ? 120 : 350);
// Split into raw paragraphs first
const rawParagraphs = cleaned
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const chunks: TextChunk[] = [];
let chunkIndex = 0;
for (const para of rawParagraphs) {
if (para.length <= targetLength) {
chunks.push({
index: chunkIndex++,
text: para,
});
continue;
}
// Split large paragraph into sentences:
// Matches CJK sentence enders (。!?;) with optional space
// OR English sentence enders (.?!;) followed by required space or end of string
const sentences = para
.split(/(?<=[。!?;])\s*|(?<=[.?!;])\s+/)
.map((s) => s.trim())
.filter(Boolean);
let currentChunk = "";
for (const sentence of sentences) {
const combined = currentChunk ? `${currentChunk} ${sentence}` : sentence;
if (combined.length <= targetLength) {
currentChunk = combined;
} else {
if (currentChunk) {
chunks.push({
index: chunkIndex++,
text: currentChunk,
});
}
currentChunk = sentence;
}
}
if (currentChunk) {
chunks.push({
index: chunkIndex++,
text: currentChunk,
});
}
}
return chunks;
}
+151
View File
@@ -0,0 +1,151 @@
import { MsEdgeTTS, OUTPUT_FORMAT, ProsodyOptions } from "msedge-tts";
import { ITextToSpeechProvider, TTSOptions, VoiceOption } from "./types";
import { hasChineseCharacters } from "./chunker";
export const DEFAULT_ENGLISH_VOICE = "en-US-ChristopherNeural";
export const DEFAULT_CHINESE_VOICE = "zh-CN-XiaoxiaoNeural";
export const POPULAR_EDGE_VOICES: VoiceOption[] = [
// Chinese / Multilingual (Mixed Chinese + English)
{
id: "zh-CN-XiaoxiaoNeural",
name: "晓晓 Xiaoxiao (CN Female - 温暖亲切 / 中英混合流利)",
gender: "female",
locale: "zh-CN",
},
{
id: "zh-CN-YunxiNeural",
name: "云希 Yunxi (CN Male - 生动活泼 / 科技与播客)",
gender: "male",
locale: "zh-CN",
},
{
id: "zh-CN-YunjianNeural",
name: "云健 Yunjian (CN Male - 沉稳大气 / 新闻专栏)",
gender: "male",
locale: "zh-CN",
},
{
id: "zh-CN-XiaoyiNeural",
name: "晓伊 Xiaoyi (CN Female - 温柔自然)",
gender: "female",
locale: "zh-CN",
},
{
id: "zh-TW-HsiaoChenNeural",
name: "曉臻 HsiaoChen (TW Female - 繁體中文)",
gender: "female",
locale: "zh-TW",
},
{
id: "zh-HK-HiuMaanNeural",
name: "曉曼 HiuMaan (HK Female - 廣東話 / 粵語)",
gender: "female",
locale: "zh-HK",
},
// English Voices
{
id: "en-US-ChristopherNeural",
name: "Christopher (US Male - Clear & Authoritative)",
gender: "male",
locale: "en-US",
},
{
id: "en-US-JennyNeural",
name: "Jenny (US Female - Natural & Engaging)",
gender: "female",
locale: "en-US",
},
{
id: "en-US-GuyNeural",
name: "Guy (US Male - Conversational)",
gender: "male",
locale: "en-US",
},
{
id: "en-US-AriaNeural",
name: "Aria (US Female - Expressive)",
gender: "female",
locale: "en-US",
},
{
id: "en-GB-RyanNeural",
name: "Ryan (UK Male - British Accent)",
gender: "male",
locale: "en-GB",
},
{
id: "en-GB-SoniaNeural",
name: "Sonia (UK Female - British Accent)",
gender: "female",
locale: "en-GB",
},
];
/**
* Resolves the optimal voice based on text language and user preference
*/
export function resolveVoiceForText(text: string, requestedVoice?: string): string {
const isChinese = hasChineseCharacters(text);
if (!requestedVoice) {
return isChinese ? DEFAULT_CHINESE_VOICE : DEFAULT_ENGLISH_VOICE;
}
// If the text has Chinese characters, but an English-only voice (en-*) was selected,
// automatically route to a Chinese neural voice so synthesis does NOT skip Chinese characters!
if (isChinese && requestedVoice.startsWith("en-")) {
const isMale = requestedVoice.includes("Christopher") || requestedVoice.includes("Guy") || requestedVoice.includes("Ryan");
return isMale ? "zh-CN-YunxiNeural" : DEFAULT_CHINESE_VOICE;
}
return requestedVoice;
}
export class EdgeTTSProvider implements ITextToSpeechProvider {
readonly name = "edge";
async getAvailableVoices(): Promise<VoiceOption[]> {
return POPULAR_EDGE_VOICES;
}
async synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer> {
const voice = resolveVoiceForText(text, options?.voice);
const tts = new MsEdgeTTS();
try {
await tts.setMetadata(
voice,
OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3
);
const prosody: ProsodyOptions = {};
if (options?.rate) prosody.rate = options.rate;
if (options?.pitch) prosody.pitch = options.pitch;
if (options?.volume) prosody.volume = options.volume;
const { audioStream } = tts.toStream(text, prosody);
const chunks: Buffer[] = [];
return await new Promise<Buffer>((resolve, reject) => {
audioStream.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
audioStream.on("end", () => {
resolve(Buffer.concat(chunks));
});
audioStream.on("error", (err: Error) => {
reject(err);
});
});
} finally {
try {
tts.close();
} catch {
// ignore close error
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ITextToSpeechProvider, TTSOptions, VoiceOption } from "./types";
export class OpenAITTSProvider implements ITextToSpeechProvider {
readonly name = "openai";
async getAvailableVoices(): Promise<VoiceOption[]> {
return [
{ id: "alloy", name: "Alloy (Neutral)", gender: "neutral", locale: "en-US" },
{ id: "echo", name: "Echo (Male)", gender: "male", locale: "en-US" },
{ id: "fable", name: "Fable (British Accent)", gender: "neutral", locale: "en-GB" },
{ id: "onyx", name: "Onyx (Deep Male)", gender: "male", locale: "en-US" },
{ id: "nova", name: "Nova (Warm Female)", gender: "female", locale: "en-US" },
{ id: "shimmer", name: "Shimmer (Clear Female)", gender: "female", locale: "en-US" },
];
}
async synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer> {
// Placeholder for Milestone 1
throw new Error(
"OpenAI TTS provider is reserved for Milestone 2. Please use 'edge' provider for Milestone 1."
);
}
}
+40
View File
@@ -0,0 +1,40 @@
export interface TimingCue {
text: string;
startTime: number; // in seconds
endTime: number; // in seconds
speaker?: string;
stageCue?: string; // Optional stage / figure cue ID e.g. "fig-1"
}
export interface TTSOptions {
voice?: string;
rate?: string; // e.g. "+0%", "-10%"
pitch?: string; // e.g. "+0Hz"
volume?: string; // e.g. "+0%"
}
export interface TTSChunkResult {
chunkIndex: number;
text: string;
audioBuffer: Buffer;
durationSeconds: number;
}
export interface SynthesizeResult {
audioBuffer: Buffer;
totalDurationSeconds: number;
timings: TimingCue[];
}
export interface VoiceOption {
id: string;
name: string;
gender: "male" | "female" | "neutral";
locale: string;
}
export interface ITextToSpeechProvider {
readonly name: string;
getAvailableVoices(): Promise<VoiceOption[]>;
synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer>;
}
View File
-266
View File
@@ -1,266 +0,0 @@
"""
Web Content Extractor Module
Provides functionalities to fetch web content using headless Chrome,
sanitize HTML, and save results with comprehensive logging and error handling.
"""
import logging
import os
import time
import random
import string
from typing import Optional
from lxml.html.clean import Cleaner
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from bs4 import BeautifulSoup
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# Configuration constants
DEFAULT_CHROME_PATH = "run/webdriver/chrome"
DEFAULT_WEB_DRIVER_PATH = "run/webdriver/chromedriver"
DEFAULT_WAIT_TIME = 10
DEFAULT_OUTPUT_DIR = "run/tmp"
CHROME_OPTIONS = [
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-gpu",
"window-size=1920,1080"
]
CLEANER_CONFIG = {
'page_structure': True,
'meta': True,
'embedded': True,
'links': True,
'style': True,
'processing_instructions': True,
'inline_style': True,
'scripts': True,
'javascript': True,
'comments': True,
'frames': True,
'forms': True,
'annoying_tags': True,
'remove_unknown_tags': True,
'safe_attrs_only': True,
'safe_attrs': frozenset(['src', 'color', 'href', 'title', 'class', 'name', 'id']),
'remove_tags': ('span', 'font', 'div')
}
ALLOWED_TAGS = {
'img': ['src', 'alt', 'width', 'height'],
'a': ['href', 'title'],
'video': ['src', 'controls', 'width', 'height'],
'source': ['src', 'type']
}
UNWANTED_TAGS = [
'script', 'style', 'nav', 'footer', 'header', 'aside',
'form', 'iframe', 'noscript', 'svg', 'canvas', 'applet',
'object', 'embed'
]
def configure_chrome_driver() -> webdriver.Chrome:
"""Configure and return a Chrome WebDriver instance."""
try:
chrome_options = Options()
for option in CHROME_OPTIONS:
chrome_options.add_argument(option)
chrome_path = os.path.join(
DEFAULT_CHROME_PATH, 'chrome-linux64', 'chrome'
)
chrome_options.binary_location = chrome_path
driver_path = os.path.join(
DEFAULT_WEB_DRIVER_PATH, 'chromedriver'
)
chrome_service = Service(driver_path)
logger.info("Initializing Chrome WebDriver")
return webdriver.Chrome(
service=chrome_service,
options=chrome_options
)
except Exception as e:
logger.error("Chrome driver configuration failed")
raise RuntimeError("WebDriver initialization failed") from e
def extract_html_with_chromedriver(
url: str,
wait_time: int = DEFAULT_WAIT_TIME
) -> Optional[str]:
"""
Extract HTML content from a URL using headless Chrome.
Args:
url: Target URL to scrape
wait_time: Maximum wait time for page load (seconds)
Returns:
str: Raw HTML content or None if extraction fails
"""
driver = None
try:
driver = configure_chrome_driver()
logger.info(f"Fetching URL: {url}")
driver.get(url)
WebDriverWait(driver, wait_time).until(
EC.presence_of_element_located((By.TAG_NAME, 'body'))
)
logger.debug("Page load completed successfully")
return driver.page_source
except Exception as e:
logger.error(f"Failed to extract HTML: {str(e)}")
return None
finally:
if driver:
logger.debug("Closing WebDriver instance")
driver.quit()
def sanitize_html(dirty_html: str) -> str:
"""
Sanitize HTML content using lxml cleaner.
Args:
dirty_html: Raw HTML input
Returns:
str: Sanitized HTML output
"""
try:
logger.info("Sanitizing HTML content")
cleaner = Cleaner(**CLEANER_CONFIG)
return cleaner.clean_html(dirty_html)
except Exception as e:
logger.error(f"HTML sanitization failed: {str(e)}")
raise
def extract_content(html_content: str) -> Optional[str]:
"""
Extract and clean main content from HTML.
Args:
html_content: Raw HTML input
Returns:
str: Cleaned HTML content or None if processing fails
"""
try:
if not html_content:
logger.warning("Empty HTML content received")
return None
logger.info("Processing HTML content")
soup = BeautifulSoup(html_content, "html.parser")
# Remove unwanted tags
for tag in soup.find_all(UNWANTED_TAGS):
tag.decompose()
# Clean tag attributes
for tag in soup.find_all():
allowed = ALLOWED_TAGS.get(tag.name, [])
attrs = list(tag.attrs.keys())
for attr in attrs:
if attr not in allowed:
del tag[attr]
# Remove empty tags
for tag in soup.find_all():
if not tag.get_text(strip=True):
tag.decompose()
return str(soup)
except Exception as e:
logger.error(f"Content extraction failed: {str(e)}")
return None
def save_html_to_file(
html_content: str,
output_dir: str = DEFAULT_OUTPUT_DIR
) -> Optional[str]:
"""
Save HTML content to timestamped file.
Args:
html_content: Content to save
output_dir: Target directory path
Returns:
str: Path to saved file or None if save fails
"""
try:
os.makedirs(output_dir, exist_ok=True)
timestamp = time.strftime("%Y%m%d%H%M%S")
rand_str = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
filename = f"output_{timestamp}_{rand_str}.html"
filepath = os.path.join(output_dir, filename)
logger.info(f"Saving output to: {filepath}")
with open(filepath, "w", encoding="utf-8") as f:
f.write(html_content)
return filepath
except Exception as e:
logger.error(f"File save failed: {str(e)}")
return None
def get_content_from_url(url: str) -> Optional[str]:
"""
Main pipeline: Fetch URL -> Extract Content -> Sanitize HTML
Args:
url: Target URL to process
Returns:
str: Processed HTML content or None if any step fails
"""
try:
raw_html = extract_html_with_chromedriver(url)
if not raw_html:
return None
sanitized = sanitize_html(raw_html)
return extract_content(sanitized)
except Exception as e:
logger.error(f"Processing pipeline failed: {str(e)}")
return None
if __name__ == "__main__":
target_url = "https://www.cncf.io/blog/2025/01/22/kubernetes-in-2025-are-you-ready-for-these-top-5-trends-and-predictions/"
if (result := get_content_from_url(target_url)) is not None:
file_path = save_html_to_file(result)
if file_path:
logger.info(f"Successfully saved output to: {file_path}")
else:
logger.error("Failed to save output file")
else:
logger.error("Content extraction failed for target URL")
View File
-192
View File
@@ -1,192 +0,0 @@
"""
LLM-based Podcast Script Converter
将HTML内容转换为播客风格的稿件,支持多种LLM模型接口。
"""
import os
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Literal
import html2text
# 第三方库导入(需要额外安装)
try:
from openai import OpenAI # OpenAI官方库
import google.generativeai as genai # Google Gemini
except ImportError:
pass # 运行时检查
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class LLMConverter(ABC):
"""LLM转换器抽象基类"""
@abstractmethod
def convert_to_podcast(self, html_content: str) -> Optional[str]:
"""将HTML内容转换为播客稿件"""
pass
class OpenAIConverter(LLMConverter):
"""ChatGPT转换器实现"""
def __init__(self, model: str = "gpt-4-turbo", temperature: float = 0.7):
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
# 将HTML转换为Markdown格式
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{
"role": "system",
"content": """你是一位专业播客编剧,需要将技术文章转换为生动有趣的播客稿件。要求:
1. 使用口语化表达,避免专业术语
2. 结构清晰,包含引言、主体和总结
3. 突出文章的核心观点和技术亮点
4. 适当添加过渡语和互动提示
5. 总长度控制在1500字左右"""
},
{
"role": "user",
"content": markdown_content
}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"OpenAI转换失败: {str(e)}")
return None
class GeminiConverter(LLMConverter):
"""Google Gemini转换器实现"""
def __init__(self, model: str = "gemini-pro", temperature: float = 0.7):
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
self.model = genai.GenerativeModel(model)
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.model.generate_content(
f"[播客稿件转换要求同OpenAI版本]\n\n{markdown_content}",
generation_config=genai.types.GenerationConfig(
temperature=self.temperature
)
)
return response.text
except Exception as e:
logger.error(f"Gemini转换失败: {str(e)}")
return None
class DeepSeekConverter(LLMConverter):
"""深度求索转换器实现"""
def __init__(self, model: str = "deepseek-chat", temperature: float = 0.7):
self.client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1"
)
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{"role": "system", "content": "[播客稿件转换要求同OpenAI版本]"},
{"role": "user", "content": markdown_content}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"DeepSeek转换失败: {str(e)}")
return None
class PodcastConverter:
"""播客稿件转换管理器"""
def __init__(
self,
provider: Literal["openai", "gemini", "deepseek"] = "openai",
model: Optional[str] = None,
temperature: float = 0.7
):
self.provider = provider
self.model = model
self.temperature = temperature
self.converter = self._init_converter()
def _init_converter(self) -> LLMConverter:
"""初始化指定的LLM转换器"""
try:
if self.provider == "openai":
return OpenAIConverter(
model=self.model or "gpt-4-turbo",
temperature=self.temperature
)
elif self.provider == "gemini":
return GeminiConverter(
model=self.model or "gemini-pro",
temperature=self.temperature
)
elif self.provider == "deepseek":
return DeepSeekConverter(
model=self.model or "deepseek-chat",
temperature=self.temperature
)
else:
raise ValueError("不支持的LLM提供商")
except Exception as e:
logger.error(f"转换器初始化失败: {str(e)}")
raise
def convert(self, html_content: str) -> Optional[str]:
"""执行转换流程"""
if not html_content:
logger.warning("输入内容为空")
return None
logger.info(f"开始转换,使用模型: {self.provider}/{self.model}")
return self.converter.convert_to_podcast(html_content)
# 使用示例
if __name__ == "__main__":
# 测试用HTML内容(实际应从文件或网络获取)
sample_html = """
<html>
<body>
<h1>人工智能的未来发展</h1>
<p>近年来,AI技术取得了突破性进展...</p>
<ul>
<li>自然语言处理的进步</li>
<li>计算机视觉的应用扩展</li>
<li>伦理问题的重要性</li>
</ul>
</body>
</html>
"""
# 使用OpenAI转换
openai_converter = PodcastConverter(provider="openai")
result = openai_converter.convert(sample_html)
if result:
print("转换结果示例:")
print(result[:500] + "...") # 打印前500字符
else:
print("转换失败")
View File
-87
View File
@@ -1,87 +0,0 @@
from flask import Flask, request, render_template, redirect, url_for
from ..modules.html_extractor.extract import get_content_from_url
from flask_dance.contrib.google import make_google_blueprint, google
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['PREFIX'] = '/proxy/5000'
app.config['GOOGLE_OAUTH_CLIENT_ID'] = 'your-client-id'
app.config['GOOGLE_OAUTH_CLIENT_SECRET'] = 'your-client-secret'
@app.route("/")
def index():
print("index!")
return render_template("index.html", prefix=app.config['PREFIX'],)
@app.route("/page")
def article_page():
url = request.args.get("url")
if not url:
return redirect(url_for('index'))
try:
content = get_content_from_url(url)
return render_template('page.html',
prefix=app.config['PREFIX'],
url=url,
summary=content,
audio_url="/static/audio/sample.mp3")
except Exception as e:
app.logger.error(f"Error processing {url}: {str(e)}")
return render_template('error.html',
error_message="Failed to process the URL"), 500
@app.route("/login", methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# Add authentication logic here
return redirect(url_for('index'))
return render_template("login.html", prefix=app.config['PREFIX'])
@app.route("/register", methods=['GET', 'POST'])
def register():
if request.method == 'POST':
# Add registration logic here
return redirect(url_for('login'))
return render_template("register.html", prefix=app.config['PREFIX'])
@app.errorhandler(404)
def page_not_found(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Page not found"), 404
@app.errorhandler(500)
def internal_error(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Internal server error"), 500
google_bp = make_google_blueprint(
scope=["profile", "email"],
redirect_to="auth_callback"
)
app.register_blueprint(google_bp, url_prefix="/auth")
@app.route("/auth/callback")
def auth_callback():
if not google.authorized:
return redirect(url_for("login"))
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
user_info = resp.json()
# Handle user authentication logic here
return redirect(url_for("index"))
return redirect(url_for("login"))
@app.route("/auth/google")
def google_auth():
return redirect(url_for("google.login"))
if __name__ == "__main__":
app.run(port=5000, debug=True)
-818
View File
@@ -1,818 +0,0 @@
:root {
--primary: #6366f1;
--surface: #f8fafc;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
touch-action: manipulation;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
font-family: system-ui, sans-serif;
background: #f8fafc;
}
/* 头部样式 */
header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
z-index: 2000;
}
.branding {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0 12px;
}
.brand-logo {
width: 32px;
height: 32px;
object-fit: contain;
}
.site-name {
font-weight: 600;
color: #1e293b;
font-size: 1.2rem;
white-space: nowrap;
}
.nav-group {
display: flex;
gap: 8px;
justify-self: end;
/* 右侧菜单保持右对齐 */
}
.history-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
justify-self: start;
}
.history-button img {
width: 32px;
height: 32px;
}
/* 菜单系统 */
.menu {
position: relative;
}
.menu-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.menu-button img {
width: 32px;
height: 32px;
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
z-index: 100;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: none;
min-width: 160px;
}
.menu-dropdown.active {
display: block;
}
.menu-item {
padding: 12px 16px;
color: #334155;
text-decoration: none;
display: block;
border-bottom: 1px solid #f1f5f9;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 90;
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
.url {
font-size: 1.1em;
color: #3b82f6;
margin-bottom: 12px;
word-break: break-all;
}
.summary {
line-height: 1.5;
color: #64748b;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
/* 默认显示4行 */
overflow: hidden;
text-overflow: ellipsis;
--lines: 4;
/* 默认显示行数 */
--mobile-lines: 3;
/* 移动端行数 */
}
/* 响应式调整 */
@media (max-width: 768px) {
.text-content {
width: 90%;
/* 小屏幕更宽 */
}
.summary {
-webkit-line-clamp: 3;
/* 减少显示行数 */
}
}
/* 播放控制栏 */
footer {
display: flex;
align-items: center;
padding: 16px;
gap: 4px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 2000;
}
.progress-bar {
flex-grow: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.1s linear;
}
.control-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.control-button img {
width: 40px;
height: 40px;
}
/* 历史记录面板样式 */
.history-panel {
position: fixed;
top: -100%;
left: 0;
right: 0;
height: 100vh;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(30px);
z-index: 2000;
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
padding: 60px 16px 16px;
}
.history-panel.active {
top: 0;
}
/* 关闭按钮位置调整 */
.history-close {
position: absolute;
top: 12px;
left: 12px;
/* 改为左上角 */
padding: 8px;
background: none;
border: none;
cursor: pointer;
}
.history-close img {
width: 32px;
height: 32px;
}
/* 滚动容器 */
.history-content {
height: calc(100vh - 100px);
overflow-y: auto;
padding: 0 8px;
}
/* 历史记录项样式 */
.history-item {
padding: 12px;
margin: 8px 0;
background: rgba(0, 0, 0, 0.03);
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
}
.history-item:hover {
background: rgba(0, 0, 0, 0.05);
}
/* 滚动条美化 */
.history-content::-webkit-scrollbar {
width: 6px;
}
.history-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
.history-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.search-form {
display: flex;
align-items: center;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.search-input {
flex-grow: 1;
border: none;
padding: 15px;
font-size: 16px;
outline: none;
color: #333;
}
.search-button {
background-color: #007bff;
/* Blue button color */
color: #fff;
border: none;
padding: 15px 20px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.search-button:hover {
background-color: #0069d9;
}
/* Optional: Add a magnifying glass icon */
.search-input::placeholder {
color: #999;
}
/* Responsive adjustments (optional) */
@media (max-width: 768px) {
.search-form {
width: 100%;
}
}
/* Add to main.css */
/* Auth System */
.auth-main {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
min-height: calc(100dvh - 120px);
}
.auth-container {
background: white;
padding: 2.5rem;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.auth-title {
text-align: center;
color: #1e293b;
font-size: 1.5rem;
margin-bottom: 2rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.input-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.primary-button {
width: 100%;
padding: 14px;
background-color: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.oauth-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: #64748b;
}
.oauth-divider::before,
.oauth-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.google-login {
width: 100%;
padding: 12px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: background 0.2s;
}
.google-login:hover {
background: #f8fafc;
}
.google-login img {
width: 20px;
height: 20px;
}
.auth-links {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
.auth-links a {
color: var(--primary);
text-decoration: none;
font-size: 0.9rem;
}
.auth-links a:hover {
text-decoration: underline;
}
/* 统一搜索页面样式 */
.search-main {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100dvh - 120px);
padding: 2rem;
}
.search-container {
width: 100%;
max-width: 720px;
text-align: center;
}
.search-title {
font-size: 2rem;
color: #1e293b;
margin-bottom: 2rem;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.search-input {
padding: 16px 24px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* 统一按钮悬停效果 */
.primary-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* 历史面板动画优化 */
.history-panel {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateY(-100%);
}
.history-panel.active {
transform: translateY(0);
}
/* 新增CSS样式 */
.search-textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--primary);
}
.search-icon-button {
position: absolute;
right: 16px;
bottom: 16px;
background: var(--primary);
border: none;
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
}
.search-icon-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
}
.search-options {
margin-top: 1.5rem;
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.option-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #475569;
}
.voice-selection select {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: white;
font-size: 0.95rem;
}
/* 加载动画 */
.loading-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px 24px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 验证状态提示 */
.invalid-input {
border-color: #ef4444 !important;
background: #fef2f2;
}
.validation-error {
color: #ef4444;
font-size: 0.9rem;
margin-top: 4px;
display: none;
}
/* 新增/修改的CSS */
.search-group {
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.search-textarea {
width: 100%;
padding: 20px;
border: none;
resize: none;
font-size: 1.1rem;
line-height: 1.6;
min-height: 150px;
background: #fff;
}
.search-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s;
}
.advanced-toggle:hover {
background: rgba(99, 102, 241, 0.1);
}
.advanced-toggle img {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.search-icon-button {
background: var(--primary);
border: none;
border-radius: 12px;
padding: 10px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: white;
transition: all 0.2s;
}
.search-icon-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* 高级选项面板 */
.advanced-options {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
background: #fff;
border-radius: 0 0 16px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.advanced-options.active {
max-height: 400px; /* 根据内容调整 */
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
padding: 20px;
}
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-group label {
font-size: 0.9rem;
color: #475569;
font-weight: 500;
}
.option-group select,
.option-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
}
/* 开关样式 */
.switch {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.slider {
position: relative;
width: 40px;
height: 20px;
background: #e2e8f0;
border-radius: 10px;
transition: 0.4s;
}
.slider:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.4s;
}
input:checked + .slider {
background: var(--primary);
}
input:checked + .slider:before {
transform: translateX(20px);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-29
View File
@@ -1,29 +0,0 @@
// history.js
const HistoryManager = {
MAX_ITEMS: 20,
init: function() {
if (!localStorage.history) {
localStorage.history = JSON.stringify([]);
}
},
add: function(item) {
const history = JSON.parse(localStorage.history);
history.unshift({
timestamp: new Date().toISOString(),
...item
});
localStorage.history = JSON.stringify(history.slice(0, this.MAX_ITEMS));
},
render: function(container) {
const history = JSON.parse(localStorage.history);
container.innerHTML = history.map(item => `
<div class="history-item">
<time>${new Date(item.timestamp).toLocaleString()}</time>
<p>${item.title}</p>
</div>
`).join('');
}
};
-216
View File
@@ -1,216 +0,0 @@
// static/js/shared.js
const App = {
init: function () {
this.bindMenuToggle();
this.bindGlobalClick();
this.initHistoryPanel();
this.initAdvancedOptions();
this.checkLoginStatus();
this.loadSettings();
},
// 菜单切换功能
bindMenuToggle: function () {
document.querySelectorAll('.menu-button').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const menu = e.currentTarget.closest('.menu').querySelector('.menu-dropdown');
menu.classList.toggle('active');
});
});
},
// 全局点击处理
bindGlobalClick: function () {
document.addEventListener('click', (e) => {
// 关闭所有菜单
document.querySelectorAll('.menu-dropdown.active').forEach(menu => {
if (!menu.contains(e.target)) {
menu.classList.remove('active');
}
});
// 关闭高级选项
const advancedPanel = document.getElementById('advancedOptions');
if (advancedPanel &&
!e.target.closest('.advanced-options') &&
!e.target.closest('.advanced-toggle')) {
advancedPanel.classList.remove('active');
const toggleImg = document.querySelector('.advanced-toggle img');
if (toggleImg) toggleImg.style.transform = 'rotate(0deg)';
}
});
},
// 历史记录面板
initHistoryPanel: function () {
const historyButton = document.querySelector('.history-button');
const historyPanel = document.getElementById('historyPanel');
if (historyButton && historyPanel) {
historyButton.addEventListener('click', () => {
historyPanel.classList.toggle('active');
});
// 触摸滑动处理
let touchStartY = 0;
historyPanel.addEventListener('touchstart', e => {
touchStartY = e.touches[0].clientY;
}, false);
historyPanel.addEventListener('touchmove', e => {
const touchY = e.touches[0].clientY;
if (touchY - touchStartY > 50) {
historyPanel.classList.remove('active');
}
}, false);
}
},
// 高级选项功能
initAdvancedOptions: function () {
const toggleButton = document.querySelector('.advanced-toggle');
if (toggleButton) {
toggleButton.addEventListener('click', () => {
const optionsPanel = document.getElementById('advancedOptions');
optionsPanel.classList.toggle('active');
const img = toggleButton.querySelector('img');
img.style.transform = optionsPanel.classList.contains('active') ?
'rotate(180deg)' : 'rotate(0deg)';
});
}
},
// 登录状态检查
checkLoginStatus: function () {
const loginItem = document.getElementById('loginItem');
if (loginItem) {
// 实际应通过API检查登录状态
const isLoggedIn = false;
if (isLoggedIn) {
loginItem.textContent = 'Profile';
loginItem.href = '/profile';
}
}
},
// 设置管理
loadSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = JSON.parse(localStorage.getItem('userSettings')) || {};
document.querySelector('[name="voice"]').value = settings.voice || 'male';
document.querySelector('[name="style"]').value = settings.style || 'news';
document.querySelector('[name="experimental"]').checked = settings.experimental || false;
}
},
saveSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = {
voice: document.querySelector('[name="voice"]').value,
style: document.querySelector('[name="style"]').value,
experimental: document.querySelector('[name="experimental"]').checked
};
localStorage.setItem('userSettings', JSON.stringify(settings));
}
},
// 通用表单验证
validateRegistration: function (form) {
const password = form.querySelector('#password').value;
const confirmPassword = form.querySelector('#confirmPassword').value;
if (password !== confirmPassword) {
alert('Passwords do not match!');
return false;
}
return true;
},
// 播放器控制
initAudioPlayer: function () {
const audio = document.getElementById('audioPlayer');
if (!audio) return;
// 设置有效音频源
if (window.audioSource) {
audio.src = window.audioSource;
}
const progress = document.querySelector('.progress-fill');
const playButton = document.getElementById('playButton');
// 安全更新播放按钮状态
const updatePlayButton = () => {
if (!audio.paused && !isFinite(audio.duration)) {
return; // 防止无效状态
}
const iconPath = audio.paused ? 'play.png' : 'pause.png';
playButton.querySelector('img').src =
`${window.prefix}/static/icons/${iconPath}`;
};
// 音频元数据加载完成后再启用控制
audio.addEventListener('loadedmetadata', () => {
// 初始化进度条
progress.style.width = '0%';
// 绑定控制事件
document.querySelectorAll('#playButton').forEach(btn => {
btn.addEventListener('click', () => {
audio.paused ? audio.play().catch(console.error) : audio.pause();
});
});
document.querySelectorAll('.jump-back').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(-15, audio));
});
document.querySelectorAll('.jump-forward').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(15, audio));
});
});
// 错误处理
audio.addEventListener('error', (e) => {
console.error('Audio error:', e.target.error);
alert('Error loading audio file');
});
},
jumpAudio: function (seconds, audio) {
try {
if (!isFinite(audio.duration) || audio.readyState < 2) {
console.warn('Audio not ready');
return;
}
const newTime = Math.max(0, Math.min(
audio.currentTime + seconds,
audio.duration
));
audio.currentTime = newTime;
} catch (error) {
console.error('Jump error:', error);
}
}
};
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
App.init();
App.initAudioPlayer();
// 注册表单验证
const regForm = document.querySelector('.auth-form');
if (regForm) {
regForm.addEventListener('submit', (e) => {
if (!App.validateRegistration(regForm)) {
e.preventDefault();
}
});
}
});
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.
-69
View File
@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Error - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/clock.png" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="/proxy/5000/static/logo.png" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="/proxy/5000/static/icons/people.png" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="/" class="menu-item">返回主页</a>
<a href="/report" class="menu-item">报告问题</a>
<a href="/login" class="menu-item" id="loginItem">登录</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/play-pause.png" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="error-container">
<h2>Oops! Something went wrong</h2>
<p class="error-message">{{ error_message }}</p>
<a href="/" class="primary-button">Return Home</a>
</div>
</main>
</body>
</html>
-108
View File
@@ -1,108 +0,0 @@
<!-- ./src/server/templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<!-- index.html 修改后的搜索部分 -->
<main class="search-main">
<div class="search-container">
<h1 class="search-title">Convert Content to Podcast</h1>
<form class="search-form" action="{{ prefix }}/page" method="get">
<!-- 搜索输入区 -->
<div class="search-group">
<textarea name="content" class="search-textarea" placeholder="Paste your text or enter URL..."
rows="4" required></textarea>
<div class="search-actions">
<button type="button" class="advanced-toggle" onclick="toggleAdvancedOptions()">
Advanced Options
<img src="{{ prefix }}/static/icons/chevron-down.svg" alt="▼">
</button>
<button type="submit" class="search-icon-button">
<img src="{{ prefix }}/static/icons/convert.svg" alt="Convert">
</button>
</div>
</div>
<!-- 高级选项下拉 -->
<div class="advanced-options" id="advancedOptions">
<div class="option-grid">
<div class="option-group">
<label>Voice Gender</label>
<select name="voice">
<option value="male">Male</option>
<option value="female">Female</option>
<option value="neutral">Neutral</option>
</select>
</div>
<div class="option-group">
<label>Style</label>
<select name="style">
<option value="news">News Report</option>
<option value="blog">Casual Blog</option>
<option value="story">Story Telling</option>
<option value="academic">Academic</option>
</select>
</div>
<div class="option-group">
<label>Custom Prompt</label>
<input type="text" name="prompt" placeholder="Additional instructions...">
</div>
<div class="option-group">
<label class="switch">
<input type="checkbox" name="experimental">
<span class="slider"></span>
<span>Experimental Mode</span>
</label>
</div>
</div>
</div>
</form>
</div>
</main>
<!-- 在body末尾添加 -->
<div class="loading-indicator">
<div class="loading-spinner"></div>
<span>Processing...</span>
</div>
</body>
</html>
-65
View File
@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Login - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Welcome Back</h2>
<form class="auth-form" method="POST" action="/login">
<div class="input-group">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
</div>
<button type="submit" class="primary-button">Login</button>
<div class="oauth-divider">
<span>or continue with</span>
</div>
<button type="button" class="google-login" onclick="loginWithGoogle()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/google.png') }}" alt="Google">
Continue with Google
</button>
</form>
<div class="auth-links">
<a href="{{ prefix }}/register">Create new account</a>
<a href="{{ prefix }}/forgot-password">Forgot password?</a>
</div>
</div>
</main>
</body>
</html>
-102
View File
@@ -1,102 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor - {{ url }} </title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script>
// 定义全局路径前缀
window.prefix = "{{ prefix }}";
// 初始化音频源
window.audioSource = "{{ prefix }}/{{ url_for('static', filename='test.mp3') }}";
</script>
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/login" class="menu-item" id="loginItem">Login</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/play-pause.png') }}" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="image-container">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Cover">
</div>
<!-- 独立文字容器 -->
<div class="text-content">
<div class="url">{{url}}</div>
<div class="summary">{{summary}}</div>
</div>
</main>
<!-- page.html 修改后的按钮部分 -->
<footer>
<button class="control-button jump-back">
<img src="{{ prefix }}/static/icons/back.png" alt="-15s">
</button>
<button class="control-button" id="playButton">
<img src="{{ prefix }}/static/icons/play.png" alt="Play/Pause">
</button>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<button class="control-button jump-forward">
<img src="{{ prefix }}/static/icons/fast.png" alt="+15s">
</button>
</footer>
<audio id="audioPlayer" src="{{ prefix }}/{{ url_for('static', filename='test.mp3') }}"></audio>
</body>
</html>
-61
View File
@@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Register - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="handleHistoryButton()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Create Account</h2>
<form class="auth-form" onsubmit="return validateRegistration(event)">
<div class="input-group">
<input type="text" id="username" placeholder="Username" required>
<input type="email" id="email" placeholder="Email" required>
<input type="password" id="password" placeholder="Password" required>
<input type="password" id="confirmPassword" placeholder="Confirm Password" required>
</div>
<button type="submit" class="primary-button">Register</button>
<div class="auth-links">
<span>Already have an account? <a href="{{ prefix }}/login">Login here</a></span>
</div>
</form>
</div>
</main>
</body>
</html>