## New Features - Article Summary Mode: AI-generated 30-second summaries with complexity analysis - Reading Stats Dashboard: Track articles read, listening time, and streaks - Bookmark/Resume: Auto-save progress when pausing, resume from where you left off - Audio Export: Export articles as downloadable WAV files - RSS Feed Manager: Subscribe to feeds with real-time validation and 31+ recommendations - Smart Speed: Auto-adjust playback based on article complexity - Voice Moods: Quick presets for different listening scenarios ## RSS Enhancements - Expanded recommendations from 8 to 31 sources across 5 categories: * General News (9 sources) * Technology (8 sources) * Business & Finance (5 sources) * Science & Research (5 sources) * International News (4 sources) - Real-time URL validation with visual feedback - Detailed error messages for different failure scenarios - Always-visible categorized recommendations - Auto-loading articles when feeds are added ## Bug Fixes - Fixed voice selection: Selected voice now consistently applies to playback - Implemented voice generation counter to prevent voice mixing between paragraphs - Fixed speed control to snap to clean 0.5 increments (1.0, 1.5, 2.0, etc.) - Fixed dark mode toggle by configuring Tailwind CDN for class-based dark mode - Removed vibe visualizer animation ## UI/UX Improvements - Redesigned voice selector with prominent voice panel and preview functionality - Added voice cards with emojis and descriptions - Enhanced feature toolbar with quick access to all new features - Improved reader view with better typography and reading modes - Added ambient reading modes (clean, sepia, night light) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
import { GoogleGenAI, Modality } from '@google/genai';
|
|
import { VoiceName } from '../types';
|
|
import { normalizeUrl } from '../utils/url';
|
|
|
|
const getAiClient = () => {
|
|
const apiKey = import.meta.env.VITE_API_KEY;
|
|
|
|
if (!apiKey) {
|
|
throw new Error(
|
|
"Gemini API key is missing. Set VITE_API_KEY in your .env.local file (e.g., VITE_API_KEY=your_key_here)."
|
|
);
|
|
}
|
|
|
|
return new GoogleGenAI({ apiKey });
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
// We remove these because they consume tokens and provide no semantic value for text extraction.
|
|
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());
|
|
});
|
|
|
|
// NOTE: We intentionally DO NOT remove semantic tags like <nav>, <footer>, or use class-based heuristics.
|
|
// Previous versions tried to identify <article> or remove .ad-container, but this often caused
|
|
// the "Content appears to be empty" error on sites with unique structures.
|
|
// Gemini Flash has a large enough context window to ingest the entire <body> and intelligently extract the article.
|
|
|
|
// Return the body. Trust Gemini to find the needle in the haystack.
|
|
return doc.body ? doc.body.innerHTML : rawHtml;
|
|
} 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<string> {
|
|
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(), 15000); // 15s 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 a 30-second summary of an article.
|
|
*/
|
|
export const generateArticleSummary = async (text: string, title: string): Promise<string> => {
|
|
const ai = getAiClient();
|
|
|
|
const prompt = `
|
|
You are a professional news summarizer. Create a brief, engaging summary of the following article.
|
|
|
|
TITLE: ${title}
|
|
|
|
ARTICLE TEXT:
|
|
${text.substring(0, 8000)} ${text.length > 8000 ? '...[truncated]' : ''}
|
|
|
|
RULES:
|
|
1. Create a summary that can be read aloud in about 30 seconds (approximately 80-100 words)
|
|
2. Start with the most important/newsworthy point
|
|
3. Include 2-3 key facts or takeaways
|
|
4. Use clear, conversational language suitable for audio
|
|
5. Do NOT use bullet points or formatting - write in flowing sentences
|
|
6. End with a brief mention of why this matters
|
|
|
|
Output ONLY the summary text, nothing else.
|
|
`;
|
|
|
|
const response = await ai.models.generateContent({
|
|
model: 'gemini-2.5-flash',
|
|
contents: prompt,
|
|
config: {
|
|
temperature: 0.3,
|
|
}
|
|
});
|
|
|
|
return response.text?.trim() || "Unable to generate summary.";
|
|
};
|
|
|
|
/**
|
|
* Analyzes text complexity for Smart Speed feature.
|
|
*/
|
|
export const analyzeTextComplexity = (text: string): {
|
|
complexity: 'simple' | 'moderate' | 'complex';
|
|
wordCount: number;
|
|
estimatedReadTime: number;
|
|
} => {
|
|
const words = text.split(/\s+/).filter(w => w.length > 0);
|
|
const wordCount = words.length;
|
|
|
|
// Calculate average word length
|
|
const avgWordLength = words.reduce((sum, w) => sum + w.length, 0) / wordCount;
|
|
|
|
// Count sentences (rough estimate)
|
|
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
|
const avgSentenceLength = wordCount / Math.max(1, sentences);
|
|
|
|
// Count complex indicators
|
|
const complexWords = words.filter(w => w.length > 10).length;
|
|
const complexWordRatio = complexWords / wordCount;
|
|
|
|
// Score complexity (0-100)
|
|
let score = 0;
|
|
score += Math.min(30, avgWordLength * 4); // Longer words = more complex
|
|
score += Math.min(30, avgSentenceLength * 1.5); // Longer sentences = more complex
|
|
score += Math.min(40, complexWordRatio * 200); // More complex words = more complex
|
|
|
|
// Determine complexity level
|
|
let complexity: 'simple' | 'moderate' | 'complex';
|
|
if (score < 35) {
|
|
complexity = 'simple';
|
|
} else if (score < 55) {
|
|
complexity = 'moderate';
|
|
} else {
|
|
complexity = 'complex';
|
|
}
|
|
|
|
// Estimate read time (words per minute varies by complexity)
|
|
const wpm = complexity === 'simple' ? 180 : complexity === 'moderate' ? 150 : 130;
|
|
const estimatedReadTime = Math.ceil(wordCount / wpm);
|
|
|
|
return { complexity, wordCount, estimatedReadTime };
|
|
};
|
|
|
|
/**
|
|
* Generates speech audio from text.
|
|
*/
|
|
export const generateSpeechFromText = async (text: string, voice: VoiceName): Promise<string> => {
|
|
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;
|
|
}; |