1022 lines
49 KiB
HTML
1022 lines
49 KiB
HTML
{% 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 & 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 %}
|