2026-06-09 00:41:27 +00:00
{% extends "base.html" %}
{% block extra_head %}
2026-06-23 10:04:23 +00:00
< link rel = "stylesheet" href = "/static/css/parse-intel.css?v=1" />
2026-06-09 00:41:27 +00:00
< style >. word-chip-cloud { display : flex ; flex-wrap : wrap ; gap : .35 rem ; overflow-y : auto ; padding : .4 rem ; background : rgba ( 0 , 0 , 0 , .2 ); border-radius : 8 px }. word-chip { font-size : .72 rem ; padding : .15 rem .5 rem ; border-radius : 999 px ; background : rgba ( 232 , 168 , 56 , .15 ); color : #f3e8d0 ; border : 1 px solid rgba ( 232 , 168 , 56 , .3 )}. word-chip . low { opacity : .7 ; font-size : .65 rem }</ style >
< style >
. browser-tabs { display : flex ; gap : 0.5 rem ; margin-bottom : 1 rem ; }
. browser-tab {
padding : 0.5 rem 1 rem ; border-radius : 8 px ; cursor : pointer ; font-size : 0.9 rem ;
background : rgba ( 148 , 163 , 184 , .08 ); border : 1 px 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 ); }
2026-06-23 10:04:23 +00:00
. parse-result-card { border : 1 px solid rgba ( 148 , 163 , 184 , .14 ); border-radius : 10 px ; padding : .75 rem .85 rem ; margin-bottom : .65 rem ; background : rgba ( 7 , 11 , 18 , .6 ); }
. parse-result-card header { display : flex ; flex-direction : column ; gap : .15 rem ; margin-bottom : .45 rem ; }
. parse-excerpt { color : #94a3b8 ; font-size : .82 rem ; line-height : 1.45 ; margin : 0 0 .45 rem ; }
. parse-headings { display : flex ; flex-wrap : wrap ; gap : .35 rem ; margin-bottom : .4 rem ; }
. parse-chip { font-size : .68 rem ; padding : .12 rem .45 rem ; border-radius : 999 px ; background : rgba ( 0 , 229 , 255 , .08 ); color : #7dd3fc ; border : 1 px solid rgba ( 0 , 229 , 255 , .2 ); }
. parse-links { margin : .25 rem 0 0 ; padding-left : 1 rem ; font-size : .78 rem ; color : #cbd5e1 ; }
. parse-links a { color : #86efac ; }
. intel-kpi-grid { display : grid ; grid-template-columns : repeat ( 5 , minmax ( 0 , 1 fr )); gap : .65 rem ; margin-bottom : 1 rem ; }
@ media ( max-width : 900px ) { . intel-kpi-grid { grid-template-columns : repeat ( 2 , 1 fr ); } }
. intel-kpi { padding : .75 rem ; border-radius : 10 px ; border : 1 px solid rgba ( 148 , 163 , 184 , .14 ); background : rgba ( 7 , 11 , 18 , .65 ); }
. intel-kpi . lbl { font-size : .68 rem ; color : #64748b ; text-transform : uppercase ; letter-spacing : .04 em ; }
. intel-kpi . val { font-size : 1.35 rem ; font-weight : 700 ; color : #f1f5f9 ; margin-top : .15 rem ; }
. intel-toolbar { display : flex ; flex-wrap : wrap ; gap : .5 rem ; align-items : center ; margin-bottom : 1 rem ; }
. intel-hype-cloud { display : flex ; flex-wrap : wrap ; gap : .45 rem ; margin : .5 rem 0 1 rem ; }
. hype-chip { font-size : .78 rem ; padding : .25 rem .6 rem ; border-radius : 999 px ; border : 1 px 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 12 px rgba ( 255 , 215 , 0 , .12 ); }
. hype-chip . score { opacity : .65 ; font-size : .65 rem ; margin-left : .25 rem ; }
. intel-page-card { border : 1 px solid rgba ( 148 , 163 , 184 , .16 ); border-radius : 12 px ; margin-bottom : .85 rem ; background : rgba ( 6 , 10 , 16 , .85 ); overflow : hidden ; }
. intel-page-head { display : flex ; flex-wrap : wrap ; gap : .5 rem ; align-items : flex-start ; justify-content : space-between ; padding : .85 rem 1 rem ; border-bottom : 1 px solid rgba ( 148 , 163 , 184 , .1 ); cursor : pointer ; }
. intel-page-head : hover { background : rgba ( 0 , 229 , 255 , .04 ); }
. intel-page-body { padding : 0 1 rem 1 rem ; }
. intel-signals { display : flex ; flex-wrap : wrap ; gap : .35 rem ; margin : .5 rem 0 ; }
. signal-chip { font-size : .68 rem ; padding : .12 rem .45 rem ; border-radius : 999 px ; background : rgba ( 34 , 197 , 94 , .12 ); color : #86efac ; border : 1 px 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 : 320 px ; overflow : auto ; white-space : pre-wrap ; font-size : .8 rem ; line-height : 1.55 ; color : #cbd5e1 ; background : rgba ( 0 , 0 , 0 , .35 ); padding : .85 rem ; border-radius : 8 px ; border : 1 px solid rgba ( 148 , 163 , 184 , .1 ); }
. intel-read-text . expanded { max-height : 560 px ; }
. intel-section-title { font-size : .72 rem ; text-transform : uppercase ; letter-spacing : .05 em ; color : #64748b ; margin : 1 rem 0 .4 rem ; font-weight : 700 ; }
. intel-trend-table { width : 100 % ; font-size : .82 rem ; }
. intel-trend-table td , . intel-trend-table th { padding : .35 rem .5 rem ; border-bottom : 1 px solid rgba ( 148 , 163 , 184 , .08 ); text-align : left ; }
. hype-score { font-size : .75 rem ; color : #f59e0b ; font-weight : 700 ; }
2026-06-09 00:41:27 +00:00
. browser-layout { display : grid ; grid-template-columns : 1 fr 280 px ; gap : 1 rem ; }
@ media ( max-width : 1100px ) { . browser-layout { grid-template-columns : 1 fr ; } }
. browser-live-row {
display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 1 rem ; margin-bottom : 1 rem ;
}
@ media ( max-width : 900px ) { . browser-live-row { grid-template-columns : 1 fr ; } }
. browser-live-panel { min-height : 380 px ; }
. browser-live-frame {
width : 100 % ; min-height : 420 px ; background : #0a0f1a ; border-radius : 10 px ;
border : 1 px solid rgba ( 148 , 163 , 184 , .15 ); object-fit : contain ;
}
. browser-novnc { width : 100 % ; height : 360 px ; border : 0 ; border-radius : 10 px ; background : #000 ; }
. browser-preview-text {
max-height : 220 px ; overflow : auto ; font-size : 0.82 rem ; color : var ( -- text - muted );
white-space : pre-wrap ; background : rgba ( 0 , 0 , 0 , .25 ); padding : .75 rem ; border-radius : 8 px ;
}
. browser-links { max-height : 160 px ; overflow : auto ; font-size : 0.78 rem ; }
. browser-status { font-size : 0.8 rem ; color : var ( -- accent - cyan ); margin-top : 0.35 rem ; }
. novnc-hint { font-size : 0.78 rem ; color : var ( -- text - muted ); margin-top : 0.5 rem ; }
. preset-grid { display : flex ; flex-wrap : wrap ; gap : 0.4 rem ; margin : 0.5 rem 0 ; }
. preset-chip { font-size : 0.78 rem ; padding : 0.25 rem 0.55 rem ; border-radius : 999 px ; cursor : pointer ; border : 1 px 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.5 rem ;
padding : 0.6 rem 0.85 rem ; margin-bottom : 1 rem ; border-radius : 10 px ;
background : rgba ( 0 , 0 , 0 , .25 ); border : 1 px 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 >
2026-06-23 10:04:23 +00:00
< button type = "button" class = "browser-tab" :class = "{ active: tab === 'analyse' }" @ click = "setTab('analyse')" > Parse Analyse</ button >
2026-06-09 00:41:27 +00:00
</ div >
2026-06-23 10:04:23 +00:00
< div class = "browser-live-row" style = "margin-bottom:1rem" x-show = "tab === 'research'" >
2026-06-09 00:41:27 +00:00
< 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 >
2026-06-23 10:04:23 +00:00
< button type = "button" class = "btn btn-secondary" @ click = "crawlAll()" :disabled = "crawlBusy" x-text = "crawlBusy ? 'Crawlen…' : 'Alles crawlen'" ></ button >
2026-06-09 00:41:27 +00:00
</ 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" >
2026-06-23 10:04:23 +00:00
< button class = "btn btn-sm" @ click = "crawlOne({{ s.id }})" :disabled = "crawlBusy" > Crawl</ button >
2026-06-09 00:41:27 +00:00
< 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 >
2026-06-23 10:04:23 +00:00
< 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 >
2026-06-09 00:41:27 +00:00
</ section >
</ div >
2026-06-23 10:04:23 +00:00
< 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 >
2026-06-09 00:41:27 +00:00
</ div >
</ div >
{% endblock %}
{% block scripts %}
2026-06-23 10:04:23 +00:00
< script src = "/static/js/parse-intel.js?v=1" ></ script >
2026-06-09 00:41:27 +00:00
< script >
function browserPage () {
return {
2026-06-23 10:04:23 +00:00
tab : ( function () {
const h = window . location . hash ;
if ( h === '#monitor' ) return 'monitor' ;
if ( h === '#analyse' ) return 'analyse' ;
return 'research' ;
})(),
2026-06-09 00:41:27 +00:00
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 : '' },
2026-06-23 10:04:23 +00:00
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 ); });
},
2026-06-09 00:41:27 +00:00
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 ;
2026-06-23 10:04:23 +00:00
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 ();
2026-06-09 00:41:27 +00:00
},
init () {
if ( window . location . hash === '#monitor' ) this . tab = 'monitor' ;
2026-06-23 10:04:23 +00:00
if ( window . location . hash === '#analyse' ) this . tab = 'analyse' ;
this . $watch ( 'tab' , ( t ) => {
if ( t === 'monitor' ) this . loadParseResults ();
if ( t === 'analyse' ) this . loadIntelligence ();
});
2026-06-09 00:41:27 +00:00
window . addEventListener ( 'hashchange' , () => {
2026-06-23 10:04:23 +00:00
const h = window . location . hash ;
this . tab = h === '#monitor' ? 'monitor' : ( h === '#analyse' ? 'analyse' : 'research' );
2026-06-09 00:41:27 +00:00
});
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 ) }),
});
2026-06-23 10:04:23 +00:00
Cockpit . toast ( 'Site toegevoegd — live in Agents → Terminals (Browser)' , 'success' );
2026-06-09 00:41:27 +00:00
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 ) });
2026-06-23 10:04:23 +00:00
Cockpit . toast ( 'Site toegevoegd — live in Agents → Terminals (Browser)' , 'success' );
2026-06-09 00:41:27 +00:00
this . reloadPage ();
} catch ( e ) { Cockpit . toast ( e . message , 'error' ); }
},
2026-06-23 10:04:23 +00:00
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' );
},
2026-06-09 00:41:27 +00:00
async crawlAll () {
2026-06-23 10:04:23 +00:00
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 ; }
2026-06-09 00:41:27 +00:00
},
async crawlOne ( id ) {
2026-06-23 10:04:23 +00:00
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 ; }
2026-06-09 00:41:27 +00:00
},
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 %}