APP-Backend / server.js
Luca448's picture
Claude Sonnet 5.5
feat(notes): add /api/notes/gdoc and /gdoc/edit proxy routes
45f26b9
Raw History Blame Contribute Delete
34.3 kB
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
const originalConsoleInfo = console.info;
console.log = function(...args) { originalConsoleLog.apply(console, ['[SCHOOLMIND]', ...args]); };
console.error = function(...args) { originalConsoleError.apply(console, ['[SCHOOLMIND]', ...args]); };
console.warn = function(...args) { originalConsoleWarn.apply(console, ['[SCHOOLMIND]', ...args]); };
console.info = function(...args) { originalConsoleInfo.apply(console, ['[SCHOOLMIND]', ...args]); };
console.log("--- server.js loading started ---");
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
import fs from 'fs';
import { registerIServFiltered } from './src/api/iserv-filtered.js';
console.log("--- Imports loaded ---");
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
console.log("--- Express app created ---");
// Increase JSON payload limit to avoid Payload Too Large errors for PDFs
app.use(express.json({ limit: '50mb' }));
app.use(cors());
// Serve static files from the dist directory
app.use(express.static(path.join(__dirname, 'dist')));
// Also serve public/ as fallback for assets downloaded during Docker build (e.g. sql-wasm.wasm)
app.use(express.static(path.join(__dirname, 'public')));
// ==========================================
// Proxy Middlewares (Migrated from vite.config.js)
// ==========================================
import { initNewsService, registerNewsRoutes } from './server/news-service.js';
// Init News Service
console.log("--- initNewsService calling ---");
initNewsService();
console.log("--- initNewsService called ---");
registerNewsRoutes(app);
console.log("--- registerNewsRoutes called ---");
// Open Source Piper TTS Proxy (Local)
app.get('/api/tts', async (req, res) => {
const text = req.query.text;
if (!text) return res.status(400).json({ error: 'Missing text' });
try {
const piperPath = path.join(__dirname, 'piper_tts', 'piper');
let modelPath = path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx');
// Check if model is in RAM disk (Linux /dev/shm)
const shmModelPath = '/dev/shm/de_DE-thorsten-high.onnx';
if (fs.existsSync(shmModelPath)) {
modelPath = shmModelPath;
}
// Wenn Piper lokal nicht existiert (z.B. im lokalen Windows Dev-Server)
if (!fs.existsSync(piperPath)) {
console.warn("Piper executable not found at " + piperPath);
return res.status(503).json({error: 'TTS Engine offline (Piper not installed locally)'});
}
res.setHeader('Content-Type', 'audio/wav');
// Clean text to prevent Piper from choking on URLs or Emojis
let cleanText = text;
cleanText = cleanText.replace(/https?:\/\/[^\s]+/g, ' Link.');
cleanText = cleanText.replace(/[\u{1F600}-\u{1F64F}]/gu, '');
cleanText = cleanText.replace(/[\u{1F300}-\u{1F5FF}]/gu, '');
cleanText = cleanText.replace(/[\u{1F680}-\u{1F6FF}]/gu, '');
cleanText = cleanText.replace(/[\u{1F700}-\u{1F77F}]/gu, '');
cleanText = cleanText.replace(/[\u{1F780}-\u{1F7FF}]/gu, '');
cleanText = cleanText.replace(/[\u{1F800}-\u{1F8FF}]/gu, '');
cleanText = cleanText.replace(/[\u{1F900}-\u{1F9FF}]/gu, '');
cleanText = cleanText.replace(/[\u{1FA00}-\u{1FA6F}]/gu, '');
cleanText = cleanText.replace(/[\u{1FA70}-\u{1FAFF}]/gu, '');
cleanText = cleanText.replace(/[\u{2600}-\u{26FF}]/gu, '');
cleanText = cleanText.replace(/[\u{2700}-\u{27BF}]/gu, '');
// Piper liest von stdin und schreibt die WAV an stdout
const piper = spawn(piperPath, ['--model', modelPath, '--output_file', '-'], {
cwd: path.join(__dirname, 'piper_tts')
});
piper.stdout.pipe(res);
piper.stderr.on('data', (data) => {
// Piper debug output is written to stderr, can be ignored or logged
// console.log(`Piper log: ${data}`);
});
piper.on('close', (code) => {
if (code !== 0) console.error(`Piper exited with code ${code}`);
});
piper.stdin.write(cleanText.trim());
piper.stdin.end();
} catch (error) {
console.error('TTS Error:', error);
if (!res.headersSent) res.status(500).json({ error: error.message });
}
});
// TTS Debug Endpoint
app.get('/api/tts/debug', (req, res) => {
try {
const piperPath = path.join(__dirname, 'piper_tts', 'piper');
const modelPath = path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx');
const configPath = path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx.json');
res.json({
piperExists: fs.existsSync(piperPath),
modelExists: fs.existsSync(modelPath),
configExists: fs.existsSync(configPath),
piperPath,
dirname: __dirname,
stats: fs.existsSync(piperPath) ? fs.statSync(piperPath) : null
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// WebUntis Proxy
import { WebUntis } from 'webuntis';
app.post('/api/untis', async (req, res) => {
const { school, username, password, server, date } = req.body;
if (!school || !username || !password || !server) {
return res.status(400).json({ error: 'Missing credentials' });
}
// Clean server string in case user pasted a full URL
let cleanServer = server.replace(/^https?:\/\//, '').replace(/\/.*$/, '').trim();
const untis = new WebUntis(school, username, password, cleanServer);
try {
await untis.login();
// Parse date if provided, else use today
const targetDate = date ? new Date(date) : new Date();
// Week range Monday–Sunday containing targetDate
const weekStart = new Date(targetDate);
const dayOfWeek = weekStart.getDay(); // 0 = Sunday
const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
weekStart.setDate(weekStart.getDate() + diffToMonday);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
const timetable = await untis.getOwnTimetableForRange(weekStart, weekEnd);
// Optionally fetch more data like subject names or teachers, but the timetable usually contains what we need
await untis.logout();
res.json({ success: true, timetable });
} catch (error) {
console.error('Untis API Error:', error);
// If login fails, webuntis usually throws an error
res.status(401).json({ error: error.message || 'Login failed' });
}
});
// Shared by every /api/notes/* route: same access key as chat/completions.
function checkNotesAccess(req, res) {
const accessToken = process.env.NOTES_ACCESS_TOKEN;
if (!accessToken) return true;
const provided = req.headers['x-app-key'] || (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (provided === accessToken) return true;
res.status(401).json({ error: { message: 'Zugriffsschluessel fehlt oder ist falsch' } });
return false;
}
// Notes-App AI Agent/Chat Proxy � keeps OPENROUTER_API_KEY server-side.
// The client (Notizen-App) only ever sees this URL, never the key.
app.post('/api/notes/chat/completions', async (req, res) => {
if (!checkNotesAccess(req, res)) return;
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: { message: 'OPENROUTER_API_KEY ist im Space nicht gesetzt' } });
}
const messages = Array.isArray(req.body.messages) ? req.body.messages : [];
if (messages.length === 0 || messages.length > 60) {
return res.status(400).json({ error: { message: 'messages fehlt oder ist zu lang' } });
}
const NOTES_TEXT_MODELS = ['deepseek/deepseek-v4-flash-0731', 'deepseek/deepseek-v4-flash'];
const NOTES_VISION_MODEL = 'deepseek/deepseek-v4-flash-vision-exp';
const hasImage = messages.some(
(message) =>
Array.isArray(message.content) &&
message.content.some((part) => part?.type === 'image_url'),
);
// Die App schickt ihr gewähltes Modell samt Fallback-Kette mit. Nur freigegebene
// IDs durchlassen, damit niemand über den Proxy teure Modelle auf unsere Kosten ruft.
const NOTES_ALLOWED_MODELS = new Set([
'google/gemini-3.8-flash',
'google/gemini-3.7-flash',
'google/gemini-3.6-flash',
'google/gemini-3.5-flash-lite',
'google/gemini-3.1-flash-lite',
'deepseek/deepseek-v4-flash',
]);
const requested = [req.body.model, ...(Array.isArray(req.body.models) ? req.body.models : [])]
.filter((id) => NOTES_ALLOWED_MODELS.has(id));
const clientModels = [...new Set(requested)].slice(0, 3);
// Gemini kann Bilder, DeepSeek V4 Flash nicht — dort bleibt das Vision-Modell.
const clientCanSee = clientModels.length > 0 && clientModels[0].startsWith('google/');
const models = process.env.NOTES_MODEL
? [process.env.NOTES_MODEL]
: clientModels.length > 0 && (!hasImage || clientCanSee)
? clientModels
: hasImage
? [NOTES_VISION_MODEL]
: NOTES_TEXT_MODELS;
const wantsStream = req.body.stream === true;
try {
const upstream = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: models[0],
models,
messages,
stream: wantsStream,
...(Array.isArray(req.body.tools) && req.body.tools.length
? { tools: req.body.tools, tool_choice: 'auto' }
: {}),
// require_parameters: nur Provider nutzen, die tools wirklich unterstützen.
// Ohne das routet der billigste Provider auch bei aktiven Tools durch und
// "erfindet" dann Tool-Aufrufe als Klartext (<searchweb>...</searchweb>)
// statt sie strukturiert als tool_calls zurückzugeben.
// Bei Gemini fest auf Google AI Studio pinnen (dort liegt unser BYOK-Key,
// kostet nichts) - sonst routet "sort: price" zeitweise auf Google Vertex
// und das zieht echtes OpenRouter-Guthaben.
provider: models[0].startsWith('google/')
? { data_collection: 'deny', only: ['google-ai-studio'], require_parameters: true }
: { data_collection: 'deny', sort: 'price', require_parameters: true },
// Reasoning ist bei Gemini 3.x Pflicht (Anfrage schlägt sonst mit 400 fehl),
// DeepSeek läuft ohne. Nur für Nicht-Gemini-Modelle abschalten.
...(models[0].startsWith('google/') ? {} : { reasoning: { enabled: false } }),
}),
});
if (!wantsStream) {
const data = await upstream.json();
res.status(upstream.status).json(data);
return;
}
if (!upstream.ok || !upstream.body) {
const data = await upstream.json().catch(() => null);
res.status(upstream.status).json(data || { error: { message: 'OpenRouter-Stream fehlgeschlagen' } });
return;
}
// SSE passthrough: forward OpenRouter's chunks to the client as they arrive.
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
for await (const chunk of upstream.body) {
res.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
res.end();
} catch (error) {
console.error('Notes agent proxy error:', error);
if (res.headersSent) {
res.end();
} else {
res.status(502).json({ error: { message: `OpenRouter nicht erreichbar: ${error.message}` } });
}
}
});
// General Web Search Proxy (Tavily) � keeps TAVILY_API_KEY_2 server-side.
// Fallback for the Notes agent's search_web tool when Wikipedia has no hit
// (current events, niche topics). Trimmed to a few short extracts per result
// here already, so the token cost is paid once, not per client.
app.post('/api/notes/search', async (req, res) => {
if (!checkNotesAccess(req, res)) return;
const apiKey = process.env.TAVILY_API_KEY_2;
if (!apiKey) {
return res.status(500).json({ error: { message: 'TAVILY_API_KEY_2 ist im Space nicht gesetzt' } });
}
const query = String(req.body?.query || '').trim();
if (!query) return res.status(400).json({ error: { message: 'query fehlt' } });
try {
const upstream = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
search_depth: 'basic',
max_results: 5,
// Kein von Tavily generiertes Answer-Feld: der Agent soll aus den
// Quellen selbst schließen, nicht eine fremde Zusammenfassung
// ungeprüft übernehmen.
include_answer: false,
include_raw_content: false,
}),
});
const data = await upstream.json();
if (!upstream.ok) {
return res.status(upstream.status).json({ error: { message: data?.error || 'Tavily-Fehler' } });
}
const results = Array.isArray(data.results)
? data.results
.filter((result) => result?.title && result?.url)
.map((result) => ({
title: result.title,
url: result.url,
extract: String(result.content || '').trim().slice(0, 500),
}))
: [];
res.json({ results });
} catch (error) {
console.error('Notes search proxy error:', error);
res.status(502).json({ error: { message: `Tavily nicht erreichbar: ${error.message}` } });
}
});
// Wolfram|Alpha Proxy (LLM-API) - keeps WOLFRAM_APPID server-side.
// Exact maths/physics/chemistry answers for the Notes agent's wolfram_alpha
// tool. The LLM-API returns plain text already shaped for a model.
app.post('/api/notes/wolfram', async (req, res) => {
if (!checkNotesAccess(req, res)) return;
const appId = process.env.WOLFRAM_APPID;
if (!appId) {
return res.status(500).json({ error: { message: 'WOLFRAM_APPID ist im Space nicht gesetzt' } });
}
const query = String(req.body?.query || '').trim().slice(0, 500);
if (!query) return res.status(400).json({ error: { message: 'query fehlt' } });
try {
const params = new URLSearchParams({ input: query, appid: appId, maxchars: '3000' });
const upstream = await fetch(`https://www.wolframalpha.com/api/v1/llm-api?${params}`, {
signal: AbortSignal.timeout(15000),
});
const result = (await upstream.text()).trim();
// 501 = Wolfram konnte die Eingabe nicht deuten; der Text erklärt warum.
if (!upstream.ok && upstream.status !== 501) {
return res.status(502).json({ error: { message: `Wolfram-Fehler ${upstream.status}` } });
}
res.json({ result: result.slice(0, 3000) });
} catch (error) {
console.error('Notes wolfram proxy error:', error);
res.status(502).json({ error: { message: `Wolfram nicht erreichbar: ${error.message}` } });
}
});
// Google Docs Proxy - keeps the OAuth refresh token server-side.
// The app sends a finished .docx (base64); Drive converts it to a real Google
// Doc in the owner's Drive (scope drive.file: only files created by this app).
let googleToken = { value: null, expires: 0 };
async function googleAccessToken() {
if (googleToken.value && Date.now() < googleToken.expires - 60000) return googleToken.value;
const upstream = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
refresh_token: process.env.GOOGLE_REFRESH_TOKEN,
grant_type: 'refresh_token',
}),
signal: AbortSignal.timeout(15000),
});
const data = await upstream.json();
if (!upstream.ok) throw new Error(data.error_description || data.error || `Token-Fehler ${upstream.status}`);
googleToken = { value: data.access_token, expires: Date.now() + data.expires_in * 1000 };
return googleToken.value;
}
app.post('/api/notes/gdoc', async (req, res) => {
if (!checkNotesAccess(req, res)) return;
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_REFRESH_TOKEN) {
return res.status(500).json({
error: { message: 'Google ist im Space nicht eingerichtet (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN)' },
});
}
const title = String(req.body?.title || '').trim().slice(0, 150) || 'Dokument';
const docx = Buffer.from(String(req.body?.docx || ''), 'base64');
// A .docx is a zip: "PK" up front. Rejects empty or foreign payloads early.
if (docx.length < 4 || docx.length > 10 * 1024 * 1024 || docx.subarray(0, 2).toString('latin1') !== 'PK') {
return res.status(400).json({ error: { message: 'docx fehlt oder ist ungültig' } });
}
try {
const token = await googleAccessToken();
const boundary = `notes${Math.random().toString(36).slice(2)}`;
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n` +
JSON.stringify({ name: title, mimeType: 'application/vnd.google-apps.document' }) +
`\r\n--${boundary}\r\nContent-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document\r\n\r\n`,
),
docx,
Buffer.from(`\r\n--${boundary}--`),
]);
const upstream = await fetch(
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink',
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': `multipart/related; boundary=${boundary}` },
body,
signal: AbortSignal.timeout(30000),
},
);
const data = await upstream.json();
if (!upstream.ok) {
return res.status(502).json({ error: { message: data?.error?.message || `Drive-Fehler ${upstream.status}` } });
}
res.json({ id: data.id, title: data.name, url: data.webViewLink });
} catch (error) {
console.error('Notes gdoc proxy error:', error);
res.status(502).json({ error: { message: `Google nicht erreichbar: ${error.message}` } });
}
});
// Bestehende Google Docs lesen/bearbeiten: eine Route, Aktion im Body.
// Braucht Docs-API + Scope documents (+ drive.readonly zum Suchen); mit drive.file
// sieht der Token nur Docs, die diese App selbst angelegt hat.
const GDOC_ID = /^[\w-]{10,100}$/;
async function googleJson(url, init = {}) {
const upstream = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${await googleAccessToken()}`, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(30000),
});
if (!upstream.ok) {
const data = await upstream.json().catch(() => null);
throw new Error(data?.error?.message || `Google-Fehler ${upstream.status}`);
}
return upstream;
}
app.post('/api/notes/gdoc/edit', async (req, res) => {
if (!checkNotesAccess(req, res)) return;
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_REFRESH_TOKEN) {
return res.status(500).json({
error: { message: 'Google ist im Space nicht eingerichtet (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN)' },
});
}
const { action, id } = req.body || {};
if (action !== 'list' && !GDOC_ID.test(String(id || ''))) {
return res.status(400).json({ error: { message: 'id fehlt oder ist ungültig' } });
}
try {
if (action === 'list') {
const name = String(req.body.query || '').trim().replace(/[\\']/g, ' ').slice(0, 100);
const q = `mimeType='application/vnd.google-apps.document' and trashed=false${name ? ` and name contains '${name}'` : ''}`;
const upstream = await googleJson(
`https://www.googleapis.com/drive/v3/files?pageSize=15&orderBy=modifiedTime%20desc&fields=files(id,name,modifiedTime,webViewLink)&q=${encodeURIComponent(q)}`,
);
return res.json({ files: (await upstream.json()).files || [] });
}
if (action === 'read') {
const upstream = await googleJson(`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=text%2Fplain`);
return res.json({ text: (await upstream.text()).slice(0, 30000) });
}
if (action === 'replace' || action === 'append') {
const text = String(req.body.text ?? '');
const find = String(req.body.find ?? '');
if (action === 'replace' && !find) return res.status(400).json({ error: { message: 'find fehlt' } });
if (action === 'append' && !text.trim()) return res.status(400).json({ error: { message: 'text fehlt' } });
const request =
action === 'replace'
? { replaceAllText: { containsText: { text: find, matchCase: true }, replaceText: text } }
: { insertText: { endOfSegmentLocation: {}, text: `\n${text}` } };
const upstream = await googleJson(`https://docs.googleapis.com/v1/documents/${id}:batchUpdate`, {
method: 'POST',
body: JSON.stringify({ requests: [request] }),
});
const changed = (await upstream.json()).replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0;
const meta = await (await googleJson(`https://www.googleapis.com/drive/v3/files/${id}?fields=name,webViewLink`)).json();
return res.json({ title: meta.name, url: meta.webViewLink, changed });
}
res.status(400).json({ error: { message: 'action ungültig' } });
} catch (error) {
console.error('Notes gdoc edit proxy error:', error);
res.status(502).json({ error: { message: error.message } });
}
});
// Web Search Proxy
app.get('/api/proxy/search', async (req, res) => {
const query = req.query.q;
if (!query) return res.status(400).json({ error: 'Missing q' });
try {
const ddgRes = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`);
const html = await ddgRes.text();
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (e) {
res.status(500).send(e.message);
}
});
// Gravity Browser Agent Proxy (Traffic Cop)
app.use('/api/agent', async (req, res) => {
try {
const targetUrl = 'http://127.0.0.1:8765' + req.originalUrl.replace('/api/agent', '');
const fetchOptions = {
method: req.method,
headers: { ...req.headers }
};
// Remove host to avoid conflicts
delete fetchOptions.headers.host;
delete fetchOptions.headers['content-length'];
delete fetchOptions.headers['content-type'];
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
fetchOptions.body = JSON.stringify(req.body);
fetchOptions.headers['Content-Type'] = 'application/json';
}
const response = await fetch(targetUrl, fetchOptions);
const text = await response.text();
res.status(response.status);
for (const [key, value] of response.headers.entries()) {
res.setHeader(key, value);
}
res.send(text);
} catch (error) {
console.error('Agent Proxy Error:', error);
res.status(502).json({ error: 'Browser Agent is offline or unreachable' });
}
});
// JSON-based DuckDuckGo endpoint using cheerio
app.get('/api/ddg-search', async (req, res) => {
const query = req.query.q;
if (!query) return res.status(400).json({ error: 'Missing query' });
try {
const cheerio = await import('cheerio');
// Using html.duckduckgo.com
const response = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
}
});
const html = await response.text();
const $ = cheerio.load(html);
const results = [];
$('.result__body').each((i, el) => {
if (i >= 10) return; // limit to top 10
const title = $(el).find('.result__title .result__a').text().trim();
const link = $(el).find('.result__title .result__a').attr('href');
const snippet = $(el).find('.result__snippet').text().trim();
if (title && link) {
// DDG wraps links in //duckduckgo.com/l/?uddg=...
let realLink = link;
if (link.includes('uddg=')) {
try {
const urlObj = new URL(link, 'https://duckduckgo.com');
realLink = decodeURIComponent(urlObj.searchParams.get('uddg'));
} catch(e) {}
}
results.push({ title, link: realLink, snippet });
}
});
res.json({ results });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Web Image Search API
app.get('/api/search-image', async (req, res) => {
const query = req.query.q;
if (!query) return res.status(400).json({ error: 'Missing query' });
try {
const cheerio = await import('cheerio');
const url = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
});
const html = await response.text();
const $ = cheerio.load(html);
let imageUrl = null;
let sourceLink = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
$('img').each((i, el) => {
const src = $(el).attr('src');
if (src && src.startsWith('https://encrypted-tbn0.gstatic.com/images')) {
if (!imageUrl) imageUrl = src;
}
});
if (imageUrl) {
res.json({ imageUrl, sourceLink });
} else {
res.status(404).json({ error: 'No image found' });
}
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Shopping Search Proxy
app.get('/api/proxy/shop', async (req, res) => {
const targetUrl = req.query.url;
if (!targetUrl) return res.status(400).json({ error: 'Missing url' });
try {
const shopRes = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1'
}
});
const text = await shopRes.text();
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(text);
} catch (e) {
res.status(500).send(e.message);
}
});
// Reader/Scrape Proxy
app.get('/api/proxy/scrape', async (req, res) => {
const targetUrl = req.query.url;
if (!targetUrl) return res.status(400).json({ error: 'Missing url' });
try {
const jinaRes = await fetch(`https://r.jina.ai/${targetUrl}`);
const text = await jinaRes.text();
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(text);
} catch (e) {
res.status(500).send(e.message);
}
});
// IServ Puppeteer Endpoint
app.post('/api/iserv', async (req, res) => {
try {
const { fetchIServTasks } = await import('./src/api/iserv.js');
const { url, username, password } = req.body;
if (!url || !username || !password) {
return res.status(400).json({ error: 'Missing credentials' });
}
const tasks = await fetchIServTasks(url, username, password);
res.setHeader('Content-Type', 'application/json');
res.json(tasks);
} catch (e) {
res.status(500).json({ error: e.message || 'Scraping failed' });
}
});
// PDF Generation Endpoint
app.post('/api/pdf', async (req, res) => {
try {
const { markdown, title } = req.body;
if (!markdown) {
return res.status(400).json({ error: "Missing markdown content" });
}
const { generatePdf } = await import('./src/api/pdf.js');
const pdfBuffer = await generatePdf(markdown, title);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(title || 'loesung')}.pdf"`);
res.send(pdfBuffer);
} catch (e) {
console.error("PDF generation failed:", e);
res.status(500).json({ error: e.message });
}
});
// YouTube Search API
app.get('/api/youtube-search', async (req, res) => {
const query = req.query.q;
if (!query) return res.status(400).send('Missing query');
try {
const { default: ytSearch } = await import('youtube-sr');
// Force IPv4 for YouTube requests to bypass HF IPv6 blocks
const dns = await import('dns');
dns.setDefaultResultOrder('ipv4first');
// Helper to fetch via DDG HTML
const fetchFromDDG = async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(`https://html.duckduckgo.com/html/?q=site:youtube.com+${encodeURIComponent(query)}`, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
signal: controller.signal
});
clearTimeout(timeoutId);
const html = await response.text();
const matches = Array.from(html.matchAll(/v=([a-zA-Z0-9_-]{11})/g));
if (matches.length > 0) return matches[0][1];
} catch(err) {
clearTimeout(timeoutId);
console.error('DDG fallback error:', err.message);
}
return null;
};
// Race ytSearch against a 10s timeout
let videoId = null;
try {
const ytPromise = ytSearch.YouTube.search(query, { limit: 1 }).then(v => v && v.length > 0 ? v[0].id : null);
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 10000));
videoId = await Promise.race([ytPromise, timeoutPromise]);
} catch (e) {
console.warn('ytSearch failed or timed out:', e.message);
}
if (!videoId) {
console.warn('Falling back to DDG...');
videoId = await fetchFromDDG();
}
if (videoId) {
if (req.query.json) {
res.setHeader('Content-Type', 'application/json');
res.json({ videoId });
} else {
res.redirect(`https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1`);
}
} else {
res.status(404).send('No videos found');
}
} catch (err) {
res.status(500).send(err.message);
}
});
// Deep Research Search Proxy (Google News RSS)
app.get('/api/research/search', async (req, res) => {
const query = req.query.q;
if (!query) return res.status(400).json({ error: 'Missing query' });
try {
const cheerio = await import('cheerio');
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=de&gl=DE&ceid=DE:de`;
const response = await fetch(url);
const xml = await response.text();
const $ = cheerio.load(xml, { xmlMode: true });
const results = [];
$('item').each((i, el) => {
if (i >= 5) return;
const title = $(el).find('title').text();
const link = $(el).find('link').text();
const description = $(el).find('description').text();
const snippet = cheerio.load(description).text().trim() || title;
if (title && link) {
results.push({ title, body: snippet, href: link });
}
});
res.json({ results });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// YouTube Transcript API
app.get('/api/youtube-transcript', async (req, res) => {
const videoId = req.query.v;
if (!videoId) return res.status(400).json({ error: 'Missing video ID' });
try {
const { YoutubeTranscript } = await import('youtube-transcript');
const transcript = await YoutubeTranscript.fetchTranscript(videoId);
res.setHeader('Content-Type', 'application/json');
res.json(transcript);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// IServ tasks without names for the assistant (token protected, credentials from the Space secrets)
registerIServFiltered(app);
// Fallback for SPA routing - only for navigation requests, NOT for assets
app.use((req, res) => {
// If the request looks like a static asset (has a file extension), return 404
if (req.path.match(/\.\w+$/)) {
return res.status(404).send('Not found');
}
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// Start Server
const PORT = process.env.PORT || 7860; // Force Hugging Face Spaces port unless set
// Preload Piper into RAM disk for faster generation
try {
if (fs.existsSync('/dev/shm')) {
const shmModelPath = '/dev/shm/de_DE-thorsten-high.onnx';
const shmConfigPath = '/dev/shm/de_DE-thorsten-high.onnx.json';
if (!fs.existsSync(shmModelPath)) {
console.log('Copying Piper model to RAM disk (/dev/shm)...');
fs.copyFileSync(path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx'), shmModelPath);
}
if (!fs.existsSync(shmConfigPath)) {
fs.copyFileSync(path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx.json'), shmConfigPath);
}
}
} catch (e) {
console.log('Failed to copy Piper model to RAM disk:', e.message);
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 SchoolMind AI Server is running on port ${PORT}`);
});