From 0775104b69eb3e728300df9ef4d6916b7692c41f Mon Sep 17 00:00:00 2001 From: Anthony <47945770+Tony0410@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:33:34 +0800 Subject: [PATCH] feat: Initialize project with basic structure and dependencies Sets up the foundational elements for the NewsCaster AI application. This includes: - Initializing the project with Vite and React. - Defining core types for articles and player state. - Configuring build tools and TypeScript. - Adding essential dependencies like React, Vite, and Google's Gemini API client. - Providing initial README instructions for running locally. - Setting up basic styling and structure in index.html. - Defining available voices and playback constants. - Implementing utility functions for audio handling. --- .gitignore | 24 +++ App.tsx | 378 +++++++++++++++++++++++++++++++++++ README.md | 25 ++- components/QueueItem.tsx | 86 ++++++++ components/ReaderView.tsx | 59 ++++++ components/VoiceSelector.tsx | 30 +++ constants.ts | 15 ++ index.html | 40 ++++ index.tsx | 15 ++ metadata.json | 5 + package.json | 24 +++ services/audioUtils.ts | 59 ++++++ services/geminiService.ts | 283 ++++++++++++++++++++++++++ tsconfig.json | 29 +++ types.ts | 35 ++++ vite.config.ts | 23 +++ 16 files changed, 1122 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/QueueItem.tsx create mode 100644 components/ReaderView.tsx create mode 100644 components/VoiceSelector.tsx create mode 100644 constants.ts create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/audioUtils.ts create mode 100644 services/geminiService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..4aa51e8 --- /dev/null +++ b/App.tsx @@ -0,0 +1,378 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Layout } from 'lucide-react'; +import { Article, PlaybackStatus, PlayerState, VoiceName } from './types'; +import { AVAILABLE_VOICES, MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants'; +import { extractArticleContent, generateSpeechFromText } from './services/geminiService'; +import { base64ToUint8Array, createWavBlob } from './services/audioUtils'; +import { QueueItem } from './components/QueueItem'; +import { VoiceSelector } from './components/VoiceSelector'; +import { ReaderView } from './components/ReaderView'; + +export default function App() { + // -- State -- + const [inputUrl, setInputUrl] = useState(''); + const [queue, setQueue] = useState([]); + // Selected article for reading (defaults to playing article) + const [viewId, setViewId] = useState(null); + + const [playerState, setPlayerState] = useState({ + isPlaying: false, + playbackRate: 1.0, + currentArticleId: null, + selectedVoice: VoiceName.Puck, + }); + + // -- Refs -- + const audioRef = useRef(new Audio()); + const audioSrcRef = useRef(null); + + // -- Helpers -- + const getCurrentArticle = () => queue.find(a => a.id === playerState.currentArticleId); + const getViewingArticle = () => { + // If user manually selected an article to view, show that. + // Otherwise show the currently playing one. + // Otherwise show the first one. + if (viewId) return queue.find(a => a.id === viewId); + if (playerState.currentArticleId) return queue.find(a => a.id === playerState.currentArticleId); + if (queue.length > 0) return queue[0]; + return null; + }; + + const updateArticleStatus = (id: string, status: PlaybackStatus, errorMessage?: string, audioUrl?: string, title?: string, text?: string) => { + setQueue(prev => prev.map(item => { + if (item.id !== id) return item; + return { + ...item, + status, + errorMessage, + audioUrl: audioUrl || item.audioUrl, + title: title || item.title, + text: text || item.text + }; + })); + }; + + // -- Handlers -- + + // 1. Add URL to Queue + const handleAddUrl = async () => { + if (!inputUrl.trim()) return; + const id = uuidv4(); + const newArticle: Article = { + id, + url: inputUrl, + title: 'Fetching info...', + text: '', + status: PlaybackStatus.LOADING_TEXT + }; + + setQueue(prev => [...prev, newArticle]); + setInputUrl(''); + // Auto view the new article while loading + if (!playerState.isPlaying) { + setViewId(id); + } + + // Start fetching text immediately + try { + const { title, text } = await extractArticleContent(newArticle.url); + updateArticleStatus(id, PlaybackStatus.IDLE, undefined, undefined, title, text); + } catch (error: any) { + updateArticleStatus(id, PlaybackStatus.ERROR, error.message || "Failed to load article"); + } + }; + + // 2. Generate Audio for an article + const prepareAudio = async (articleId: string): Promise => { + const article = queue.find(a => a.id === articleId); + if (!article) return null; + + // If already has audio return it + if (article.audioUrl) return article.audioUrl; + + updateArticleStatus(articleId, PlaybackStatus.LOADING_AUDIO); + + try { + if (!article.text || article.text.length < 10) { + throw new Error("No text available to read."); + } + const base64Audio = await generateSpeechFromText(article.text, playerState.selectedVoice); + const pcmData = base64ToUint8Array(base64Audio); + const wavBlob = createWavBlob(pcmData); + const audioUrl = URL.createObjectURL(wavBlob); + + updateArticleStatus(articleId, PlaybackStatus.READY, undefined, audioUrl); + return audioUrl; + } catch (error: any) { + updateArticleStatus(articleId, PlaybackStatus.ERROR, error.message || "Failed to generate speech"); + return null; + } + }; + + // 3. Play Logic + const playArticle = useCallback(async (id: string) => { + const article = queue.find(a => a.id === id); + if (!article) return; + + // If currently playing a different one, pause it. + if (playerState.currentArticleId && playerState.currentArticleId !== id) { + audioRef.current.pause(); + } + + setPlayerState(prev => ({ ...prev, currentArticleId: id, isPlaying: true })); + // Also switch view to the playing article + setViewId(id); + + let src = article.audioUrl; + + // Check if we need to generate audio + if (!src) { + src = await prepareAudio(id); + } + + if (src) { + // Only update src if it's different to avoid reload + if (audioSrcRef.current !== src) { + audioRef.current.src = src; + audioSrcRef.current = src; + // Apply current speed + audioRef.current.playbackRate = playerState.playbackRate; + } + + try { + await audioRef.current.play(); + updateArticleStatus(id, PlaybackStatus.PLAYING); + } catch (e) { + console.error("Play error", e); + setPlayerState(prev => ({ ...prev, isPlaying: false })); + } + } + }, [queue, playerState.currentArticleId, playerState.playbackRate, playerState.selectedVoice]); + + const pausePlayback = useCallback(() => { + audioRef.current.pause(); + setPlayerState(prev => ({ ...prev, isPlaying: false })); + if (playerState.currentArticleId) { + updateArticleStatus(playerState.currentArticleId, PlaybackStatus.PAUSED); + } + }, [playerState.currentArticleId]); + + const handleSpeedChange = (newSpeed: number) => { + // Clamp + const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed)); + setPlayerState(prev => ({ ...prev, playbackRate: speed })); + if (audioRef.current) { + audioRef.current.playbackRate = speed; + } + }; + + // Auto-Advance Logic + useEffect(() => { + const audio = audioRef.current; + + const handleEnded = () => { + const currentId = playerState.currentArticleId; + if (currentId) { + updateArticleStatus(currentId, PlaybackStatus.COMPLETED); + + // Find next + const currentIndex = queue.findIndex(a => a.id === currentId); + if (currentIndex !== -1 && currentIndex < queue.length - 1) { + const nextId = queue[currentIndex + 1].id; + playArticle(nextId); + } else { + setPlayerState(prev => ({ ...prev, isPlaying: false })); + } + } + }; + + audio.addEventListener('ended', handleEnded); + return () => { + audio.removeEventListener('ended', handleEnded); + }; + }, [playerState.currentArticleId, queue, playArticle]); + + + // -- Render -- + + const currentArticle = getCurrentArticle(); + const viewingArticle = getViewingArticle(); + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

NewsCaster AI

+
+ + setPlayerState(prev => ({ ...prev, selectedVoice: v }))} + disabled={playerState.isPlaying} + /> +
+
+ + {/* Main Content - Split Layout */} +
+ + {/* Left Column: Controls & Queue (5 cols) */} +
+ {/* Input Section */} +
+ setInputUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} + /> + +
+ + {/* Queue List */} +
+
+

Up Next

+ {queue.length} articles +
+ +
+ {queue.length === 0 ? ( +
+

No articles queued.

+
+ ) : ( + queue.map(article => ( +
setViewId(article.id)} className="cursor-pointer"> + playArticle(article.id)} + onPause={pausePlayback} + onRemove={() => { + if (playerState.currentArticleId === article.id) { + pausePlayback(); + setPlayerState(prev => ({ ...prev, currentArticleId: null })); + } + setQueue(prev => prev.filter(a => a.id !== article.id)); + if (viewId === article.id) setViewId(null); + }} + /> +
+ )) + )} +
+
+
+ + {/* Right Column: Reader View (7 cols) */} +
+ +
+ + {/* Mobile: Reader View appears below if selected */} +
+ {viewingArticle && ( +
+

Article Reader

+
+ +
+
+ )} +
+
+ + {/* Sticky Player */} +
+
+ + {/* Current Track Info */} +
+ {currentArticle ? ( +
+

{currentArticle.title}

+

Playing from queue

+
+ ) : ( +
Ready to play
+ )} +
+ + {/* Controls */} +
+ + {/* Speed Control */} +
+ +
+ + {playerState.playbackRate.toFixed(1)}x + +
+
+ + {/* Main Transport */} +
+ + + + + +
+
+
+
+
+ ); +} diff --git a/README.md b/README.md index 2241000..c399b6a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1a8wkyYOUvPDWvUXbrtN2dznWVZ-VdSDJ + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/QueueItem.tsx b/components/QueueItem.tsx new file mode 100644 index 0000000..3650049 --- /dev/null +++ b/components/QueueItem.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Article, PlaybackStatus } from '../types'; +import { Play, Pause, Loader2, AlertCircle, FileText, Headphones } from 'lucide-react'; + +interface QueueItemProps { + article: Article; + isActive: boolean; + isPlaying: boolean; + onPlay: () => void; + onPause: () => void; + onRemove: () => void; +} + +export const QueueItem: React.FC = ({ + article, + isActive, + isPlaying, + onPlay, + onPause, + onRemove +}) => { + + const getStatusIcon = () => { + switch (article.status) { + case PlaybackStatus.LOADING_TEXT: + return ; + case PlaybackStatus.LOADING_AUDIO: + return ; + case PlaybackStatus.ERROR: + return ; + case PlaybackStatus.PLAYING: + return
+
+
+
+
; + default: + return ; + } + }; + + const isReady = article.status === PlaybackStatus.READY || article.status === PlaybackStatus.PAUSED || article.status === PlaybackStatus.PLAYING || article.status === PlaybackStatus.COMPLETED; + + return ( +
+
+ {getStatusIcon()} +
+ +
+

+ {article.title || article.url} +

+

+ {article.url} +

+ {article.errorMessage && ( +

{article.errorMessage}

+ )} +
+ +
+ {isReady && ( + + )} + +
+
+ ); +}; \ No newline at end of file diff --git a/components/ReaderView.tsx b/components/ReaderView.tsx new file mode 100644 index 0000000..8858151 --- /dev/null +++ b/components/ReaderView.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Article } from '../types'; +import { FileText } from 'lucide-react'; + +interface ReaderViewProps { + article?: Article | null; +} + +export const ReaderView: React.FC = ({ article }) => { + if (!article) { + return ( +
+ +

Select an article to read along

+

The text will appear here while you listen.

+
+ ); + } + + // Split text by newlines to create paragraphs + const paragraphs = article.text + ? article.text.split('\n').filter(p => p.trim().length > 0) + : []; + + return ( +
+
+

+ {article.title} +

+ + {new URL(article.url).hostname} + +
+ +
+ {paragraphs.length > 0 ? ( + paragraphs.map((paragraph, idx) => ( +

+ {paragraph} +

+ )) + ) : ( +
+
+
+
+

Extracting article content...

+
+ )} +
+
+ ); +}; diff --git a/components/VoiceSelector.tsx b/components/VoiceSelector.tsx new file mode 100644 index 0000000..ef9ea33 --- /dev/null +++ b/components/VoiceSelector.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { VoiceName } from '../types'; +import { AVAILABLE_VOICES } from '../constants'; +import { Mic } from 'lucide-react'; + +interface VoiceSelectorProps { + selectedVoice: VoiceName; + onVoiceChange: (voice: VoiceName) => void; + disabled?: boolean; +} + +export const VoiceSelector: React.FC = ({ selectedVoice, onVoiceChange, disabled }) => { + return ( +
+ + +
+ ); +}; \ No newline at end of file diff --git a/constants.ts b/constants.ts new file mode 100644 index 0000000..7625d56 --- /dev/null +++ b/constants.ts @@ -0,0 +1,15 @@ +import { VoiceName } from './types'; + +export const AVAILABLE_VOICES = [ + { name: VoiceName.Puck, label: 'Puck (Male, Standard)' }, + { name: VoiceName.Charon, label: 'Charon (Male, Deep)' }, + { name: VoiceName.Kore, label: 'Kore (Female, Soothing)' }, + { name: VoiceName.Fenrir, label: 'Fenrir (Male, Energetic)' }, + { name: VoiceName.Zephyr, label: 'Zephyr (Female, Clear)' }, +]; + +export const MIN_SPEED = 0.5; +export const MAX_SPEED = 3.5; +export const SPEED_STEP = 0.5; + +export const SAMPLE_RATE = 24000; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..5ed3d7f --- /dev/null +++ b/index.html @@ -0,0 +1,40 @@ + + + + + + NewsCaster AI + + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..3a41164 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "NewsCaster AI", + "description": "A realistic AI news reader that converts article URLs to speech with adjustable speed and pitch preservation.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9649cf7 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "newscaster-ai", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.554.0", + "@google/genai": "^1.30.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/services/audioUtils.ts b/services/audioUtils.ts new file mode 100644 index 0000000..1f934a1 --- /dev/null +++ b/services/audioUtils.ts @@ -0,0 +1,59 @@ +/** + * Converts a Base64 string (Raw PCM) to a Uint8Array. + */ +export const base64ToUint8Array = (base64: string): Uint8Array => { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; + +/** + * Wraps raw PCM data in a WAV container so it can be played by standard HTML5 Audio elements. + * This allows us to use `playbackRate` with automatic pitch preservation. + */ +export const createWavBlob = (pcmData: Uint8Array, sampleRate: number = 24000): Blob => { + const numChannels = 1; + const bitsPerSample = 16; + const byteRate = (sampleRate * numChannels * bitsPerSample) / 8; + const blockAlign = (numChannels * bitsPerSample) / 8; + const dataSize = pcmData.length; + const chunkSize = 36 + dataSize; + + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + // RIFF chunk descriptor + writeString(view, 0, 'RIFF'); + view.setUint32(4, chunkSize, true); + writeString(view, 8, 'WAVE'); + + // fmt sub-chunk + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM) + view.setUint16(20, 1, true); // AudioFormat (1 for PCM) + view.setUint16(22, numChannels, true); // NumChannels + view.setUint32(24, sampleRate, true); // SampleRate + view.setUint32(28, byteRate, true); // ByteRate + view.setUint16(32, blockAlign, true); // BlockAlign + view.setUint16(34, bitsPerSample, true); // BitsPerSample + + // data sub-chunk + writeString(view, 36, 'data'); + view.setUint32(40, dataSize, true); + + // Write PCM data + const dataView = new Uint8Array(buffer, 44); + dataView.set(pcmData); + + return new Blob([buffer], { type: 'audio/wav' }); +}; + +const writeString = (view: DataView, offset: number, string: string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } +}; \ No newline at end of file diff --git a/services/geminiService.ts b/services/geminiService.ts new file mode 100644 index 0000000..6b96f80 --- /dev/null +++ b/services/geminiService.ts @@ -0,0 +1,283 @@ +import { GoogleGenAI, Modality } from '@google/genai'; +import { VoiceName } from '../types'; + +const getAiClient = () => { + const apiKey = process.env.API_KEY; + if (!apiKey) { + throw new Error("API Key is missing"); + } + return new GoogleGenAI({ apiKey }); +}; + +/** + * Helper to ensure URL has protocol. + * Proxies often fail if 'http/https' is missing. + */ +const normalizeUrl = (url: string) => { + let cleanUrl = url.trim(); + if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) { + return `https://${cleanUrl}`; + } + return cleanUrl; +}; + +/** + * List of CORS proxies to try in order. + * This improves reliability if one service is down or blocked. + */ +const PROXY_PROVIDERS = [ + // AllOrigins: Generally the most reliable for raw text + (url: string) => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`, + + // CodeTabs: Good fallback, handles redirects well + (url: string) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`, + + // CORSProxy.io: Fast but sometimes has strict CORS headers + (url: string) => `https://corsproxy.io/?${encodeURIComponent(url)}`, + + // ThingProxy: Another fallback + (url: string) => `https://thingproxy.freeboard.io/fetch/${url}` +]; + +/** + * Cleans raw HTML by removing scripts, styles, and non-content elements. + * This acts like a dedicated "Reader Mode" pre-processor. + */ +function cleanAndMinifyHtml(rawHtml: string): string { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(rawHtml, 'text/html'); + + // 1. Remove heavy technical tags + const technicalTags = ['script', 'style', 'noscript', 'iframe', 'svg', 'link', 'meta', 'button', 'input', 'form', 'img', 'picture', 'video']; + technicalTags.forEach(tag => { + const elements = doc.querySelectorAll(tag); + elements.forEach(el => el.remove()); + }); + + // 2. Remove semantic layout tags that are usually clutter + const layoutTags = ['nav', 'footer', 'aside', 'header']; + layoutTags.forEach(tag => { + const elements = doc.querySelectorAll(tag); + elements.forEach(el => el.remove()); + }); + + // 3. Remove common ad/social/cookie containers by class/id heuristics + const junkSelectors = [ + '[class*="ad-"]', '[id*="ad-"]', + '[class*="cookie"]', '[id*="cookie"]', + '[class*="newsletter"]', '[id*="newsletter"]', + '[class*="social"]', '[class*="share"]', + '[class*="comment"]', '[id*="comment"]', + '[class*="recommended"]', '[class*="related"]' + ]; + + junkSelectors.forEach(selector => { + try { + const elements = doc.querySelectorAll(selector); + elements.forEach(el => el.remove()); + } catch (e) { + // Ignore invalid selector errors + } + }); + + // 4. Return the cleanest possible content + // If there is a specific article tag, it's usually the best bet. + const article = doc.querySelector('article'); + if (article && article.textContent && article.textContent.length > 200) { + return article.innerHTML; + } + + const main = doc.querySelector('main'); + if (main && main.textContent && main.textContent.length > 200) { + return main.innerHTML; + } + + // Fallback: Return the cleaned body + return doc.body.innerHTML; + } catch (e) { + console.warn("HTML cleaning failed, using raw string", e); + return rawHtml; + } +} + +/** + * Fetches Raw HTML using a rotation of proxies. + */ +async function fetchRawHtml(inputUrl: string): Promise { + const url = normalizeUrl(inputUrl); + let lastError; + + for (const provider of PROXY_PROVIDERS) { + let proxyUrl = ''; + try { + proxyUrl = provider(url); + console.log(`Fetching via proxy: ${proxyUrl}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout per proxy + + // We purposely do NOT add complex headers here. + // Adding headers like 'X-Requested-With' often triggers a CORS Preflight (OPTIONS) request, + // which many simple free proxies do not handle correctly, causing "Load failed". + const response = await fetch(proxyUrl, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Proxy returned status ${response.status}`); + } + + const text = await response.text(); + + // Simple validation to ensure we got something resembling HTML/Text + if (text && text.length > 100) { + return text; + } else { + throw new Error("Response too short, likely blocked or empty."); + } + } catch (e) { + console.warn(`Proxy attempt failed for ${proxyUrl}:`, e); + lastError = e; + } + } + + throw lastError || new Error("Unable to access article content via proxies."); +} + +/** + * Uses Gemini to extract clean text from the raw HTML. + */ +async function parseHtmlWithGemini(html: string, url: string): Promise<{ title: string; text: string }> { + const ai = getAiClient(); + + const cleanedHtml = cleanAndMinifyHtml(html); + + if (cleanedHtml.length < 100) { + throw new Error("Content appears to be empty after cleaning. The site might require JavaScript to render."); + } + + const prompt = ` + SOURCE URL: ${url} + + TASK: + I have provided the HTML source of a webpage. + Your job is to act as a dumb "Text Extractor" tool. + Extract the TITLE and the FULL BODY TEXT of the main article. + + CRITICAL RULES: + 1. VERBATIM: Do NOT rewrite, summarize, or fix the text. Output it exactly as written in the HTML. + 2. FULL TEXT: Do NOT stop early. Process the entire HTML to find the end of the article. + 3. CLEANING: Exclude ads, navigation, "read more" links, and comments. + 4. FORMATTING: Keep the paragraphs intact. + 5. FAILURE: If the HTML contains a CAPTCHA, Login Screen, or Paywall message instead of an article, return the text "PAYWALL_DETECTED". + + Output Format: + ===TITLE_START=== + (Headline) + ===TITLE_END=== + ===TEXT_START=== + (Paragraph 1) + + (Paragraph 2) + + ... + + (Final Paragraph) + ===TEXT_END=== + + HTML CONTENT: + ${cleanedHtml} + `; + + const response = await ai.models.generateContent({ + model: 'gemini-2.5-flash', + contents: prompt, + config: { + temperature: 0.0, // Strict deterministic output + } + }); + + return parseResponse(response.text || ""); +} + +function parseResponse(rawText: string): { title: string; text: string } { + if (rawText.includes("PAYWALL_DETECTED")) { + throw new Error("This article is behind a paywall or anti-bot protection and cannot be accessed directly."); + } + + const titleMatch = rawText.match(/===TITLE_START===([\s\S]*?)===TITLE_END===/); + const textMatch = rawText.match(/===TEXT_START===([\s\S]*?)===TEXT_END===/); + + const title = titleMatch ? titleMatch[1].trim() : ""; + const text = textMatch ? textMatch[1].trim() : ""; + + // Fallback logic for malformed AI responses + if (!text && rawText.length > 100) { + // If AI failed to use delimiters but returned text, try to use it if it looks like an article + if (!rawText.includes("===TEXT_START===") && rawText.length > 200) { + return { title: "Extracted Content", text: rawText }; + } + } + + if (!text || text.length < 50) { + throw new Error("Could not extract article text. The page structure might be too complex or empty."); + } + + return { title, text }; +} + +/** + * Main Extraction Function + */ +export const extractArticleContent = async (url: string): Promise<{ title: string; text: string }> => { + console.log("Attempting to extract:", url); + + try { + // 1. Fetch Raw HTML via Proxy + const html = await fetchRawHtml(url); + + // 2. Parse with Gemini + console.log("HTML fetched (" + html.length + " chars). Parsing..."); + return await parseHtmlWithGemini(html, url); + + } catch (error: any) { + console.error("Extraction failed:", error); + // We intentionally DO NOT fall back to Google Search here, as per user request. + // We want to fail if we can't get the direct content. + throw new Error(error.message || "Failed to access article directly."); + } +}; + +/** + * Generates speech audio from text. + */ +export const generateSpeechFromText = async (text: string, voice: VoiceName): Promise => { + const ai = getAiClient(); + + const response = await ai.models.generateContent({ + model: 'gemini-2.5-flash-preview-tts', + contents: { + parts: [{ text: text }] + }, + config: { + responseModalities: [Modality.AUDIO], + speechConfig: { + voiceConfig: { + prebuiltVoiceConfig: { + voiceName: voice + } + } + } + } + }); + + const base64Audio = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; + + if (!base64Audio) { + throw new Error("No audio data received from model"); + } + + return base64Audio; +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..7b1ae44 --- /dev/null +++ b/types.ts @@ -0,0 +1,35 @@ +export enum VoiceName { + Puck = 'Puck', + Charon = 'Charon', + Kore = 'Kore', + Fenrir = 'Fenrir', + Zephyr = 'Zephyr', +} + +export enum PlaybackStatus { + IDLE = 'IDLE', + LOADING_TEXT = 'LOADING_TEXT', + LOADING_AUDIO = 'LOADING_AUDIO', + READY = 'READY', + PLAYING = 'PLAYING', + PAUSED = 'PAUSED', + ERROR = 'ERROR', + COMPLETED = 'COMPLETED' +} + +export interface Article { + id: string; + url: string; + title: string; + text: string; + audioUrl?: string; // Blob URL for the WAV file + status: PlaybackStatus; + errorMessage?: string; +} + +export interface PlayerState { + isPlaying: boolean; + playbackRate: number; + currentArticleId: string | null; + selectedVoice: VoiceName; +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } + } + }; +});