Initial commit: Visigine (Vite client + Express/SQLite backend)
Container-ready via docker/ compose (frontend nginx + backend Node). Compose adjusted for Coolify on the prod server: frontend uses expose:80 (no host binding — host 8080 is taken by the Coolify proxy; Traefik routes visigine.de), backend ALLOWED_ORIGINS=https://visigine.de. Secrets stay in server/.env (git-ignored); see server/.env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
232
server/routes/admin-monitoring.js
Normal file
232
server/routes/admin-monitoring.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Router } from 'express'
|
||||
import * as repo from '../db/repo.js'
|
||||
import { runMonitoring, RunError } from '../lib/monitoring/run.js'
|
||||
import { generateQueries } from '../lib/monitoring/generate-queries.js'
|
||||
import { runAnalysisPipeline, validateUrl } from '../lib/pipeline.js'
|
||||
import { getProviderModes } from '../lib/providers/index.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function serializeClient(c) {
|
||||
if (!c) return null
|
||||
let aliases = []
|
||||
try { aliases = JSON.parse(c.brand_aliases || '[]') } catch { /* keep [] */ }
|
||||
return { ...c, brand_aliases: aliases }
|
||||
}
|
||||
|
||||
function mentionRate(mentions, runs) {
|
||||
if (!runs || runs <= 0) return null
|
||||
return Number((mentions / runs).toFixed(4))
|
||||
}
|
||||
|
||||
// ─── clients ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/clients', (_req, res) => {
|
||||
const rows = repo.listClients().map((c) => ({
|
||||
id: c.id,
|
||||
hostname: c.hostname,
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
queries_count: c.queries_count,
|
||||
last_run_at: c.last_run_at,
|
||||
mention_rate_30d: mentionRate(c.mentions_30d, c.runs_30d),
|
||||
runs_30d: c.runs_30d,
|
||||
mentions_30d: c.mentions_30d,
|
||||
}))
|
||||
res.json({ clients: rows, providerModes: getProviderModes() })
|
||||
})
|
||||
|
||||
router.post('/clients', async (req, res) => {
|
||||
const body = req.body || {}
|
||||
if (!body.url) return res.status(400).json({ error: 'URL erforderlich.' })
|
||||
|
||||
const v = validateUrl(body.url)
|
||||
if (v.error) return res.status(v.error.http).json({ error: v.error.msg })
|
||||
|
||||
// If hostname already exists, return existing instead of duplicating.
|
||||
const existing = repo.getClientByHost(v.host)
|
||||
if (existing) return res.json(serializeClient(existing))
|
||||
|
||||
// Pull siteData from the analyze pipeline if name/description not provided.
|
||||
let { hostname, name, description, language, brand_aliases } = body
|
||||
hostname = hostname || v.host
|
||||
|
||||
if (!name || !description) {
|
||||
try {
|
||||
const out = await runAnalysisPipeline(v.targetUrl, { debugMode: false })
|
||||
if (!out.error) {
|
||||
const sd = out._siteData || {}
|
||||
name = name || sd.name || v.host
|
||||
description = description || sd.description || null
|
||||
language = language || sd.language || 'de'
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[admin-monitoring] prefill failed:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
const aliasesJson = JSON.stringify(Array.isArray(brand_aliases) ? brand_aliases : [])
|
||||
|
||||
try {
|
||||
const created = repo.insertClient({
|
||||
hostname,
|
||||
url: v.targetUrl,
|
||||
name: name || v.host,
|
||||
description,
|
||||
brand_aliases: aliasesJson,
|
||||
language: language || 'de',
|
||||
})
|
||||
return res.status(201).json(serializeClient(created))
|
||||
} catch (err) {
|
||||
if (String(err?.message || '').includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: 'Marke existiert bereits.' })
|
||||
}
|
||||
console.error('[admin-monitoring] insert error', err)
|
||||
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/clients/:id', (req, res) => {
|
||||
const c = repo.getClient(Number(req.params.id))
|
||||
if (!c) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
res.json(serializeClient(c))
|
||||
})
|
||||
|
||||
router.patch('/clients/:id', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const existing = repo.getClient(id)
|
||||
if (!existing) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
const patch = req.body || {}
|
||||
if (patch.status && !['active', 'paused', 'archived'].includes(patch.status)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Status.' })
|
||||
}
|
||||
if (patch.brand_aliases !== undefined && Array.isArray(patch.brand_aliases)) {
|
||||
patch.brand_aliases = JSON.stringify(patch.brand_aliases)
|
||||
}
|
||||
const updated = repo.updateClient(id, patch)
|
||||
res.json(serializeClient(updated))
|
||||
})
|
||||
|
||||
router.delete('/clients/:id', (req, res) => {
|
||||
const ok = repo.deleteClient(Number(req.params.id))
|
||||
if (!ok) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
res.json({ deleted: true })
|
||||
})
|
||||
|
||||
// ─── queries ──────────────────────────────────────────────────────
|
||||
|
||||
router.post('/clients/:id/generate-queries', async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const c = repo.getClient(id)
|
||||
if (!c) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
|
||||
const previousCount = repo.listAllQueries(id).length
|
||||
const result = await generateQueries({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
url: c.url,
|
||||
hostname: c.hostname,
|
||||
})
|
||||
|
||||
if (!result.queries || result.queries.length === 0) {
|
||||
return res.status(502).json({ error: 'Query-Generierung fehlgeschlagen. Bitte später erneut versuchen.' })
|
||||
}
|
||||
|
||||
repo.replaceQueries(id, result.queries)
|
||||
return res.json({
|
||||
generated: result.queries.length,
|
||||
replaced: previousCount,
|
||||
warning: result.warning || null,
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/clients/:id/queries', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
res.json({ queries: repo.listAllQueries(id) })
|
||||
})
|
||||
|
||||
router.post('/clients/:id/queries', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
const text = String(req.body?.text || '').trim()
|
||||
if (text.length < 5) return res.status(400).json({ error: 'Query zu kurz.' })
|
||||
const q = repo.insertQuery(id, text)
|
||||
res.status(201).json(q)
|
||||
})
|
||||
|
||||
router.patch('/queries/:id', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!repo.getQuery(id)) return res.status(404).json({ error: 'Query nicht gefunden.' })
|
||||
const patch = req.body || {}
|
||||
if (patch.text !== undefined) patch.text = String(patch.text).trim()
|
||||
if (patch.text !== undefined && patch.text.length < 5) {
|
||||
return res.status(400).json({ error: 'Query zu kurz.' })
|
||||
}
|
||||
const q = repo.updateQuery(id, patch)
|
||||
res.json(q)
|
||||
})
|
||||
|
||||
router.delete('/queries/:id', (req, res) => {
|
||||
const ok = repo.deleteQuery(Number(req.params.id))
|
||||
if (!ok) return res.status(404).json({ error: 'Query nicht gefunden.' })
|
||||
res.json({ deleted: true })
|
||||
})
|
||||
|
||||
// ─── runs ─────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/clients/:id/run', async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const summary = await runMonitoring(id)
|
||||
res.json(summary)
|
||||
} catch (err) {
|
||||
if (err instanceof RunError) {
|
||||
const status = err.code === 'CLIENT_NOT_FOUND' ? 404 : 400
|
||||
const message = err.code === 'CLIENT_NOT_FOUND' ? 'Marke nicht gefunden.'
|
||||
: err.code === 'NO_ACTIVE_QUERIES' ? 'Keine aktiven Queries — bitte zuerst generieren oder anlegen.'
|
||||
: err.code === 'CLIENT_NOT_ACTIVE' ? 'Marke ist pausiert oder archiviert.'
|
||||
: err.code
|
||||
return res.status(status).json({ error: message, code: err.code })
|
||||
}
|
||||
console.error('[admin-monitoring] run error', err)
|
||||
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/clients/:id/runs', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
const page = Math.max(1, Number(req.query.page || 1))
|
||||
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)))
|
||||
const offset = (page - 1) * limit
|
||||
res.json({
|
||||
page,
|
||||
limit,
|
||||
total: repo.countRuns(id),
|
||||
runs: repo.recentRuns(id, limit, offset),
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/clients/:id/stats', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
|
||||
const byProvider = repo.statsByProvider(id)
|
||||
const totals = repo.totalsLast30d(id)
|
||||
res.json({
|
||||
byProvider: byProvider.map((r) => ({
|
||||
provider: r.provider,
|
||||
total: r.total,
|
||||
mentions: r.mentions,
|
||||
cost: Number((r.cost || 0).toFixed(6)),
|
||||
mention_rate: mentionRate(r.mentions, r.total),
|
||||
last_run: r.last_run,
|
||||
})),
|
||||
totalRuns30d: totals?.total || 0,
|
||||
totalMentions30d: totals?.mentions || 0,
|
||||
mentionRate30d: mentionRate(totals?.mentions, totals?.total),
|
||||
totalCost30d: Number(((totals?.cost) || 0).toFixed(6)),
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
159
server/routes/admin.js
Normal file
159
server/routes/admin.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Router } from 'express'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import JSZip from 'jszip'
|
||||
import { runAnalysisPipeline, validateUrl } from '../lib/pipeline.js'
|
||||
import { buildReadme } from '../lib/autofix/index.js'
|
||||
import { recordAnalysis, recentAnalyses, computeStats } from '../lib/activity.js'
|
||||
import { cacheGet, cacheSet } from '../lib/cache.js'
|
||||
|
||||
function normalizeForCache(url) {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return `${u.protocol}//${u.hostname.toLowerCase()}${u.pathname || '/'}`
|
||||
} catch {
|
||||
return url.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function safeFilename(host) {
|
||||
if (!host) return 'visigine-autofix.zip'
|
||||
const cleaned = host.toLowerCase().replace(/[^a-z0-9.\-]/g, '').slice(0, 50)
|
||||
return cleaned ? `visigine-autofix-${cleaned}.zip` : 'visigine-autofix.zip'
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Admin analyze: always debug mode. Cache bypass defaults to true but can be
|
||||
// toggled off via { bypassCache: false } in the request body — useful for
|
||||
// inspecting what a regular cached response would look like.
|
||||
// Critical: `_debug` is NEVER stripped here, even in NODE_ENV=production.
|
||||
router.post('/analyze', async (req, res) => {
|
||||
const started = Date.now()
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
res.setHeader('X-Request-Id', requestId)
|
||||
|
||||
const v = validateUrl(req?.body?.url)
|
||||
if (v.error) {
|
||||
const ms = Date.now() - started
|
||||
recordAnalysis({ requestId, host: null, ms, status: 'err', code: v.error.code, admin: true })
|
||||
return res.status(v.error.http).json({ error: v.error.msg })
|
||||
}
|
||||
const { targetUrl, host } = v
|
||||
const bypassCache = req.body?.bypassCache !== false
|
||||
const cacheKey = normalizeForCache(targetUrl)
|
||||
|
||||
// When the admin opts in to the cache, serve from it just like /api/analyze
|
||||
// would. We still attach a _debug shell so the UI can render its sections.
|
||||
if (!bypassCache) {
|
||||
const cached = cacheGet(cacheKey)
|
||||
if (cached) {
|
||||
const ms = Date.now() - started
|
||||
res.setHeader('X-Cache', 'HIT')
|
||||
recordAnalysis({
|
||||
requestId, host,
|
||||
score: cached.data.score, issuesCount: cached.data.issues.length,
|
||||
failedCheckIds: cached.failedCheckIds || [],
|
||||
cacheHit: true, ms, status: 'ok', admin: true,
|
||||
})
|
||||
const response = {
|
||||
...cached.data,
|
||||
_debug: cached.debugPayload
|
||||
? { ...cached.debugPayload, totalMs: ms, cacheHit: true }
|
||||
: { totalMs: ms, cacheHit: true, checks: [], fetches: {}, siteData: null, note: 'Aus Cache geladen — keine frischen Debug-Daten verfügbar.' },
|
||||
}
|
||||
return res.json(response)
|
||||
}
|
||||
}
|
||||
res.setHeader('X-Cache', 'MISS')
|
||||
|
||||
try {
|
||||
const out = await runAnalysisPipeline(targetUrl, { debugMode: true })
|
||||
if (out.error) {
|
||||
const ms = Date.now() - started
|
||||
recordAnalysis({ requestId, host, cacheHit: false, ms, status: 'err', code: out.error.code, admin: true })
|
||||
return res.status(out.error.http).json({ error: out.error.msg })
|
||||
}
|
||||
|
||||
const ms = Date.now() - started
|
||||
const response = { ...out.data, _debug: { ...out.debugPayload, totalMs: ms, cacheHit: false } }
|
||||
|
||||
// Populate the cache so non-bypass admin requests (and the public route)
|
||||
// can later read it.
|
||||
if (!bypassCache && out.mainStatus >= 200 && out.mainStatus < 400) {
|
||||
cacheSet(cacheKey, { data: out.data, failedCheckIds: out.failedCheckIds, debugPayload: out.debugPayload })
|
||||
}
|
||||
|
||||
recordAnalysis({
|
||||
requestId, host,
|
||||
score: out.data.score, issuesCount: out.data.issues.length,
|
||||
failedCheckIds: out.failedCheckIds,
|
||||
cacheHit: false, ms, status: 'ok', admin: true,
|
||||
})
|
||||
return res.json(response)
|
||||
} catch (err) {
|
||||
console.error('[admin-analyze-error]', requestId, err?.message || err)
|
||||
const ms = Date.now() - started
|
||||
recordAnalysis({ requestId, host, cacheHit: false, ms, status: 'err', code: 'INTERNAL', admin: true })
|
||||
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/recent', (_req, res) => {
|
||||
res.json({ analyses: recentAnalyses() })
|
||||
})
|
||||
|
||||
router.get('/stats', (_req, res) => {
|
||||
res.json(computeStats())
|
||||
})
|
||||
|
||||
// Admin ZIP: always bypass cache, no rate limit.
|
||||
router.post('/autofix/zip', async (req, res) => {
|
||||
const started = Date.now()
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
res.setHeader('X-Request-Id', requestId)
|
||||
|
||||
const v = validateUrl(req?.body?.url)
|
||||
if (v.error) {
|
||||
const ms = Date.now() - started
|
||||
recordAnalysis({ requestId, host: null, ms, status: 'err', code: v.error.code, admin: true })
|
||||
return res.status(v.error.http).json({ error: v.error.msg })
|
||||
}
|
||||
const { targetUrl, host } = v
|
||||
|
||||
try {
|
||||
const out = await runAnalysisPipeline(targetUrl, { debugMode: false })
|
||||
if (out.error) {
|
||||
const ms = Date.now() - started
|
||||
recordAnalysis({ requestId, host, cacheHit: false, ms, status: 'err', code: out.error.code, admin: true })
|
||||
return res.status(out.error.http).json({ error: out.error.msg })
|
||||
}
|
||||
|
||||
const zip = new JSZip()
|
||||
zip.file('llms.txt', out.data.autofix.llmsTxt.content)
|
||||
zip.file('robots.txt', out.data.autofix.robotsTxt.content)
|
||||
zip.file('jsonld.html', out.data.autofix.jsonLd.content)
|
||||
zip.file('README.txt', buildReadme(out.data.autofix))
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
|
||||
|
||||
const filename = safeFilename(host)
|
||||
res.setHeader('Content-Type', 'application/zip')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
||||
res.setHeader('Content-Length', buffer.length)
|
||||
|
||||
const ms = Date.now() - started
|
||||
recordAnalysis({
|
||||
requestId, host,
|
||||
score: out.data.score, issuesCount: out.data.issues.length,
|
||||
failedCheckIds: out.failedCheckIds,
|
||||
cacheHit: false, ms, status: 'ok', admin: true,
|
||||
})
|
||||
return res.send(buffer)
|
||||
} catch (err) {
|
||||
console.error('[admin-zip-error]', requestId, err?.message || err)
|
||||
const ms = Date.now() - started
|
||||
recordAnalysis({ requestId, host, cacheHit: false, ms, status: 'err', code: 'INTERNAL', admin: true })
|
||||
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
118
server/routes/analyze.js
Normal file
118
server/routes/analyze.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Router } from 'express'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { cacheGet, cacheSet } from '../lib/cache.js'
|
||||
import { runAnalysisPipeline, validateUrl, toPublicResponse } from '../lib/pipeline.js'
|
||||
import { recordAnalysis } from '../lib/activity.js'
|
||||
|
||||
function normalizeForCache(url) {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return `${u.protocol}//${u.hostname.toLowerCase()}${u.pathname || '/'}`
|
||||
} catch {
|
||||
return url.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
function logLine({ requestId, host, score, issues, cache, ms, status, code }) {
|
||||
const parts = [
|
||||
`[analyze]`,
|
||||
`id=${requestId}`,
|
||||
`ts=${nowIso()}`,
|
||||
`host=${host || '-'}`,
|
||||
]
|
||||
if (score !== undefined) parts.push(`score=${score}`)
|
||||
if (issues !== undefined) parts.push(`issues=${issues}`)
|
||||
parts.push(`cache=${cache}`)
|
||||
parts.push(`ms=${ms}`)
|
||||
parts.push(`status=${status}`)
|
||||
if (code) parts.push(`code=${code}`)
|
||||
console.log(parts.join(' '))
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const started = Date.now()
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
res.setHeader('X-Request-Id', requestId)
|
||||
|
||||
const debugRequested = req.query?.debug === '1'
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const debugMode = debugRequested && !isProd
|
||||
|
||||
const v = validateUrl(req?.body?.url)
|
||||
if (v.error) {
|
||||
const ms = Date.now() - started
|
||||
logLine({ requestId, cache: 'miss', ms, status: 'err', code: v.error.code })
|
||||
recordAnalysis({ requestId, host: null, ms, status: 'err', code: v.error.code })
|
||||
return res.status(v.error.http).json({ error: v.error.msg })
|
||||
}
|
||||
const { targetUrl, host: hostForLog } = v
|
||||
const cacheKey = normalizeForCache(targetUrl)
|
||||
|
||||
if (!debugMode) {
|
||||
const cached = cacheGet(cacheKey)
|
||||
if (cached) {
|
||||
const ms = Date.now() - started
|
||||
res.setHeader('X-Cache', 'HIT')
|
||||
logLine({
|
||||
requestId, host: hostForLog,
|
||||
score: cached.data.score, issues: cached.data.issues.length,
|
||||
cache: 'hit', ms, status: 'ok',
|
||||
})
|
||||
recordAnalysis({
|
||||
requestId, host: hostForLog,
|
||||
score: cached.data.score, issuesCount: cached.data.issues.length,
|
||||
failedCheckIds: cached.failedCheckIds || [],
|
||||
cacheHit: true, ms, status: 'ok',
|
||||
})
|
||||
return res.json(toPublicResponse(cached.data))
|
||||
}
|
||||
}
|
||||
res.setHeader('X-Cache', 'MISS')
|
||||
|
||||
try {
|
||||
const out = await runAnalysisPipeline(targetUrl, { debugMode })
|
||||
if (out.error) {
|
||||
const ms = Date.now() - started
|
||||
logLine({ requestId, host: hostForLog, cache: 'miss', ms, status: 'err', code: out.error.code })
|
||||
recordAnalysis({ requestId, host: hostForLog, cacheHit: false, ms, status: 'err', code: out.error.code })
|
||||
return res.status(out.error.http).json({ error: out.error.msg })
|
||||
}
|
||||
|
||||
const response = toPublicResponse(out.data)
|
||||
if (debugMode && out.debugPayload) response._debug = out.debugPayload
|
||||
// Belt-and-suspenders: in production, never serve _debug.
|
||||
if (isProd && response._debug) delete response._debug
|
||||
|
||||
if (!debugMode && out.mainStatus >= 200 && out.mainStatus < 400) {
|
||||
cacheSet(cacheKey, { data: out.data, failedCheckIds: out.failedCheckIds })
|
||||
}
|
||||
|
||||
const ms = Date.now() - started
|
||||
logLine({
|
||||
requestId, host: hostForLog,
|
||||
score: out.data.score, issues: out.data.issues.length,
|
||||
cache: 'miss', ms, status: 'ok',
|
||||
})
|
||||
recordAnalysis({
|
||||
requestId, host: hostForLog,
|
||||
score: out.data.score, issuesCount: out.data.issues.length,
|
||||
failedCheckIds: out.failedCheckIds,
|
||||
cacheHit: false, ms, status: 'ok',
|
||||
})
|
||||
return res.json(response)
|
||||
} catch (err) {
|
||||
console.error('[analyze-error]', requestId, err?.message || err)
|
||||
const ms = Date.now() - started
|
||||
logLine({ requestId, host: hostForLog, cache: 'miss', ms, status: 'err', code: 'INTERNAL' })
|
||||
recordAnalysis({ requestId, host: hostForLog, cacheHit: false, ms, status: 'err', code: 'INTERNAL' })
|
||||
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
64
server/routes/demo-monitoring.js
Normal file
64
server/routes/demo-monitoring.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Reserved for iteration 4b. Backend-only for now — no UI surfaces this yet.
|
||||
// No auth, 5/hour/IP rate limit. Runs a single (query × provider) against one
|
||||
// real-or-mock provider and returns the result. Nothing is persisted.
|
||||
import { Router } from 'express'
|
||||
import { validateUrl, runAnalysisPipeline } from '../lib/pipeline.js'
|
||||
import { getProviders } from '../lib/providers/index.js'
|
||||
import { detectMention } from '../lib/monitoring/detect-mention.js'
|
||||
import { generateQueries } from '../lib/monitoring/generate-queries.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const url = req.body?.url
|
||||
let queryText = (req.body?.query || '').trim()
|
||||
|
||||
const v = validateUrl(url)
|
||||
if (v.error) return res.status(v.error.http).json({ error: v.error.msg })
|
||||
|
||||
// If no query supplied, generate one quickly. We pull siteData from a fresh
|
||||
// analyze pass so the generated query is on-brand.
|
||||
let clientLike
|
||||
try {
|
||||
const out = await runAnalysisPipeline(v.targetUrl, { debugMode: false })
|
||||
if (out.error) return res.status(out.error.http).json({ error: out.error.msg })
|
||||
const sd = out._siteData || {}
|
||||
clientLike = {
|
||||
name: sd.name || v.host,
|
||||
hostname: v.host,
|
||||
brand_aliases: '[]',
|
||||
}
|
||||
if (!queryText) {
|
||||
const { queries } = await generateQueries({
|
||||
name: clientLike.name,
|
||||
description: sd.description,
|
||||
url: v.targetUrl,
|
||||
hostname: v.host,
|
||||
})
|
||||
queryText = queries?.[0] || `Welche Anbieter gibt es für ${clientLike.name}?`
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[demo-monitoring]', err?.message || err)
|
||||
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
|
||||
}
|
||||
|
||||
const providers = getProviders()
|
||||
const provider = providers.openai
|
||||
const result = await provider.query(queryText, { brandHint: clientLike.name })
|
||||
const mention = result.error
|
||||
? { mentioned: false, position: null, snippet: null }
|
||||
: detectMention(result.content, clientLike)
|
||||
|
||||
const truncated = (result.content || '').slice(0, 500)
|
||||
res.json({
|
||||
provider: result.provider,
|
||||
query: queryText,
|
||||
response: truncated,
|
||||
truncated: (result.content || '').length > 500,
|
||||
mentioned: mention.mentioned,
|
||||
snippet: mention.snippet,
|
||||
error: result.error || null,
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user