Files

1022 lines
49 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/parse-intel.css?v=1" />
<style>.word-chip-cloud{display:flex;flex-wrap:wrap;gap:.35rem;overflow-y:auto;padding:.4rem;background:rgba(0,0,0,.2);border-radius:8px}.word-chip{font-size:.72rem;padding:.15rem .5rem;border-radius:999px;background:rgba(232,168,56,.15);color:#f3e8d0;border:1px solid rgba(232,168,56,.3)}.word-chip.low{opacity:.7;font-size:.65rem}</style>
<style>
.browser-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.browser-tab {
padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem;
background: rgba(148,163,184,.08); border: 1px solid rgba(148,163,184,.15); color: var(--text-secondary);
}
.browser-tab.active { background: rgba(212,175,55,.15); border-color: var(--accent-gold); color: var(--accent-gold); }
.parse-result-card { border: 1px solid rgba(148,163,184,.14); border-radius: 10px; padding: .75rem .85rem; margin-bottom: .65rem; background: rgba(7,11,18,.6); }
.parse-result-card header { display:flex; flex-direction:column; gap:.15rem; margin-bottom:.45rem; }
.parse-excerpt { color:#94a3b8; font-size:.82rem; line-height:1.45; margin:0 0 .45rem; }
.parse-headings { display:flex; flex-wrap:wrap; gap:.35rem; margin-bottom:.4rem; }
.parse-chip { font-size:.68rem; padding:.12rem .45rem; border-radius:999px; background:rgba(0,229,255,.08); color:#7dd3fc; border:1px solid rgba(0,229,255,.2); }
.parse-links { margin:.25rem 0 0; padding-left:1rem; font-size:.78rem; color:#cbd5e1; }
.parse-links a { color:#86efac; }
.intel-kpi-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.65rem; margin-bottom:1rem; }
@media (max-width:900px){ .intel-kpi-grid { grid-template-columns:repeat(2,1fr); } }
.intel-kpi { padding:.75rem; border-radius:10px; border:1px solid rgba(148,163,184,.14); background:rgba(7,11,18,.65); }
.intel-kpi .lbl { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.04em; }
.intel-kpi .val { font-size:1.35rem; font-weight:700; color:#f1f5f9; margin-top:.15rem; }
.intel-toolbar { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:1rem; }
.intel-hype-cloud { display:flex; flex-wrap:wrap; gap:.45rem; margin:.5rem 0 1rem; }
.hype-chip { font-size:.78rem; padding:.25rem .6rem; border-radius:999px; border:1px solid rgba(168,85,247,.35); background:rgba(168,85,247,.12); color:#e9d5ff; cursor:default; }
.hype-chip.cross { border-color:rgba(255,215,0,.45); background:rgba(255,215,0,.1); color:#fde68a; box-shadow:0 0 12px rgba(255,215,0,.12); }
.hype-chip .score { opacity:.65; font-size:.65rem; margin-left:.25rem; }
.intel-page-card { border:1px solid rgba(148,163,184,.16); border-radius:12px; margin-bottom:.85rem; background:rgba(6,10,16,.85); overflow:hidden; }
.intel-page-head { display:flex; flex-wrap:wrap; gap:.5rem; align-items:flex-start; justify-content:space-between; padding:.85rem 1rem; border-bottom:1px solid rgba(148,163,184,.1); cursor:pointer; }
.intel-page-head:hover { background:rgba(0,229,255,.04); }
.intel-page-body { padding:0 1rem 1rem; }
.intel-signals { display:flex; flex-wrap:wrap; gap:.35rem; margin:.5rem 0; }
.signal-chip { font-size:.68rem; padding:.12rem .45rem; border-radius:999px; background:rgba(34,197,94,.12); color:#86efac; border:1px solid rgba(34,197,94,.25); }
.signal-chip.hot { background:rgba(245,158,11,.12); color:#fcd34d; border-color:rgba(245,158,11,.35); }
.intel-read-text { max-height:320px; overflow:auto; white-space:pre-wrap; font-size:.8rem; line-height:1.55; color:#cbd5e1; background:rgba(0,0,0,.35); padding:.85rem; border-radius:8px; border:1px solid rgba(148,163,184,.1); }
.intel-read-text.expanded { max-height:560px; }
.intel-section-title { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:#64748b; margin:1rem 0 .4rem; font-weight:700; }
.intel-trend-table { width:100%; font-size:.82rem; }
.intel-trend-table td, .intel-trend-table th { padding:.35rem .5rem; border-bottom:1px solid rgba(148,163,184,.08); text-align:left; }
.hype-score { font-size:.75rem; color:#f59e0b; font-weight:700; }
.browser-layout { display: grid; grid-template-columns: 1fr 280px; gap: 1rem; }
@media (max-width: 1100px) { .browser-layout { grid-template-columns: 1fr; } }
.browser-live-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;
}
@media (max-width: 900px) { .browser-live-row { grid-template-columns: 1fr; } }
.browser-live-panel { min-height: 380px; }
.browser-live-frame {
width: 100%; min-height: 420px; background: #0a0f1a; border-radius: 10px;
border: 1px solid rgba(148,163,184,.15); object-fit: contain;
}
.browser-novnc { width: 100%; height: 360px; border: 0; border-radius: 10px; background: #000; }
.browser-preview-text {
max-height: 220px; overflow: auto; font-size: 0.82rem; color: var(--text-muted);
white-space: pre-wrap; background: rgba(0,0,0,.25); padding: .75rem; border-radius: 8px;
}
.browser-links { max-height: 160px; overflow: auto; font-size: 0.78rem; }
.browser-status { font-size: 0.8rem; color: var(--accent-cyan); margin-top: 0.35rem; }
.novnc-hint { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.5rem; }
.preset-grid { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0; }
.preset-chip { font-size: 0.78rem; padding: 0.25rem 0.55rem; border-radius: 999px; cursor: pointer; border: 1px solid rgba(148,163,184,.2); background: rgba(0,0,0,.2); }
.preset-chip:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
.job-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
padding: 0.6rem 0.85rem; margin-bottom: 1rem; border-radius: 10px;
background: rgba(0,0,0,.25); border: 1px solid rgba(148,163,184,.12);
}
.job-bar.active { border-color: rgba(232,168,56,.35); }
</style>
{% endblock %}
{% block content %}
<div x-data="browserPage()" x-init="init()">
<header class="page-header">
<h1>Browser &amp; Monitor</h1>
<p class="subtitle page-tip">Voer een URL in — Playwright + live VNC desktop openen dezelfde pagina. Gebruik <strong>Volledige extractie</strong> voor gewicht, prijs en alle zichtbare data.</p>
</header>
<div class="job-bar" :class="{ active: jobRunning }">
<strong style="font-size:0.85rem">Achtergrondtaak</strong>
<span class="browser-status" style="margin:0" x-text="jobLabel || 'Geen actieve taak — je kunt vrij navigeren'"></span>
<button type="button" class="btn btn-sm btn-reject" x-show="jobRunning" @click="stopJob()">⏹ Stop</button>
<label style="font-size:0.78rem;color:var(--text-muted);margin-left:auto">
<input type="checkbox" x-model="vncSync" @change="startPoll()" /> Screenshot = VNC scherm
</label>
<label style="font-size:0.78rem;color:var(--text-muted)">
<input type="checkbox" x-model="livePoll" @change="startPoll()" /> Live poll
</label>
</div>
<section class="panel">
<h2>Snelle sites</h2>
<p class="page-tip">Klik om URL te laden. TikTok/Instagram vereisen soms inloggen via VNC.</p>
<div class="preset-grid">
<span class="preset-chip" @click="loadPreset('https://www.bidfood.nl/webshop/assortiment/mekkafood/_/N-1z10pje/')">Bidfood Mekkafood</span>
<span class="preset-chip" @click="loadPreset('https://www.sligro.nl/')">Sligro</span>
<span class="preset-chip" @click="loadPreset('https://www.tiktok.com/')">TikTok</span>
<span class="preset-chip" @click="loadPreset('https://www.instagram.com/')">Instagram</span>
<span class="preset-chip" @click="loadPreset('https://www.cheemafood.nl/')">Cheema</span>
</div>
<div class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem">
<input class="form-input" style="flex:2;min-width:200px" placeholder="Nieuwe site URL" x-model="newSiteUrl" />
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="newSiteName" />
<button type="button" class="btn btn-secondary" @click="addSiteQuick()">+ Toevoegen aan monitor</button>
</div>
</section>
<div class="browser-tabs">
<button type="button" class="browser-tab" :class="{ active: tab === 'research' }" @click="setTab('research')">Research</button>
<button type="button" class="browser-tab" :class="{ active: tab === 'monitor' }" @click="setTab('monitor')">Site Monitor <span class="muted">({{ stats.active_sites }})</span></button>
<button type="button" class="browser-tab" :class="{ active: tab === 'analyse' }" @click="setTab('analyse')">Parse Analyse</button>
</div>
<div class="browser-live-row" style="margin-bottom:1rem" x-show="tab === 'research'">
<section class="panel browser-live-panel">
<h2>Screenshot <small class="muted" x-text="vncSync ? 'VNC sync' : 'Playwright'"></small></h2>
<img class="browser-live-frame" :src="screenshotUrl" alt="Browser screenshot" />
</section>
<section class="panel browser-live-panel">
<h2>Live desktop <small class="muted">noVNC</small>
<a href="{{ novnc_url }}" target="_blank" style="font-size:0.75rem;margin-left:0.5rem">Open apart ↗</a>
</h2>
<iframe class="browser-novnc" src="{{ novnc_url }}" title="Live VNC" data-clear-on-nav></iframe>
<p class="novnc-hint">Wachtwoord: <code>Foodlinkk2026</code>. Tip: open VNC ook <a href="{{ novnc_url }}" target="_blank">in apart venster</a> — dan geen navigatie-waarschuwing bij wisselen van pagina.</p>
</section>
</div>
<div x-show="tab === 'research'">
<section class="panel browser-instruct-box">
<h2>Interactief — leer Herman wat te klikken</h2>
<p class="page-tip">Beschrijf in het Nederlands wat de browser moet doen. Voorbeeld: <em>Accepteren cookies, scroll naar beneden, klik op Mekkafood filter</em></p>
<textarea class="form-input" x-model="instruction" rows="3" placeholder="Accepteren cookies → scroll 2× → klik op 'Snacks'"></textarea>
<div class="browser-action-bar">
<button type="button" class="btn btn-primary" @click="instructBrowse()" :disabled="loading">Uitvoeren + screenshot</button>
<button type="button" class="btn btn-primary" @click="extractFull()" :disabled="loading">🔬 Volledige extractie</button>
<button type="button" class="btn btn-secondary" @click="vncNavigate()" :disabled="loading">🖥 Open in VNC</button>
<button type="button" class="btn btn-secondary" @click="exportPhoto()" :disabled="!session">📥 Foto export</button>
<button type="button" class="btn btn-secondary" @click="runOcr()" :disabled="!session">🔍 OCR tekst</button>
<button type="button" class="btn btn-secondary" @click="savePhotoToDb()" :disabled="!session">💾 Foto → DB</button>
<a class="btn btn-secondary" href="{{ gradio_url }}" target="_blank" rel="noopener">🤖 Browser Use AI</a>
</div>
<div class="browser-steps-log" x-show="stepsLog.length" x-text="stepsLog.join(' · ')"></div>
<div x-show="ocrChips.length" style="margin-top:0.5rem">
<small class="muted">OCR woorden (compact)</small>
<div class="word-chip-cloud" style="max-height:72px;margin-top:0.35rem">
<template x-for="(w,i) in ocrChips" :key="i">
<span class="word-chip low" x-text="w"></span>
</template>
</div>
</div>
</section>
<section class="panel">
<form class="form-grid" @submit.prevent="browse()" style="grid-template-columns: 1fr 1fr 80px auto auto auto;">
<input class="form-input" x-model="url" placeholder="https://..." required />
<input class="form-input" x-model="task" placeholder="Taak (optioneel)" />
<input class="form-input" type="number" min="1" max="12" x-model.number="scrollPages" title="Scroll pagina's voor extractie" />
<button type="submit" class="btn btn-primary" :disabled="loading">
<span x-show="!loading">Browse + VNC</span><span x-show="loading">Bezig…</span>
</button>
<button type="button" class="btn btn-secondary" @click="addMonitor()">+ Monitor</button>
</form>
<div class="browser-status" x-text="statusText"></div>
</section>
<div class="browser-layout">
<div>
<section class="panel" x-show="extractedItems.length">
<h2>Geëxtraheerde data <small class="muted" x-text="'(' + extractedItems.length + ' items)'"></small></h2>
<table class="data-table extract-table">
<thead><tr><th>Regel / product</th><th>Gewicht</th><th>Prijs</th><th>Bron</th></tr></thead>
<tbody>
<template x-for="(it, idx) in extractedItems" :key="idx">
<tr>
<td x-text="it.raw"></td>
<td x-text="it.weight || '—'"></td>
<td x-text="it.price || '—'"></td>
<td x-text="it.source || '—'"></td>
</tr>
</template>
</tbody>
</table>
</section>
<section class="panel" x-show="session">
<h2 x-text="session ? session.title : 'Resultaat'"></h2>
<p class="page-tip"><span x-text="session ? session.final_url : ''"></span></p>
<div class="browser-preview-text" x-text="session ? (session.content_preview || session.content_text || '') : ''"></div>
<div class="browser-links" x-show="session && session.links && session.links.length">
<strong>Links op pagina:</strong>
<template x-for="lnk in (session.links || []).slice(0,15)" :key="lnk.href">
<div><a :href="lnk.href" target="_blank" x-text="lnk.text || lnk.href"></a></div>
</template>
</div>
</section>
</div>
<aside>
<section class="panel">
<h2>Recente sessies</h2>
<template x-for="s in recent" :key="s.id">
<div class="clickable-row" style="padding:.4rem 0;border-bottom:1px solid rgba(148,163,184,.1)" @click="loadSession(s.id)">
<strong x-text="s.title || s.url"></strong><br/>
<small x-text="s.created_at ? s.created_at.substring(0,16).replace('T',' ') : ''"></small>
</div>
</template>
<p class="empty-state" x-show="!recent.length">Nog geen sessies.</p>
</section>
</aside>
</div>
</div>
<div x-show="tab === 'monitor'" x-cloak>
<div class="kpi-grid">
<div class="kpi-card clickable-kpi"><div class="kpi-label">Active sites</div><div class="kpi-value">{{ stats.active_sites }}</div></div>
<div class="kpi-card amber clickable-kpi"><div class="kpi-label">Changes 24h</div><div class="kpi-value">{{ stats.changes_24h }}</div></div>
</div>
<section class="panel">
<h2>Website toevoegen</h2>
<form class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center" @submit.prevent="addSite">
<input class="form-input" style="flex:2;min-width:200px" placeholder="https://..." x-model="monitorForm.url" required />
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="monitorForm.name" />
<button class="btn btn-primary" type="submit">Toevoegen</button>
<button type="button" class="btn btn-secondary" @click="crawlAll()" :disabled="crawlBusy" x-text="crawlBusy ? 'Crawlen…' : 'Alles crawlen'"></button>
</form>
</section>
<section class="panel">
<h2>Gemonitorde websites</h2>
<table class="data-table"><thead><tr><th>Naam</th><th>URL</th><th>Laatst gecheckt</th><th>Acties</th></tr></thead>
<tbody>{% for s in sites %}
<tr>
<td>{{ s.name }}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis" title="{{ s.url }}">{{ s.url }}</td>
<td>{{ s.last_crawled or '—' }}</td>
<td class="btn-group">
<button class="btn btn-sm" @click="crawlOne({{ s.id }})" :disabled="crawlBusy">Crawl</button>
<button class="btn btn-sm btn-secondary" @click="toggleSite({{ s.id }})">{{ 'Pauze' if s.is_active else 'Activeer' }}</button>
<button class="btn btn-sm btn-reject" @click="removeSite({{ s.id }})">Verwijder</button>
</td></tr>{% else %}
<tr><td colspan="4" class="empty-state">Geen sites — voeg een URL toe of klik + Monitor bij Research.</td></tr>
{% endfor %}</tbody></table>
</section>
<div class="grid-2">
<section class="panel"><h2>Wijzigingen</h2>
<ul>{% for c in page_changes %}<li><strong>{{ c.name or c.url }}</strong> · {{ c.changed_at }}</li>{% else %}<li class="empty-state">Geen wijzigingen</li>{% endfor %}</ul>
</section>
<section class="panel"><h2>Crawl logs</h2>
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% else %}<li class="empty-state">Nog geen crawls</li>{% endfor %}</ul>
</section>
</div>
<section class="panel" x-show="parseResults.length" x-cloak>
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem">
<div>
<h2 style="margin:0">Parse-overzicht (kort)</h2>
<p class="page-tip" style="margin:.25rem 0 0">Voor volledige analyse → tab <button type="button" class="btn btn-sm btn-pulse" @click="setTab('analyse')">Parse Analyse</button></p>
</div>
<button type="button" class="btn btn-sm" @click="loadParseResults()">↻ Verversen</button>
</div>
<template x-for="p in parseResults" :key="p.id">
<article class="parse-result-card">
<header>
<strong x-text="p.title || p.url"></strong>
<span class="muted" x-text="'#' + (p.site_id||'—') + ' · ' + (p.word_count||0) + ' woorden · ' + (p.links_count||0) + ' links'"></span>
</header>
<p class="parse-excerpt" x-text="p.excerpt || p.meta_description || '—'"></p>
<div class="parse-headings" x-show="(p.headings||[]).length">
<template x-for="h in (p.headings||[]).slice(0,5)" :key="h">
<span class="parse-chip" x-text="h"></span>
</template>
</div>
<ul class="parse-links" x-show="(p.links_sample||[]).length">
<template x-for="lnk in (p.links_sample||[]).slice(0,6)" :key="lnk.href">
<li><a :href="lnk.href" target="_blank" rel="noopener" x-text="lnk.label || lnk.href"></a></li>
</template>
</ul>
</article>
</template>
</section>
</div>
<div x-show="tab === 'analyse'" x-cloak class="pi-workbench">
<header class="pi-header">
<div>
<h2>Parse Intelligence Workbench</h2>
<p class="page-tip" style="margin:0.25rem 0 0">Filter · sorteer · klik hype-termen · exporteer — alles wat gecrawld is</p>
</div>
<div class="pi-header-actions">
<button type="button" class="btn btn-sm btn-secondary" @click="loadIntelligence()" :disabled="intelLoading">↻ Ververs</button>
<button type="button" class="btn btn-sm btn-secondary" @click="exportIntelCsv()" x-show="intelFilteredPages.length">⬇ CSV</button>
<button type="button" class="btn btn-sm btn-pulse" @click="crawlAndAnalyse()" :disabled="crawlBusy || intelLoading">Crawl + analyse</button>
</div>
</header>
<div class="pi-kpi-strip" x-show="intel && intel.summary">
<div class="pi-kpi-cell">
<div class="lbl">Gefilterd</div>
<div class="val" x-text="intelFilteredPages.length + '/' + (intel.pages||[]).length">0</div>
<div class="sub">pagina's zichtbaar</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Woorden</div>
<div class="val" x-text="intelFilteredWordSum">0</div>
<div class="sub">in selectie</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Hype-termen</div>
<div class="val" x-text="(intel.hype_terms||[]).length">0</div>
<div class="sub">klik om te filteren</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Gewijzigd 7d</div>
<div class="val" x-text="intel.summary.changes_7d">0</div>
<div class="sub">shift-signalen</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Gem. hype</div>
<div class="val" x-text="intelAvgHype">0</div>
<div class="sub">score selectie</div>
</div>
</div>
<div class="pi-loading" x-show="intelLoading">Analyse laden…</div>
<div class="pi-body" :class="{ 'detail-open': intelSelectedId }" x-show="intel && !intelLoading">
<aside class="pi-filters">
<label>Zoek in alle geparste data</label>
<input type="search" class="pi-search" placeholder="halal, curry, bidfood…" x-model="intelSearchLocal" @input.debounce.250ms="" />
<label>Site</label>
<select class="pi-select" x-model="intelSiteId">
<option value="">Alle sites</option>
<template x-for="s in intelSiteOptions" :key="s.id">
<option :value="s.id" x-text="s.name"></option>
</template>
</select>
<label>Min. woorden <span x-text="intelMinWords"></span></label>
<input type="range" class="pi-range" min="0" max="3000" step="50" x-model.number="intelMinWords" />
<label>Min. hype-score <span x-text="intelMinHype"></span></label>
<input type="range" class="pi-range" min="0" max="80" step="5" x-model.number="intelMinHype" />
<label class="pi-toggle-row"><input type="checkbox" x-model="intelChangedOnly" /> Alleen recent gewijzigd</label>
<label>Sorteren</label>
<select class="pi-select" x-model="intelSortCol" @change="">
<option value="hype_score">Hype score</option>
<option value="word_count">Woorden</option>
<option value="crawled_at">Datum crawl</option>
<option value="site_name">Site</option>
<option value="title">Titel</option>
<option value="links_count">Links</option>
</select>
<select class="pi-select" style="margin-top:0.35rem" x-model="intelSortDir">
<option value="desc">Aflopend ↓</option>
<option value="asc">Oplopend ↑</option>
</select>
<div class="pi-filter-chips" x-show="intelActiveTerms.length || intelChangedOnly || intelMinWords > 0">
<template x-for="t in intelActiveTerms" :key="t">
<span class="pi-filter-chip">term: <span x-text="t"></span><button type="button" @click="removeIntelTerm(t)">×</button></span>
</template>
<span class="pi-filter-chip" x-show="intelChangedOnly">gewijzigd<button type="button" @click="intelChangedOnly=false">×</button></span>
<button type="button" class="btn btn-sm" style="margin-top:0.35rem;width:100%" @click="clearIntelFilters()">Filters wissen</button>
</div>
</aside>
<div class="pi-main">
<div class="pi-trends-bar" x-show="(intel.hype_terms||[]).length">
<div class="lbl">Hype-termen — klik om te filteren (goud = cross-site)</div>
<div class="pi-hype-cloud">
<template x-for="t in (intel.hype_terms||[]).slice(0,35)" :key="t.term">
<span class="pi-hype-chip" :class="{ cross: t.cross_site, active: isIntelTermActive(t.term) }"
@click="toggleIntelTerm(t.term)" :title="'Sites: ' + (t.sites||[]).join(', ')">
<span x-text="t.term"></span><span class="sc" x-text="t.score"></span>
</span>
</template>
</div>
</div>
<div class="pi-toolbar">
<span class="count"><strong x-text="intelFilteredPages.length"></strong> resultaten</span>
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'hype_score' }" @click="sortIntelBy('hype_score')">Hype</button>
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'word_count' }" @click="sortIntelBy('word_count')">Woorden</button>
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'crawled_at' }" @click="sortIntelBy('crawled_at')">Datum</button>
<div class="pi-view-toggle">
<button type="button" :class="{ active: intelView === 'table' }" @click="intelView='table'">Tabel</button>
<button type="button" :class="{ active: intelView === 'cards' }" @click="intelView='cards'">Kaarten</button>
</div>
</div>
<div class="pi-empty" x-show="!intelFilteredPages.length">
<strong>Geen matches</strong>
Pas filters aan, wis hype-termen, of crawl eerst onder Site Monitor.
</div>
<div class="pi-table-wrap" x-show="intelView === 'table' && intelFilteredPages.length">
<table class="pi-table">
<thead>
<tr>
<th @click="sortIntelBy('site_name')">Site <span class="arrow" x-show="intelSortCol==='site_name'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('title')">Titel <span class="arrow" x-show="intelSortCol==='title'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('word_count')" class="num">Woorden <span class="arrow" x-show="intelSortCol==='word_count'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('hype_score')" class="num">Hype <span class="arrow" x-show="intelSortCol==='hype_score'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('links_count')" class="num">Links</th>
<th>Signalen</th>
</tr>
</thead>
<tbody>
<template x-for="p in intelFilteredPages" :key="p.id">
<tr :class="{ selected: intelSelectedId === p.id }" @click="selectIntelPage(p.id)">
<td class="site-cell" x-text="p.site_name"></td>
<td class="title-cell" :title="p.title || p.url" x-text="p.title || p.url"></td>
<td class="num" x-text="p.word_count"></td>
<td class="num"><span class="pi-badge hype-high" x-text="p.hype_score"></span></td>
<td class="num" x-text="p.links_count"></td>
<td>
<span class="pi-badge changed" x-show="p.recently_changed">Δ</span>
<span class="muted" style="font-size:.7rem" x-text="(p.signals||[])[0]||''"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pi-cards" x-show="intelView === 'cards' && intelFilteredPages.length">
<template x-for="p in intelFilteredPages" :key="'c-'+p.id">
<article class="pi-card" :class="{ selected: intelSelectedId === p.id }" @click="selectIntelPage(p.id)">
<div class="site-cell" x-text="p.site_name"></div>
<strong style="font-size:.85rem;display:block;margin:.25rem 0" x-text="p.title||p.url"></strong>
<div class="muted" style="font-size:.72rem" x-text="(p.word_count||0)+' wo · hype '+ (p.hype_score||0)"></div>
<p style="font-size:.75rem;color:#94a3b8;margin:.4rem 0 0;line-height:1.4" x-text="(p.excerpt||'').slice(0,120)+'…'"></p>
</article>
</template>
</div>
</div>
<aside class="pi-detail">
<template x-if="!intelSelectedPage">
<div class="pi-detail-empty">← Klik een rij om de volledige parse te lezen<br><small>Highlight op actieve zoektermen</small></div>
</template>
<template x-if="intelSelectedPage">
<div style="display:flex;flex-direction:column;height:100%">
<header class="pi-detail-head">
<h3 x-text="intelSelectedPage.site_name + ' — ' + (intelSelectedPage.title || 'Pagina')"></h3>
<div class="pi-detail-meta">
<span x-text="(intelSelectedPage.word_count||0) + ' woorden'"></span> ·
<span x-text="'hype ' + (intelSelectedPage.hype_score||0)"></span> ·
<span x-text="(intelSelectedPage.crawled_at||'').substring(0,16).replace('T',' ')"></span>
</div>
<div class="intel-signals" style="margin-top:.5rem">
<template x-for="sig in (intelSelectedPage.signals||[])" :key="sig">
<span class="signal-chip" :class="{ hot: sig.indexOf('gewijzigd')>=0 }" x-text="sig"></span>
</template>
</div>
<div style="margin-top:.5rem;display:flex;gap:.4rem;flex-wrap:wrap">
<a class="btn btn-sm btn-secondary" :href="intelSelectedPage.final_url || intelSelectedPage.url" target="_blank" rel="noopener">Live URL ↗</a>
<button type="button" class="btn btn-sm" @click="loadFullPage(intelSelectedPage.id)">Volledige tekst</button>
</div>
</header>
<div class="pi-detail-tabs">
<button type="button" :class="{ active: intelDetailTab === 'text' }" @click="intelDetailTab='text'">Tekst</button>
<button type="button" :class="{ active: intelDetailTab === 'headings' }" @click="intelDetailTab='headings'">Koppen</button>
<button type="button" :class="{ active: intelDetailTab === 'links' }" @click="intelDetailTab='links'">Links</button>
</div>
<div class="pi-detail-body">
<div x-show="intelDetailTab === 'text'" class="pi-read-text" x-html="intelDetailHtml"></div>
<div x-show="intelDetailTab === 'headings'" class="pi-heading-list">
<template x-for="h in (intelSelectedPage.headings||[])" :key="h">
<div class="pi-heading-item" x-text="h"></div>
</template>
<p class="muted" x-show="!(intelSelectedPage.headings||[]).length">Geen koppen gevonden</p>
</div>
<ul x-show="intelDetailTab === 'links'" class="pi-link-list">
<template x-for="lnk in (intelSelectedPage.links_sample||[])" :key="lnk.href">
<li><a :href="lnk.href" target="_blank" rel="noopener" x-text="lnk.label || lnk.href"></a></li>
</template>
</ul>
</div>
</div>
</template>
</aside>
</div>
<div class="pi-empty" x-show="!intel && !intelLoading">
<strong>Nog geen data</strong>
Voeg sites toe onder Site Monitor, crawl, en kom terug — of klik Crawl + analyse.
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/parse-intel.js?v=1"></script>
<script>
function browserPage() {
return {
tab: (function () {
const h = window.location.hash;
if (h === '#monitor') return 'monitor';
if (h === '#analyse') return 'analyse';
return 'research';
})(),
url: {{ default_url | tojson }},
instruction: 'Accepteren cookies, scroll naar beneden, toon alle producten',
scrollPages: 6,
newSiteUrl: '',
newSiteName: '',
extractedItems: [],
stepsLog: [],
ocrText: '',
ocrChips: [],
loading: false,
livePoll: true,
vncSync: true,
jobRunning: false,
jobLabel: '',
jobAbort: null,
session: null,
recent: [],
monitorForm: { url: '', name: '' },
parseResults: [],
crawlBusy: false,
intel: null,
intelLoading: false,
intelSearchLocal: '',
intelSiteId: '',
intelSortCol: 'hype_score',
intelSortDir: 'desc',
intelView: 'table',
intelMinWords: 0,
intelMinHype: 0,
intelChangedOnly: false,
intelActiveTerms: [],
intelSelectedId: null,
intelDetailTab: 'text',
intelFullPage: null,
fullPageText: '',
get intelFilteredPages() {
if (!this.intel || !window.ParseIntel) return [];
const pages = ParseIntel.filterPages(this.intel.pages || [], {
search: this.intelSearchLocal,
terms: this.intelActiveTerms,
siteId: this.intelSiteId,
minWords: this.intelMinWords,
minHype: this.intelMinHype,
changedOnly: this.intelChangedOnly,
});
return ParseIntel.sortPages(pages, this.intelSortCol, this.intelSortDir);
},
get intelSelectedPage() {
const id = this.intelSelectedId;
if (!id) return null;
const all = this.intel && this.intel.pages ? this.intel.pages : [];
let p = all.find(function (x) { return x.id === id; });
if (p && this.intelFullPage === id && this.fullPageText) {
p = Object.assign({}, p, { content_read: this.fullPageText });
}
return p || null;
},
get intelDetailHtml() {
const p = this.intelSelectedPage;
if (!p || !window.ParseIntel) return '';
const text = (this.intelFullPage === p.id && this.fullPageText) ? this.fullPageText : (p.content_read || p.excerpt || '');
const terms = (this.intelActiveTerms || []).slice();
if ((this.intelSearchLocal || '').trim()) terms.push(this.intelSearchLocal.trim());
return ParseIntel.highlightHtml(text, terms);
},
get intelFilteredWordSum() {
return (this.intelFilteredPages || []).reduce(function (s, p) { return s + (p.word_count || 0); }, 0);
},
get intelAvgHype() {
const rows = this.intelFilteredPages || [];
if (!rows.length) return 0;
const sum = rows.reduce(function (s, p) { return s + (p.hype_score || 0); }, 0);
return Math.round(sum / rows.length);
},
get intelSiteOptions() {
const seen = {};
const out = [];
(this.intel && this.intel.pages ? this.intel.pages : []).forEach(function (p) {
if (!p.site_id || seen[p.site_id]) return;
seen[p.site_id] = true;
out.push({ id: String(p.site_id), name: p.site_name || ('Site ' + p.site_id) });
});
return out.sort(function (a, b) { return a.name.localeCompare(b.name); });
},
screenshotUrl: '/api/admin/browser/live/screenshot.jpg?t=0',
statusText: 'Klaar — voer een URL in of klik Browse nu.',
pollTimer: null,
normalizeUrl(u) {
u = (u || '').trim();
if (!u) return u;
if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
return u;
},
siteNameFromUrl(u) {
try { return new URL(u).hostname.replace(/^www\./, ''); } catch (e) { return u; }
},
setTab(t) {
this.tab = t;
const hash = t === 'monitor' ? '#monitor' : (t === 'analyse' ? '#analyse' : '');
history.replaceState(null, '', hash ? '/browser' + hash : '/browser');
if (t === 'monitor') this.loadParseResults();
if (t === 'analyse') this.loadIntelligence();
},
init() {
if (window.location.hash === '#monitor') this.tab = 'monitor';
if (window.location.hash === '#analyse') this.tab = 'analyse';
this.$watch('tab', (t) => {
if (t === 'monitor') this.loadParseResults();
if (t === 'analyse') this.loadIntelligence();
});
window.addEventListener('hashchange', () => {
const h = window.location.hash;
this.tab = h === '#monitor' ? 'monitor' : (h === '#analyse' ? 'analyse' : 'research');
});
this.loadRecent();
this.startPoll();
},
startPoll() {
if (this.pollTimer) clearInterval(this.pollTimer);
if (!this.livePoll) return;
const base = this.vncSync
? '/api/admin/browser/vnc/screenshot.jpg?t='
: '/api/admin/browser/live/screenshot.jpg?t=';
this.pollTimer = setInterval(() => {
this.screenshotUrl = base + Date.now();
}, 1500);
this.screenshotUrl = base + Date.now();
},
startJob(label) {
if (this.jobAbort) this.jobAbort.abort();
this.jobAbort = new AbortController();
this.jobRunning = true;
this.jobLabel = label;
return this.jobAbort.signal;
},
endJob(msg) {
this.jobRunning = false;
this.jobLabel = msg || '';
this.jobAbort = null;
},
stopJob() {
if (this.jobAbort) {
this.jobAbort.abort();
Cockpit.toast('Taak geannuleerd — server kan nog kort doorlopen', 'info');
}
this.endJob('Gestopt door gebruiker');
this.loading = false;
},
async runJob(label, fn) {
const signal = this.startJob(label);
this.loading = true;
try {
return await fn(signal);
} catch (e) {
if (e.name === 'AbortError') {
this.endJob('Gestopt');
return null;
}
throw e;
} finally {
this.loading = false;
if (this.jobRunning) this.endJob('Klaar');
}
},
reloadPage() {
if (window.Cockpit && Cockpit.clearIframes) Cockpit.clearIframes();
setTimeout(() => location.reload(), 80);
},
async loadRecent() {
try {
const r = await Cockpit.api('/browser/sessions?limit=8');
this.recent = r.sessions || [];
} catch (e) {}
},
async instructBrowse() {
this.stepsLog = [];
this.statusText = 'Instructies uitvoeren…';
try {
const r = await this.runJob('Instructies uitvoeren…', (signal) =>
Cockpit.api('/browser/instruct', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
signal,
})
);
if (!r) return;
this.session = r.session;
this.stepsLog = (r.session.metadata && r.session.metadata.steps) || [];
this.statusText = 'Klaar — ' + this.stepsLog.length + ' stappen';
if (r.session.id) {
this.screenshotUrl = '/api/admin/browser/sessions/' + r.session.id + '/screenshot.jpg?t=' + Date.now();
this.vncSync = false;
}
await this.loadRecent();
Cockpit.toast('Instructies uitgevoerd', 'success');
} catch (e) { this.statusText = e.message; Cockpit.toast(e.message, 'error'); }
},
exportPhoto() {
if (!this.session || !this.session.id) return;
const a = document.createElement('a');
a.href = '/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg';
a.download = 'foodlinkk-browser-' + this.session.id + '.jpg';
a.click();
},
async runOcr() {
if (!this.session || !this.session.id) return;
try {
const r = await Cockpit.api('/browser/sessions/' + this.session.id + '/ocr');
this.ocrText = r.ocr_text || '';
const words = (this.ocrText.match(/[\wÀ-ÿ]{3,}/g) || []);
const seen = new Set();
this.ocrChips = [];
words.forEach(w => { const k = w.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.ocrChips.push(w); } });
if (this.ocrChips.length > 40) this.ocrChips = this.ocrChips.slice(0, 40);
Cockpit.toast('OCR klaar', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async browse() {
this.statusText = 'Browser + VNC starten op achtergrond…';
try {
const r = await this.runJob('Browse + VNC…', async (signal) => {
const vncP = Cockpit.api('/browser/vnc-navigate', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 4 }),
signal,
}).catch(() => null);
const browseP = Cockpit.api('/browser/browse', {
method: 'POST',
body: JSON.stringify({ url: this.url, task: this.task, instruction: this.instruction, wait_seconds: 4 }),
signal,
});
const [res] = await Promise.all([browseP, vncP]);
return res;
});
if (!r) return;
this.session = r.session;
this.statusText = 'Klaar — sessie #' + r.session.id + ' (je kunt verder navigeren)';
if (r.session.id) {
this.vncSync = true;
this.startPoll();
}
await this.loadRecent();
Cockpit.toast('Browse gestart — VNC + database', 'success');
} catch (e) {
this.statusText = 'Fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
async vncNavigate() {
this.statusText = 'VNC opent URL…';
try {
const r = await this.runJob('VNC navigate…', (signal) =>
Cockpit.api('/browser/vnc-navigate', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
signal,
})
);
if (!r) return;
this.statusText = 'VNC: ' + (r.title || r.final_url || this.url);
this.vncSync = true;
this.startPoll();
Cockpit.toast('URL geopend in live desktop', 'success');
} catch (e) {
this.statusText = 'VNC fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
async extractFull() {
this.statusText = 'Extractie op achtergrond…';
this.extractedItems = [];
try {
const r = await this.runJob('Volledige extractie…', (signal) =>
Cockpit.api('/browser/extract-full', {
method: 'POST',
body: JSON.stringify({
url: this.url, instruction: this.instruction, wait_seconds: 5,
scroll_pages: this.scrollPages, also_vnc: true,
}),
signal,
})
);
if (!r) return;
this.session = r.session;
this.extractedItems = r.session.extracted_items || (r.session.metadata && r.session.metadata.extracted_items) || [];
this.statusText = 'Extractie klaar — ' + this.extractedItems.length + ' items';
if (r.session.id) {
this.vncSync = true;
this.startPoll();
}
await this.loadRecent();
Cockpit.toast(this.extractedItems.length + ' items geëxtraheerd', 'success');
} catch (e) {
this.statusText = 'Extractie fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
loadPreset(u) {
this.url = u;
this.statusText = 'Preset geladen — klik Browse + VNC of Volledige extractie';
},
async addSiteQuick() {
const u = this.normalizeUrl(this.newSiteUrl || this.url);
if (!u) return Cockpit.toast('Geen URL', 'error');
try {
await Cockpit.api('/monitor/sites', {
method: 'POST',
body: JSON.stringify({ url: u, name: this.newSiteName || this.siteNameFromUrl(u) }),
});
Cockpit.toast('Site toegevoegd — live in Agents → Terminals (Browser)', 'success');
this.newSiteUrl = '';
this.newSiteName = '';
this.setTab('monitor');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async savePhotoToDb() {
if (!this.session || !this.session.id) return;
try {
const img = await fetch('/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg');
const blob = await img.blob();
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result.split(',')[1]);
r.onerror = rej;
r.readAsDataURL(blob);
});
const r = await Cockpit.api('/photos/analyze', {
method: 'POST',
body: JSON.stringify({
image_b64: b64,
source: 'browser-session',
filename: 'session-' + this.session.id + '.jpg',
session_id: this.session.id,
}),
});
Cockpit.toast('Foto opgeslagen in DB #' + r.photo.id, 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async loadSession(id) {
try {
const r = await Cockpit.api('/browser/sessions/' + id);
this.session = r.session;
this.url = r.session.url || this.url;
this.extractedItems = (r.session.metadata && r.session.metadata.extracted_items) || r.session.extracted_items || [];
this.screenshotUrl = '/api/admin/browser/sessions/' + id + '/screenshot.jpg?t=' + Date.now();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async addMonitor() {
const u = this.normalizeUrl(this.url);
if (!u) return Cockpit.toast('Geen URL', 'error');
try {
await Cockpit.api('/monitor/sites', {
method: 'POST',
body: JSON.stringify({ url: u, name: (this.session && this.session.title) || this.siteNameFromUrl(u) }),
});
Cockpit.toast('Toegevoegd aan Site Monitor', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async addSite() {
const payload = { ...this.monitorForm, url: this.normalizeUrl(this.monitorForm.url) };
if (!payload.url) return Cockpit.toast('URL verplicht', 'error');
if (!payload.name) payload.name = this.siteNameFromUrl(payload.url);
try {
await Cockpit.api('/monitor/sites', { method: 'POST', body: JSON.stringify(payload) });
Cockpit.toast('Site toegevoegd — live in Agents → Terminals (Browser)', 'success');
this.reloadPage();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async loadParseResults() {
try {
const r = await Cockpit.api('/monitor/parse-results?limit=12');
this.parseResults = r.items || [];
} catch (e) { /* ignore */ }
},
async loadIntelligence() {
this.intelLoading = true;
try {
this.intel = await Cockpit.api('/monitor/intelligence?limit=100');
if (this.intelSelectedId) {
const still = (this.intel.pages || []).some(function (p) { return p.id === this.intelSelectedId; }.bind(this));
if (!still) {
this.intelSelectedId = null;
this.intelFullPage = null;
this.fullPageText = '';
}
}
} catch (e) {
Cockpit.toast(e.message || 'Analyse laden mislukt', 'error');
} finally {
this.intelLoading = false;
}
},
sortIntelBy(col) {
if (this.intelSortCol === col) {
this.intelSortDir = this.intelSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.intelSortCol = col;
this.intelSortDir = col === 'title' || col === 'site_name' ? 'asc' : 'desc';
}
},
toggleIntelTerm(term) {
term = String(term || '').toLowerCase();
if (!term) return;
const idx = this.intelActiveTerms.indexOf(term);
if (idx >= 0) this.intelActiveTerms.splice(idx, 1);
else this.intelActiveTerms.push(term);
},
isIntelTermActive(term) {
return this.intelActiveTerms.indexOf(String(term).toLowerCase()) >= 0;
},
removeIntelTerm(term) {
const idx = this.intelActiveTerms.indexOf(term);
if (idx >= 0) this.intelActiveTerms.splice(idx, 1);
},
clearIntelFilters() {
this.intelSearchLocal = '';
this.intelSiteId = '';
this.intelMinWords = 0;
this.intelMinHype = 0;
this.intelChangedOnly = false;
this.intelActiveTerms = [];
},
selectIntelPage(id) {
this.intelSelectedId = id;
this.intelDetailTab = 'text';
this.intelFullPage = null;
this.fullPageText = '';
},
exportIntelCsv() {
if (!window.ParseIntel || !this.intelFilteredPages.length) return;
ParseIntel.downloadCsv(this.intelFilteredPages, 'foodlinkk-parse-' + new Date().toISOString().slice(0, 10) + '.csv');
Cockpit.toast('CSV gedownload', 'success');
},
async crawlAndAnalyse() {
await this.crawlAll();
const self = this;
setTimeout(function () { self.loadIntelligence(); }, 2800);
},
async loadFullPage(id) {
try {
const r = await Cockpit.api('/monitor/parse-results/' + id);
this.intelFullPage = id;
this.fullPageText = (r.page && (r.page.content_full || r.page.content_read)) || '';
Cockpit.toast('Volledige tekst geladen', 'success');
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
formatCrawlToast(result) {
const rows = result.results || [];
if (!rows.length) return result.message || 'Geen sites om te crawlen';
return rows.map(function (r) {
if (r.status === 'ERROR') return '✗ ' + (r.name || r.url) + ': ' + (r.error || 'fout');
return (r.changed ? '△ ' : '✓ ') + (r.title || r.name) + ' — ' + (r.word_count || 0) + ' woorden';
}).join('\n');
},
async crawlAll() {
if (this.crawlBusy) return;
this.crawlBusy = true;
try {
const r = await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
Cockpit.toast(this.formatCrawlToast(r), r.errors ? 'warn' : 'success');
await this.loadParseResults();
setTimeout(() => this.reloadPage(), 1500);
} catch (e) { Cockpit.toast(e.message, 'error'); }
finally { this.crawlBusy = false; }
},
async crawlOne(id) {
if (this.crawlBusy) return;
this.crawlBusy = true;
try {
const r = await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
Cockpit.toast(this.formatCrawlToast(r), r.errors ? 'warn' : 'success');
await this.loadParseResults();
setTimeout(() => this.reloadPage(), 1500);
} catch (e) { Cockpit.toast(e.message, 'error'); }
finally { this.crawlBusy = false; }
},
async toggleSite(id) {
await Cockpit.api('/monitor/sites/' + id + '/toggle', { method: 'PATCH' });
this.reloadPage();
},
async removeSite(id) {
if (!confirm('Website permanent verwijderen uit monitoring?')) return;
await Cockpit.api('/monitor/sites/' + id + '?hard=true', { method: 'DELETE' });
Cockpit.toast('Verwijderd', 'success'); this.reloadPage();
},
};
}
</script>
{% endblock %}