Spaces:
Running
Download implementation_plan.md from Luca448/APP-Backend: direct link, hf CLI and curl.
- Browser
- Download file 36.2 kB
-
https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/implementation_plan.md
- Command line
-
hf download hf://spaces/Luca448/APP-Backend/implementation_plan.md
-
curl -L -o implementation_plan.md https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/implementation_plan.md
Technischer Implementierungsplan: Entdecken-Feed 2.0 (v3 – Final)
Übergabe-Dokument für den Programmierer. Jede Sektion enthält exakte Spezifikationen, Datenstrukturen und Code-Skelette. Stand: Juli 2026.
0. Hosting-Constraint: HuggingFace Spaces (Free Tier)
HuggingFace Spaces Free Tier schläft nach ~15 Minuten Inaktivität ein! Ein
setInterval-Cron-Job (alle 6h) feuert NIEMALS zuverlässig. Alle zeitgesteuerten Hintergrund-Prozesse sind auf Free Spaces nutzlos.
Lösung: On-Demand-Generierung statt Cron
User öffnet Entdecken-Tab
│
▼
Frontend: GET /api/news → Backend antwortet mit { articles, lastRun }
│
▼
Frontend prüft: (Date.now() - lastRun) > 6 Stunden?
│
┌────┴────┐
│ NEIN │ JA
│ ▼
│ Frontend: POST /api/news/generate
│ (zeigt Skeleton-Loader)
│ │
│ ▼
│ Backend generiert im Hintergrund
│ Frontend pollt GET /api/news alle 8s
│ │
│ ▼
│ Neue Artikel erscheinen → Loader verschwindet
│ │
└─────────┘
│
▼
Artikel werden angezeigt
Code-Konsequenzen:
setInterval()ininitNewsService()wird entferntGET /api/newsliefert ab sofort:res.json({ articles: data.articles, lastRun: data.lastRun })- Frontend übernimmt die Trigger-Logik
1. Modell-Auswahl (Stand: Juli 2026)
KI-Modelle veralten schnell! Die folgenden Modellnamen wurden am 06.07.2026 recherchiert. Vor der Implementierung bitte auf ai.google.dev/gemini-api/docs/models verifizieren.
Gemini-Modelle (Free Tier via Google AI Studio)
| Modell | API-Name | Free Tier | Stärken | Einsatz im Plan |
|---|---|---|---|---|
| Gemini 3.5 Flash | gemini-3.5-flash |
✅ Ja (~1500 RPD) | Neuestes Flash-Modell (Mai 2026), top bei Coding & Agentic Tasks | Primär für alle Calls |
| Gemini 3.1 Flash-Lite | gemini-3.1-flash-lite |
✅ Ja | Ultra-günstig, schnell | Fallback bei Rate-Limits |
| Gemini 2.5 Flash | gemini-2.5-flash |
✅ Ja | Bewährt, stabil | Letzter Fallback |
gemini-3.5-pro |
❌ Nur Paid | Frontier-Qualität | Nicht nutzbar (kein Free Tier) |
Modell-Kaskade (Fallback-Strategie)
// EXAKTE Modell-Kaskade für den Backend-Code:
const MODEL_CASCADE = [
'gemini-3.5-flash', // Primär: Neuestes & bestes Free-Modell (Mai 2026)
'gemini-3.1-flash-lite', // Fallback 1: Leichtgewichtig, kaum Rate-Limits
'gemini-2.5-flash', // Fallback 2: Bewährt, stabil
];
Der aktuelle Code nutzt
gemini-3.5-flashals Primär-Modell (Zeile 193 in news-service.js). Das ist korrekt und aktuell.gemini-2.5-flashist der letzte Fallback. NICHTgemini-2.0oder andere veraltete Modelle verwenden!
Alternative Free-Tier-Anbieter (für spätere Erweiterung)
| Anbieter | Modelle | Free Tier | Sinnvoll für |
|---|---|---|---|
| Groq | Llama-basiert, GPT-OSS | ✅ ~30 RPM, ~14.400 RPD | Ultra-schnelle Inference (z.B. für Stufe 2 Extraction) |
| OpenRouter | 50+ Modelle aggregiert | ✅ Begrenzte Free Credits | Multi-Modell-Fallback |
| Mistral | Mistral Small, Codestral | ✅ Free Tier | Europäische Alternative |
Für den MVP fokussieren wir uns auf Gemini Free Tier – das reicht vollkommen aus. Die anderen Anbieter sind optionale Erweiterungen.
2. Tavily-Credit-Strategie
Ist-Zustand
| Feature | Calls/Event | Frequenz | Tavily-Credits/Tag |
|---|---|---|---|
| Discover Feed | 3 (3 Topics × 1) | alle 6h (4×/Tag) | 12 (basic = 1 Credit) |
| Deep Research | 8 Calls pro Session | User-triggered | 16 (advanced = 2 Credits) |
| Gesamt-Baseline | — | — | ~12 + 16 pro DR |
Soll-Zustand: Zero-Tavily-Discover + Power Deep Research
| Feature | Tavily-Credits | Strategie |
|---|---|---|
| Discover Feed | 0 | Google News RSS + Jina Reader + Wikipedia (alles kostenlos) |
| Deep Research | bis zu 16 pro Session | Unverändert, alle 1000 Credits/Monat stehen zur Verfügung |
Resultat: ~62 Deep Research Sessions/Monat (statt ~40)
3. Free-Tier-Budget (alles $0)
| Service | Free Tier | Unser Verbrauch/Tag | Headroom |
|---|---|---|---|
| Gemini 3.5 Flash | ~1500 RPD, ~1M TPD | ~13-52 Requests | ✅ 96%+ frei |
| Tavily | 1000 Credits/Monat | 0 für Discover | ✅ 100% für Deep Research |
Jina Reader (r.jina.ai) |
20 RPM ohne Key | 6-18 Calls/Zyklus | ✅ Kostenlos |
| Google Trends RSS | Unbegrenzt | 1-4 Calls/Tag | ✅ Kostenlos |
| Google News RSS | Unbegrenzt | 1-20 Calls/Tag | ✅ Kostenlos |
| Wikipedia API | Unbegrenzt (mit UA) | 2-12 Calls/Tag | ✅ Kostenlos |
4. Architektur-Übersicht
┌─────────────────────────────────────────────────────────────────┐
│ ON-DEMAND TRIGGER (User öffnet Entdecken-Tab) │
│ Bedingung: lastRun > 6h oder manueller Refresh │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Phase 0: TREND-DETEKTION (kostenlos) │ │
│ │ • Google Trends RSS (DE) → 10-20 Trending-Topics │ │
│ │ • Google News RSS Headlines → Top-5 News-Themen │ │
│ │ • User-Topics → mit Datums-Enrichment │ │
│ │ → Merge + Deduplizierung → 8-12 Final-Topics │ │
│ └─────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Phase 1: DUPLIKAT-VORFILTER │ │
│ │ Jaccard-Similarity gegen existierende Artikel-Titel │ │
│ │ + Vergleich mit data.lastTrends │ │
│ │ → Nur NEUE/VERÄNDERTE Topics weiter → max 6 │ │
│ └─────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Phase 2: QUERY-DEKOMPOSITION (1× Gemini 3.5 Flash) │ │
│ │ Batch-Call: ALLE Topics → je 3 Sub-Queries │ │
│ └─────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Phase 3: WEB-RECHERCHE (kostenlos) │ │
│ │ Pro Sub-Query: │ │
│ │ • Google News RSS Search → Headlines + Links │ │
│ │ Pro Topic (Top 3 URLs): │ │
│ │ ★ Jina Reader (r.jina.ai) → VOLLER Artikeltext ★ │ │
│ │ • Wikipedia API → Kontext + Bilder │ │
│ │ → URL-Deduplizierung gegen data.usedUrls │ │
│ └─────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Phase 4: STUFE 1 – Grounded Generation │ │
│ │ (1× Gemini 3.5 Flash pro Topic) │ │
│ │ Input: VOLLE Artikeltexte + Wiki-Kontext + Datum │ │
│ │ → Umfassender Artikel (800-1200 Wörter) │ │
│ └─────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Phase 5: STUFE 2 – Structured Extraction │ │
│ │ (1× Gemini 3.5 Flash pro Topic, temp=0.1) │ │
│ │ → JSON: { title, snippet, content, category, sources } │ │
│ │ + Title-Similarity-Check gegen existierende Artikel │ │
│ └─────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Phase 6: SPEICHERN + CLEANUP │ │
│ │ • data.articles.unshift(article) │ │
│ │ • data.usedUrls updaten (48h TTL) │ │
│ │ • data.lastTrends updaten │ │
│ │ • Max 50 Artikel behalten │ │
│ │ • saveData() │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Kosten pro Zyklus: $0.00 (Gemini Free + Jina Free + RSS/Wiki) │
└─────────────────────────────────────────────────────────────────┘
5. Datenstruktur (news.json – erweitert)
{
"topics": ["KI Tools", "Abitur Vorbereitung"],
"articles": [
{
"id": "1720281600000_a3f2x",
"topic": "KI Tools",
"category": "Technologie", // Von Gemini klassifiziert
"title": "Neues KI-Tool revolutioniert...",
"snippet": "OpenAI hat heute...", // Max 150 Zeichen
"content": "## Vollständiger Artikel...", // Markdown, 800-1200 Wörter
"sources": [
{ "title": "heise.de", "url": "https://...", "snippet": "..." }
],
"imageUrl": "https://...",
"timestamp": 1720281600000,
"isTrending": true,
"trendSource": "google_trends" // "google_trends"|"google_news"|"user_topic"
}
],
"lastRun": 1720281600000,
"lastTrends": ["Hitzewelle", "EM 2026"], // NEU
"usedUrls": { // NEU: URL-Dedup mit 48h TTL
"https://heise.de/article/123": 1720281600000
}
}
6. Detaillierte Phase-Spezifikationen
Phase 0: Trend-Detektion (kostenlos)
async function detectTrends() {
const trends = [];
// === Quelle 1: Google Trends RSS (DE) – KOSTENLOS ===
try {
const url = 'https://trends.google.de/trending/rss?geo=DE';
const res = await fetch(url);
const xml = await res.text();
const $ = cheerio.load(xml, { xmlMode: true });
$('item').each((i, el) => {
if (i >= 10) return;
const title = $(el).find('title').text().trim();
const traffic = $(el).find('ht\\:approx_traffic, approx_traffic').text();
const newsItems = [];
$(el).find('ht\\:news_item_title, news_item_title').each((_, newsEl) => {
newsItems.push($(newsEl).text());
});
trends.push({
topic: title, traffic, relatedHeadlines: newsItems.slice(0, 3),
source: 'google_trends'
});
});
} catch (e) {
console.error('[TrendDetector] Google Trends RSS failed:', e.message);
}
// === Quelle 2: Google News RSS Top-Headlines (DE) – KOSTENLOS ===
try {
const url = 'https://news.google.com/rss?hl=de&gl=DE&ceid=DE:de';
const res = await fetch(url);
const xml = await res.text();
const $ = cheerio.load(xml, { xmlMode: true });
$('item').each((i, el) => {
if (i >= 5) return;
const title = $(el).find('title').text().trim();
const cleanTitle = title.replace(/\s*-\s*[^-]+$/, '').trim();
if (!trends.some(t => jaccardSimilarity(t.topic, cleanTitle) > 0.5)) {
trends.push({
topic: cleanTitle, traffic: 'headline', relatedHeadlines: [],
source: 'google_news'
});
}
});
} catch (e) {
console.error('[TrendDetector] Google News RSS failed:', e.message);
}
return trends;
}
User-Topics Enrichment
function enrichUserTopics(userTopics) {
const now = new Date();
const monthYear = now.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
return userTopics.map(topic => ({
topic: topic,
searchQuery: `${topic} Neuigkeiten ${monthYear}`,
traffic: 'user_topic', relatedHeadlines: [], source: 'user_topic'
}));
}
Phase 1: Duplikat-Vorfilter
function jaccardSimilarity(str1, str2) {
const normalize = s => s.toLowerCase()
.replace(/[^\wäöüß]/g, ' ').split(/\s+/).filter(w => w.length > 2);
const set1 = new Set(normalize(str1));
const set2 = new Set(normalize(str2));
if (set1.size === 0 || set2.size === 0) return 0;
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return intersection.size / union.size;
}
function filterDuplicateTopics(newTopics, existingArticles, lastTrends) {
return newTopics.filter(trend => {
const isRepeatTrend = lastTrends.some(lt => jaccardSimilarity(trend.topic, lt) > 0.7);
const recentArticles = existingArticles
.filter(a => Date.now() - a.timestamp < 24 * 60 * 60 * 1000);
const hasSimilarArticle = recentArticles
.some(a => jaccardSimilarity(trend.topic, a.title) > 0.5);
if (isRepeatTrend && hasSimilarArticle) {
console.log(`[DupFilter] Skipping "${trend.topic}" – already covered`);
return false;
}
return true;
});
}
Phase 2: Query-Dekomposition (1 Gemini-Call für ALLE Topics)
async function decomposeTopics(topics, apiKey) {
const topicsList = topics.map((t, i) =>
`${i+1}. "${t.topic}" (Quelle: ${t.source})`
).join('\n');
const prompt = `Du bist ein Recherche-Assistent. Heute ist der ${new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}.
Für jedes Thema: Generiere exakt 3 präzise, unterschiedliche Suchphrasen auf Deutsch, die verschiedene Aspekte abdecken. Die Phrasen sollen AKTUELLE Informationen finden.
THEMEN:
${topicsList}
Antworte EXAKT als JSON:
{
"decomposed": [
{
"originalTopic": "Thema",
"queries": ["Phrase 1", "Phrase 2", "Phrase 3"],
"category": "Technologie|Wissenschaft|Politik|Wirtschaft|Bildung|Gesellschaft|Sport|Kultur|Gesundheit"
}
]
}`;
return JSON.parse(await callGemini(prompt, apiKey, {
temperature: 0.3, responseMimeType: 'application/json'
}));
}
Phase 3: Web-Recherche + ★ Jina Reader Content-Enrichment ★
Das ist der Game-Changer gegenüber dem alten Plan. Statt nur RSS-Snippets (1 Satz) bekommt Gemini den vollen Artikeltext über Jina Reader – kostenlos, 20 RPM ohne API-Key.
async function researchTopic(decomposedTopic) {
const allResults = [];
const allImages = [];
// === SCHRITT 1: Google News RSS Search (kostenlos, pro Sub-Query) ===
for (const query of decomposedTopic.queries) {
try {
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=de&gl=DE&ceid=DE:de`;
const res = await fetch(url);
const xml = await res.text();
const $ = cheerio.load(xml, { xmlMode: true });
$('item').each((i, el) => {
if (i >= 4) return; // 4 pro Query × 3 Queries = max 12 URLs
const title = $(el).find('title').text();
const link = $(el).find('link').text();
const pubDate = $(el).find('pubDate').text();
const description = $(el).find('description').text().replace(/<[^>]+>/g, '');
if (!allResults.some(r => r.url === link) && !isUrlUsed(link)) {
allResults.push({
title: title.replace(/\s*-\s*[^-]+$/, ''),
url: link,
snippet: description,
fullContent: null, // Wird in Schritt 2 befüllt
pubDate, query
});
}
});
} catch (e) {
console.warn(`[Research] RSS failed for "${query}":`, e.message);
}
await new Promise(r => setTimeout(r, 500));
}
// === SCHRITT 2: ★ Jina Reader – Volle Artikeltexte scrapen ★ (kostenlos) ===
// Nur die Top-3 relevantesten URLs scrapen (Rate Limit schonen)
const urlsToScrape = allResults.slice(0, 3);
for (const result of urlsToScrape) {
try {
const jinaUrl = `https://r.jina.ai/${result.url}`;
const jinaRes = await fetch(jinaUrl, {
headers: {
'Accept': 'text/plain',
// Optional: 'Authorization': 'Bearer jina_xxx' für 500 RPM
}
});
if (jinaRes.ok) {
const fullText = await jinaRes.text();
// Limitiere auf ~2000 Zeichen pro Quelle (Gemini Context schonen)
result.fullContent = fullText.substring(0, 2000);
console.log(`[Jina] Scraped ${result.url} (${fullText.length} chars)`);
}
} catch (e) {
console.warn(`[Jina] Failed to scrape ${result.url}:`, e.message);
}
// Rate Limit: Max 20 RPM ohne Key → 3 Sekunden Pause
await new Promise(r => setTimeout(r, 3000));
}
// === SCHRITT 3: Wikipedia für Kontext + Bilder (kostenlos) ===
try {
const wikiHeaders = { 'User-Agent': 'SchoolMindApp/1.0 (contact@schoolmind.ai)' };
const searchUrl = `https://de.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(decomposedTopic.originalTopic)}&format=json&utf8=&srlimit=2`;
const searchRes = await fetch(searchUrl, { headers: wikiHeaders });
const searchData = await searchRes.json();
for (const result of (searchData.query?.search || []).slice(0, 2)) {
const extractUrl = `https://de.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(result.title)}&prop=extracts|pageimages&exintro=true&explaintext=true&format=json&pithumbsize=800`;
const extractRes = await fetch(extractUrl, { headers: wikiHeaders });
const extractData = await extractRes.json();
const page = Object.values(extractData.query?.pages || {})[0];
if (page?.extract) {
allResults.push({
title: `Wikipedia: ${result.title}`,
url: `https://de.wikipedia.org/wiki/${encodeURIComponent(result.title)}`,
snippet: page.extract.substring(0, 500),
fullContent: page.extract.substring(0, 2000),
pubDate: null, query: 'wikipedia_context'
});
}
if (page?.thumbnail?.source) allImages.push(page.thumbnail.source);
}
} catch (e) {
console.warn('[Research] Wikipedia failed:', e.message);
}
return { results: allResults, images: allImages };
}
URL-Dedup-Helfer
function isUrlUsed(url) {
if (!data.usedUrls) return false;
const entry = data.usedUrls[url];
if (!entry) return false;
if (Date.now() - entry > 48 * 60 * 60 * 1000) { delete data.usedUrls[url]; return false; }
return true;
}
function markUrlsAsUsed(results) {
if (!data.usedUrls) data.usedUrls = {};
const now = Date.now();
for (const r of results) data.usedUrls[r.url] = now;
// Cleanup alte Einträge (> 48h)
for (const [url, ts] of Object.entries(data.usedUrls)) {
if (now - ts > 48 * 60 * 60 * 1000) delete data.usedUrls[url];
}
}
Phase 4: Stufe 1 – Grounded Generation (1× Gemini 3.5 Flash pro Topic)
async function stage1_groundedGeneration(topic, category, searchResults, images, apiKey) {
const today = new Date().toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
});
// Baue Quellen-Text mit VOLLEN Artikeltexten (dank Jina Reader!)
const sourcesText = searchResults.map((s, i) => {
const dateInfo = s.pubDate
? ` (${new Date(s.pubDate).toLocaleDateString('de-DE')})` : '';
const content = s.fullContent
? `\nVOLLER TEXT:\n${s.fullContent}`
: `\nSnippet: ${s.snippet}`;
return `[${i+1}] ${s.title}${dateInfo}\nURL: ${s.url}${content}`;
}).join('\n\n---\n\n');
const imagesText = images.length > 0
? `\n\nVERFÜGBARE BILDER:\n${images.map(url => `- ${url}`).join('\n')}`
: '';
const prompt = `DATUM HEUTE: ${today}
KATEGORIE: ${category}
Du bist ein preisgekrönter Wissenschaftsjournalist für ein anspruchsvolles Schüler-/Studenten-Magazin.
THEMA: "${topic}"
QUELLEN (${searchResults.length} Stück, teilweise mit VOLLEM Artikeltext):
${sourcesText}
${imagesText}
AUFGABE: Schreibe einen UMFASSENDEN, TIEFGRÜNDIGEN Artikel (800-1200 Wörter).
QUALITÄTSSTANDARDS:
1. **Zusammenfassung zuerst:** Beginne mit 2-3 Sätzen, die das Wichtigste auf den Punkt bringen.
2. **Tiefe:** Erkläre nicht nur WAS passiert, sondern WARUM es wichtig ist und welche Konsequenzen es hat.
3. **Struktur:** 3-5 Sektionen mit ### Markdown-Überschriften.
4. **Quellen:** Verweise IMMER mit [1], [2] etc. auf die konkreten Quellen.
5. **Aktualität:** NEUERE Quellen > ältere. Widersprüche benennen.
6. **Bilder:** 1-2 Bilder aus der Liste passend einbinden: . NICHT am Textanfang.
7. **Diagramme:** Wo sinnvoll \`\`\`mermaid Diagramme nutzen. KEINE Sonderzeichen in Labels.
8. **Verständlichkeit:** Fachbegriffe erklären. Zielgruppe: 16-25-Jährige.
Wenn das Thema NICHT nachrichtenwürdig ist, antworte EXAKT mit "SKIP".
Antworte NUR mit dem Markdown-Artikel oder "SKIP".`;
return await callGemini(prompt, apiKey, { temperature: 0.7 });
}
Phase 5: Stufe 2 – Structured Extraction (1× Gemini 3.5 Flash, temp=0.1)
async function stage2_structuredExtraction(rawArticle, searchResults, topic, category, images, apiKey) {
const prompt = `Extrahiere aus folgendem Artikel-Text ein JSON-Objekt.
ARTIKEL:
${rawArticle}
QUELLEN:
${searchResults.map((s, i) => `[${i+1}] "${s.title}" | ${s.url}`).join('\n')}
BILDER: ${images.join(', ') || 'Keine'}
TOPIC: "${topic}"
KATEGORIE-VORSCHLAG: "${category}"
Antworte EXAKT als JSON:
{
"title": "Fesselnde Überschrift (max 80 Zeichen, KEIN Markdown)",
"snippet": "1-2 Sätze für den Feed (max 150 Zeichen)",
"content": "Vollständiger Artikel in Markdown (übernehme den Input, korrigiere Formatierung)",
"category": "Technologie|Wissenschaft|Politik|Wirtschaft|Bildung|Gesellschaft|Sport|Kultur|Gesundheit",
"imageUrl": "Beste Bild-URL oder null",
"sources": [{ "title": "domain.de", "url": "https://...", "snippet": "Kurz" }]
}`;
return JSON.parse(await callGemini(prompt, apiKey, {
temperature: 0.1, responseMimeType: 'application/json'
}));
}
Zentrale Gemini-Helfer-Funktion mit Modell-Kaskade
async function callGemini(prompt, apiKey, options = {}) {
const { temperature = 0.7, responseMimeType = null } = options;
const body = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature }
};
if (responseMimeType) body.generationConfig.responseMimeType = responseMimeType;
// ★ AKTUELLE Modell-Kaskade (Juli 2026) ★
const models = [
'gemini-3.5-flash', // Primär: Neuestes Free-Modell (Mai 2026)
'gemini-3.1-flash-lite', // Fallback 1: Leicht, selten Rate-Limited
'gemini-2.5-flash', // Fallback 2: Bewährt
];
for (const model of models) {
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const err = await response.text();
throw new Error(`${model}: ${response.status} – ${err.substring(0, 200)}`);
}
const json = await response.json();
const text = json.candidates?.[0]?.content?.parts?.map(p => p.text || '').join('') || '';
console.log(`[Gemini] ✅ ${model} succeeded`);
return text.trim();
} catch (e) {
console.error(`[Gemini] ❌ ${model} failed:`, e.message);
}
}
throw new Error('All Gemini models in cascade failed');
}
7. Haupt-Orchestrierung (runGenerationCycle)
async function runGenerationCycle(options = {}) {
const { dryRun = false, singleTopic = null } = options;
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) return { status: 'skipped', reason: 'no_api_key' };
const log = [];
// Phase 0
let trendTopics = singleTopic
? [{ topic: singleTopic, traffic: 'manual', relatedHeadlines: [], source: 'manual_test' }]
: [...await detectTrends(), ...enrichUserTopics(data.topics)];
log.push(`Phase 0: ${trendTopics.length} topics detected`);
if (dryRun) {
trendTopics.forEach(t => log.push(` → "${t.topic}" (${t.source})`));
return { status: 'dry_run', log, topics: trendTopics };
}
// Phase 1
const filtered = filterDuplicateTopics(trendTopics, data.articles, data.lastTrends || []);
const topicsToProcess = filtered.slice(0, 6);
log.push(`Phase 1: ${topicsToProcess.length} new topics after dedup`);
if (topicsToProcess.length === 0) {
data.lastRun = Date.now(); saveData();
return { status: 'no_new_topics', log };
}
// Phase 2
let decomposed;
try {
decomposed = await decomposeTopics(topicsToProcess, apiKey);
} catch (e) {
decomposed = { decomposed: topicsToProcess.map(t => ({
originalTopic: t.topic, queries: [t.searchQuery || t.topic], category: 'Allgemein'
}))};
}
// Phases 3-5
let newArticles = 0;
for (const item of (decomposed.decomposed || [])) {
try {
const researchData = await researchTopic(item);
log.push(`"${item.originalTopic}": ${researchData.results.length} sources`);
if (researchData.results.length === 0) { log.push(` → SKIPPED (no sources)`); continue; }
const rawArticle = await stage1_groundedGeneration(
item.originalTopic, item.category, researchData.results, researchData.images, apiKey
);
if (!rawArticle || rawArticle.startsWith('SKIP')) { log.push(` → SKIPPED by AI`); continue; }
const structured = await stage2_structuredExtraction(
rawArticle, researchData.results, item.originalTopic, item.category, researchData.images, apiKey
);
if (data.articles.some(a => jaccardSimilarity(a.title, structured.title) > 0.7)) {
log.push(` → DUPLICATE title, skipping`); continue;
}
const trendInfo = topicsToProcess.find(t => t.topic === item.originalTopic);
data.articles.unshift({
id: Date.now().toString() + '_' + Math.random().toString(36).substr(2, 5),
topic: item.originalTopic,
category: structured.category || item.category || 'Allgemein',
title: structured.title,
snippet: structured.snippet,
content: structured.content,
sources: structured.sources || [],
imageUrl: structured.imageUrl || researchData.images[0] || null,
timestamp: Date.now(),
isTrending: trendInfo?.source === 'google_trends',
trendSource: trendInfo?.source || 'unknown'
});
markUrlsAsUsed(researchData.results);
newArticles++;
log.push(` → ✅ "${structured.title}"`);
await new Promise(r => setTimeout(r, 3000));
} catch (e) {
log.push(` → ❌ ERROR: ${e.message}`);
}
}
// Phase 6
data.lastTrends = topicsToProcess.map(t => t.topic);
data.lastRun = Date.now();
if (data.articles.length > 50) data.articles = data.articles.slice(0, 50);
saveData();
return { status: 'complete', newArticles, log };
}
8. API-Endpunkte
// BESTEHEND (unverändert):
// GET /api/news, GET /api/topics, POST /api/topics,
// DELETE /api/topics/:t, DELETE /api/news/:id
// GEÄNDERT:
app.get('/api/news', (req, res) => {
res.json({ articles: data.articles, lastRun: data.lastRun }); // ← lastRun hinzugefügt!
});
app.post('/api/news/generate', express.json(), async (req, res) => {
const { dryRun, singleTopic } = req.body || {};
res.json({ status: 'started' }); // Sofort antworten
runGenerationCycle({ dryRun, singleTopic }); // Async im Hintergrund
});
// NEU:
app.get('/api/news/trends', async (req, res) => {
const trends = await detectTrends();
res.json(trends);
});
app.get('/api/news/debug', (req, res) => {
res.json({
lastRun: data.lastRun,
lastRunAgo: `${Math.round((Date.now() - data.lastRun) / 3600000)}h ago`,
lastTrends: data.lastTrends || [],
articleCount: data.articles.length,
usedUrlCount: Object.keys(data.usedUrls || {}).length,
topicsCount: data.topics.length
});
});
9. Frontend-Anpassungen (news-view.js)
9.1 On-Demand-Trigger in fetchData()
async fetchData() {
try {
this.isLoading = true;
this.render();
const newsRes = await fetch(getApiUrl('/api/news'));
if (newsRes.ok) {
const data = await newsRes.json();
this.articles = data.articles; // ← NEU: articles aus Objekt
this.lastRun = data.lastRun; // ← NEU: lastRun speichern
// Extrahiere Kategorien aus Artikeln für die Tabs
this.categories = [...new Set(this.articles.map(a => a.category).filter(Boolean))];
}
const topicsRes = await fetch(getApiUrl('/api/topics'));
if (topicsRes.ok) this.topics = await topicsRes.json();
// ★ ON-DEMAND TRIGGER ★
const sixHours = 6 * 60 * 60 * 1000;
if (!this.lastRun || (Date.now() - this.lastRun) > sixHours) {
console.log('[NewsView] Triggering generation (lastRun > 6h)...');
this.triggerGeneration();
}
} catch (e) {
console.error('Error fetching news:', e);
} finally {
this.isLoading = false;
this.render();
}
}
9.2 Topic-Tabs aus Artikel-Kategorien
// Zeile 70 – VORHER:
// ['Alle', ...Array.from(new Set(['Allgemeine News', ...this.topics]))]
// NACHHER:
const tabs = ['Alle', ...(this.categories || []), 'Meine Themen'];
9.3 Trending-Badge
// Zeile 234 – VORHER:
// ${index === 0 ? '<div class="news-tag highlight-tag">Neu</div>' : ''}
// NACHHER:
${article.isTrending
? '<div class="news-tag highlight-tag">🔥 Trending</div>'
: (Date.now() - article.timestamp < 6*60*60*1000
? '<div class="news-tag">Neu</div>' : '')}
9.4 Filter-Logik
// Zeile 210:
if (this.activeTopic === 'Alle') {
filteredArticles = this.articles;
} else if (this.activeTopic === 'Meine Themen') {
filteredArticles = this.articles.filter(a => a.trendSource === 'user_topic');
} else {
filteredArticles = this.articles.filter(a => a.category === this.activeTopic);
}
10. Dateien-Änderungen Zusammenfassung
[MODIFY] news-service.js
- Entfernt:
searchWeb(),generateArticleWithGemini(),setInterval()-Cron - Neu:
detectTrends(),enrichUserTopics(),jaccardSimilarity(),filterDuplicateTopics(),decomposeTopics(),researchTopic()(mit Jina Reader!),stage1_groundedGeneration(),stage2_structuredExtraction(),callGemini()(mit Modell-Kaskade),isUrlUsed(),markUrlsAsUsed() - Geändert:
runGenerationCycle()(mitdryRun,singleTopic),GET /api/news(+ lastRun), neue Endpoints/api/news/trends,/api/news/debug - Modelle:
gemini-3.5-flash→gemini-3.1-flash-lite→gemini-2.5-flash - Tavily komplett entfernt aus diesem Service
[MODIFY] news-view.js
fetchData(): Parst{ articles, lastRun }statt Array, On-Demand-Trigger- Topic-Tabs: Dynamisch aus Artikel-Kategorien + "Meine Themen"
- Trending-Badge (🔥) für Trend-Artikel
- Filter-Logik für Kategorien
[MODIFY] index.html
- Versionsnummer hochzählen
11. Test-Strategie
Test 1: Dry-Run (0 API-Kosten)
curl -X POST http://localhost:5173/api/news/generate \
-H "Content-Type: application/json" -d '{"dryRun": true}'
→ Zeigt erkannte Trends, keine Gemini-Calls
Test 2: Single-Topic (2 Gemini-Calls + 3 Jina-Calls)
curl -X POST http://localhost:5173/api/news/generate \
-H "Content-Type: application/json" -d '{"singleTopic": "Künstliche Intelligenz 2026"}'
→ Minimaler Verbrauch, voller Pipeline-Test
Test 3: Debug (0 Kosten)
curl http://localhost:5173/api/news/debug
Test 4: Trends anschauen (0 Kosten)
curl http://localhost:5173/api/news/trends
Test 5: Duplikat-Check
# Gleichen Single-Topic nochmal – sollte als Duplikat gefiltert werden
curl -X POST http://localhost:5173/api/news/generate \
-H "Content-Type: application/json" -d '{"singleTopic": "Künstliche Intelligenz 2026"}'
12. Qualitätsvergleich: Vorher vs. Nachher
| Aspekt | Vorher | Nachher |
|---|---|---|
| Quell-Tiefe | Tavily Snippet (~200 Wörter) | Jina Reader Volltext (~2000 Wörter) |
| Quellen-Anzahl | 5 pro Topic | 8-12 pro Topic (3 Sub-Queries × 4) |
| Trend-Erkennung | ❌ Keine | ✅ Google Trends + News RSS |
| Duplikat-Schutz | ❌ Keiner | ✅ Jaccard + URL-Dedup |
| Artikel-Länge | ~200-300 Wörter | ~800-1200 Wörter |
| Kosten | 12 Tavily-Credits/Tag | $0.00 |
| Modelle | gemini-3.5-flash (nur 1) |
Kaskade: 3.5-flash → 3.1-flash-lite → 2.5-flash |