3516 lines
176 KiB
HTML
3516 lines
176 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Ghost Node — Auction Sniper</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@300;400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet" />
|
||
|
||
<style>
|
||
/* ─── Reset ────────────────────────────────────────────────── */
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
/* ─── Design Tokens ─────────────────────────────────────────── */
|
||
:root {
|
||
--bg-void: #030509;
|
||
--bg-panel: #080d14;
|
||
--bg-card: #0b1220;
|
||
--bg-input: #0d1525;
|
||
--border-dim: #1a2a3a;
|
||
--border-glow: #0ff;
|
||
--accent: #00f5ff;
|
||
--accent-hot: #ff3e6c;
|
||
--accent-gold: #f0c040;
|
||
--accent-green: #00ff88;
|
||
--text-bright: #e8f4ff;
|
||
--text-mid: #7a9bb5;
|
||
--text-dim: #354a5e;
|
||
--font-mono: 'Share Tech Mono', monospace;
|
||
--font-ui: 'Rajdhani', sans-serif;
|
||
--font-display: 'Orbitron', monospace;
|
||
--glow-cyan: 0 0 8px #00f5ff66, 0 0 24px #00f5ff22;
|
||
--glow-hot: 0 0 8px #ff3e6c66, 0 0 24px #ff3e6c22;
|
||
--glow-green: 0 0 8px #00ff8866, 0 0 24px #00ff8822;
|
||
--r: 4px;
|
||
}
|
||
|
||
/* ─── Scrollbar ─────────────────────────────────────────────── */
|
||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||
::-webkit-scrollbar-track { background: var(--bg-void); }
|
||
::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
|
||
|
||
/* ─── Body ──────────────────────────────────────────────────── */
|
||
body {
|
||
background: var(--bg-void);
|
||
color: var(--text-bright);
|
||
font-family: var(--font-ui);
|
||
font-size: 14px;
|
||
min-height: 100vh;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* CRT scan-line overlay */
|
||
body::after {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
background: repeating-linear-gradient(
|
||
0deg,
|
||
transparent,
|
||
transparent 2px,
|
||
rgba(0,0,0,.08) 2px,
|
||
rgba(0,0,0,.08) 4px
|
||
);
|
||
pointer-events: none;
|
||
z-index: 9999;
|
||
}
|
||
|
||
/* ─── Header ────────────────────────────────────────────────── */
|
||
header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 28px;
|
||
border-bottom: 1px solid var(--border-dim);
|
||
background: linear-gradient(90deg, #030509 0%, #060e18 50%, #030509 100%);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
|
||
.logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.logo-icon {
|
||
width: 38px; height: 38px;
|
||
border: 2px solid var(--accent);
|
||
border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
box-shadow: var(--glow-cyan);
|
||
animation: pulse 3s ease-in-out infinite;
|
||
font-size: 18px;
|
||
}
|
||
@keyframes pulse {
|
||
0%,100% { box-shadow: var(--glow-cyan); }
|
||
50% { box-shadow: 0 0 18px #00f5ffaa, 0 0 40px #00f5ff44; }
|
||
}
|
||
.logo-text {
|
||
font-family: var(--font-display);
|
||
font-size: 18px;
|
||
font-weight: 900;
|
||
letter-spacing: 3px;
|
||
color: var(--accent);
|
||
text-shadow: var(--glow-cyan);
|
||
}
|
||
.logo-sub {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
letter-spacing: 2px;
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.header-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
.status-pill {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-mid);
|
||
letter-spacing: 1px;
|
||
}
|
||
.status-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--accent-green);
|
||
box-shadow: var(--glow-green);
|
||
animation: blink 1.2s step-start infinite;
|
||
}
|
||
@keyframes blink { 50% { opacity: .2; } }
|
||
|
||
.btn-control {
|
||
padding: 6px 16px;
|
||
font-family: var(--font-display);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 2px;
|
||
border: 1px solid;
|
||
border-radius: var(--r);
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
background: transparent;
|
||
}
|
||
.btn-pause { border-color: var(--accent-hot); color: var(--accent-hot); }
|
||
.btn-pause:hover { background: var(--accent-hot); color: #fff; box-shadow: var(--glow-hot); }
|
||
.btn-resume { border-color: var(--accent-green); color: var(--accent-green); }
|
||
.btn-resume:hover { background: var(--accent-green); color: #000; box-shadow: var(--glow-green); }
|
||
.btn-restart { border-color: var(--accent-gold); color: var(--accent-gold); }
|
||
.btn-restart:hover { background: var(--accent-gold); color: #000; box-shadow: 0 0 12px rgba(255,200,0,.4); }
|
||
.btn-restart.restarting { opacity:.5; pointer-events:none; }
|
||
.btn-kill { border-color: #ff2222; color: #ff2222; }
|
||
.btn-kill:hover { background: #ff2222; color: #fff; box-shadow: 0 0 14px rgba(255,34,34,.55); }
|
||
.btn-kill:disabled { opacity:.45; pointer-events:none; }
|
||
|
||
/* ─── Layout ─────────────────────────────────────────────────── */
|
||
.shell {
|
||
display: flex;
|
||
min-height: calc(100vh - 65px);
|
||
}
|
||
|
||
/* ─── Sidebar Nav ────────────────────────────────────────────── */
|
||
nav {
|
||
width: 200px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid var(--border-dim);
|
||
padding: 24px 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 20px;
|
||
cursor: pointer;
|
||
font-family: var(--font-ui);
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
letter-spacing: 1.5px;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
border-left: 3px solid transparent;
|
||
transition: all .15s;
|
||
user-select: none;
|
||
}
|
||
.nav-item:hover { color: var(--text-mid); background: rgba(0,245,255,.04); }
|
||
.nav-item.active {
|
||
color: var(--accent);
|
||
border-left-color: var(--accent);
|
||
background: rgba(0,245,255,.07);
|
||
text-shadow: var(--glow-cyan);
|
||
}
|
||
.nav-item .nav-icon { font-size: 16px; width: 18px; text-align: center; }
|
||
|
||
.nav-divider {
|
||
height: 1px;
|
||
background: var(--border-dim);
|
||
margin: 10px 20px;
|
||
}
|
||
|
||
/* ─── Main Content ───────────────────────────────────────────── */
|
||
main {
|
||
flex: 1;
|
||
padding: 28px;
|
||
overflow-y: auto;
|
||
animation: fadeIn .25s ease;
|
||
}
|
||
@keyframes fadeIn { from { opacity:0; transform: translateY(6px); } }
|
||
|
||
.tab-panel { display: none; }
|
||
.tab-panel.active { display: block; }
|
||
|
||
/* ─── Page Heading ───────────────────────────────────────────── */
|
||
.page-header {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
margin-bottom: 24px;
|
||
}
|
||
.page-title {
|
||
font-family: var(--font-display);
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
letter-spacing: 3px;
|
||
color: var(--accent);
|
||
text-shadow: var(--glow-cyan);
|
||
}
|
||
.page-sub {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
margin-top: 4px;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
/* ─── Stat Cards ─────────────────────────────────────────────── */
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 28px;
|
||
}
|
||
.stat-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-dim);
|
||
border-radius: var(--r);
|
||
padding: 18px 20px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: border-color .2s;
|
||
}
|
||
.stat-card:hover { border-color: var(--accent); box-shadow: var(--glow-cyan); }
|
||
.stat-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 100%; height: 2px;
|
||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||
opacity: .6;
|
||
}
|
||
.stat-label {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 2px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
margin-bottom: 8px;
|
||
}
|
||
.stat-value {
|
||
font-family: var(--font-display);
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
text-shadow: var(--glow-cyan);
|
||
line-height: 1;
|
||
}
|
||
.stat-unit {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--text-mid);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* ─── Activity Log ───────────────────────────────────────────── */
|
||
.log-box {
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--border-dim);
|
||
border-radius: var(--r);
|
||
padding: 16px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-mid);
|
||
height: 200px;
|
||
overflow-y: auto;
|
||
line-height: 1.7;
|
||
}
|
||
.log-line { display: flex; gap: 10px; }
|
||
.log-time { color: var(--text-dim); flex-shrink: 0; }
|
||
.log-msg-ok { color: var(--accent-green); }
|
||
.log-msg-warn { color: var(--accent-gold); }
|
||
.log-msg-hit { color: var(--accent); }
|
||
|
||
/* ─── Table ──────────────────────────────────────────────────── */
|
||
.table-wrap {
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--border-dim);
|
||
border-radius: var(--r);
|
||
overflow: hidden;
|
||
}
|
||
.table-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border-dim);
|
||
background: var(--bg-card);
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.toolbar-title {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-mid);
|
||
letter-spacing: 1px;
|
||
}
|
||
.toolbar-actions { display: flex; gap: 8px; align-items: center; }
|
||
.search-input {
|
||
background: var(--bg-input);
|
||
border: 1px solid var(--border-dim);
|
||
border-radius: var(--r);
|
||
padding: 6px 10px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-bright);
|
||
width: 200px;
|
||
outline: none;
|
||
transition: border-color .2s;
|
||
}
|
||
.search-input:focus { border-color: var(--accent); box-shadow: var(--glow-cyan); }
|
||
.search-input::placeholder { color: var(--text-dim); }
|
||
|
||
table { width: 100%; border-collapse: collapse; }
|
||
thead tr { background: var(--bg-card); }
|
||
th {
|
||
padding: 10px 14px;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 1.5px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border-dim);
|
||
}
|
||
th.sortable {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
}
|
||
th.sortable:hover { color: var(--accent); }
|
||
th.sort-asc { color: var(--accent) !important; }
|
||
th.sort-asc::after { content: " ▲"; font-size: 9px; }
|
||
th.sort-desc { color: var(--accent-gold) !important; }
|
||
th.sort-desc::after { content: " ▼"; font-size: 9px; }
|
||
td {
|
||
padding: 10px 14px;
|
||
font-family: var(--font-ui);
|
||
font-size: 13px;
|
||
border-bottom: 1px solid #0d1825;
|
||
vertical-align: middle;
|
||
max-width: 320px;
|
||
}
|
||
tr:last-child td { border-bottom: none; }
|
||
tr:hover td { background: rgba(0,245,255,.03); }
|
||
|
||
.score-badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 3px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
.score-pos { background: rgba(0,255,136,.12); color: var(--accent-green); border: 1px solid #00ff8840; }
|
||
.score-zero { background: rgba(255,200,60,.1); color: var(--accent-gold); border: 1px solid #f0c04040; }
|
||
|
||
.listing-title {
|
||
color: var(--text-bright);
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 300px;
|
||
display: block;
|
||
}
|
||
.listing-link {
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
opacity: .7;
|
||
transition: opacity .15s;
|
||
}
|
||
.listing-link:hover { opacity: 1; text-shadow: var(--glow-cyan); }
|
||
|
||
.price-tag {
|
||
font-family: var(--font-mono);
|
||
color: var(--accent-gold);
|
||
font-size: 12px;
|
||
text-shadow: 0 0 6px #f0c04066;
|
||
}
|
||
/* Pulsing glow for lots closing within 5 minutes */
|
||
@keyframes tlPulse {
|
||
0%,100% { text-shadow: 0 0 4px #ff444466; }
|
||
50% { text-shadow: 0 0 14px #ff4444cc, 0 0 4px #ff4444; }
|
||
}
|
||
.tl-pulse {
|
||
animation: tlPulse 1s ease-in-out infinite;
|
||
}
|
||
.humanize-btn {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
letter-spacing: 1px;
|
||
padding: 8px 4px;
|
||
border: 1px solid var(--border-dim);
|
||
background: var(--bg-card);
|
||
color: var(--text-mid);
|
||
transition: all .15s;
|
||
}
|
||
.humanize-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.humanize-btn.active-raw { border-color: #ff4444; color: #ff4444; background: rgba(255,68,68,.08); }
|
||
.humanize-btn.active-low { border-color: var(--accent-gold); color: var(--accent-gold); background: rgba(240,192,64,.08); }
|
||
.humanize-btn.active-medium { border-color: var(--accent); color: var(--accent); background: rgba(0,245,255,.08); }
|
||
.humanize-btn.active-heavy { border-color: var(--accent-green); color: var(--accent-green); background: rgba(0,255,136,.08); }
|
||
.channel-btn {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
letter-spacing: 1px;
|
||
padding: 8px 4px;
|
||
border: 1px solid var(--border-dim);
|
||
background: var(--bg-card);
|
||
color: var(--text-mid);
|
||
transition: all .15s;
|
||
}
|
||
.channel-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.channel-btn.active { border-color: var(--accent-green); color: var(--accent-green); background: rgba(0,255,136,.12); }
|
||
.site-health-ok { color: var(--accent-green); font-family: var(--font-mono); font-size: 10px; }
|
||
.site-health-warn { color: #ff9900; font-family: var(--font-mono); font-size: 10px; }
|
||
.site-health-err { color: #ff4444; font-family: var(--font-mono); font-size: 10px; }
|
||
.site-cooldown { color: #ff4444; font-family: var(--font-mono); font-size: 10px; font-weight: bold; }
|
||
|
||
/* ─── AI Debug Log ────────────────────────────────────────────── */
|
||
.aidl-card { border-radius: 6px; border: 1px solid; padding: 0; overflow: hidden; font-family: var(--font-mono); font-size: 11px; }
|
||
.aidl-card.request { border-color: rgba(0,245,255,.25); background: rgba(0,245,255,.04); }
|
||
.aidl-card.response { border-color: rgba(0,255,136,.25); background: rgba(0,255,136,.04); }
|
||
.aidl-card.response.verdict-yes { border-color: rgba(0,255,136,.5); background: rgba(0,255,136,.07); }
|
||
.aidl-card.response.verdict-no { border-color: rgba(255,68,68,.35); background: rgba(255,68,68,.05); }
|
||
.aidl-card.error { border-color: rgba(255,153,0,.35); background: rgba(255,153,0,.05); }
|
||
.aidl-header { display:flex; align-items:center; gap:8px; padding: 7px 12px; border-bottom: 1px solid rgba(255,255,255,.06); flex-wrap:wrap; }
|
||
.aidl-badge { padding: 2px 7px; border-radius: 3px; font-size: 9px; font-weight: 700; letter-spacing: 1px; }
|
||
.aidl-badge.req { background: rgba(0,245,255,.18); color: var(--accent); }
|
||
.aidl-badge.resp { background: rgba(0,255,136,.18); color: var(--accent-green); }
|
||
.aidl-badge.err { background: rgba(255,153,0,.2); color: #ff9900; }
|
||
.aidl-badge.filter { background: rgba(180,0,255,.18); color: #cc88ff; }
|
||
.aidl-badge.adapt { background: rgba(255,200,0,.15); color: var(--accent-gold); }
|
||
.aidl-body { padding: 10px 12px; white-space: pre-wrap; word-break: break-word; color: var(--text-mid); font-size: 11px; line-height: 1.65; max-height: 320px; overflow-y: auto; }
|
||
.aidl-body.collapsed { max-height: 60px; overflow: hidden; position: relative; }
|
||
.aidl-body.collapsed::after { content:''; position:absolute; bottom:0; left:0; right:0; height:24px; background:linear-gradient(transparent, var(--bg-card)); }
|
||
.aidl-expand { background:none; border:none; color:var(--accent); cursor:pointer; font-family:var(--font-mono); font-size:10px; padding:4px 12px 6px; text-align:left; width:100%; }
|
||
.aidebug-filter.active { border-color: var(--accent); color: var(--accent); background: rgba(0,245,255,.1); }
|
||
|
||
/* ─── Buttons ────────────────────────────────────────────────── */
|
||
.btn {
|
||
padding: 7px 16px;
|
||
font-family: var(--font-ui);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
border-radius: var(--r);
|
||
cursor: pointer;
|
||
border: 1px solid;
|
||
transition: all .15s;
|
||
text-transform: uppercase;
|
||
}
|
||
.btn-primary {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #000;
|
||
}
|
||
.btn-primary:hover { box-shadow: var(--glow-cyan); transform: translateY(-1px); }
|
||
.btn-danger {
|
||
background: transparent;
|
||
border-color: var(--accent-hot);
|
||
color: var(--accent-hot);
|
||
}
|
||
.btn-danger:hover { background: var(--accent-hot); color: #fff; box-shadow: var(--glow-hot); }
|
||
.btn-ghost {
|
||
background: transparent;
|
||
border-color: var(--border-dim);
|
||
color: var(--text-mid);
|
||
}
|
||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||
.btn-sm { padding: 4px 10px; font-size: 10px; }
|
||
|
||
/* ─── Forms ──────────────────────────────────────────────────── */
|
||
.form-section {
|
||
background: var(--bg-panel);
|
||
border: 1px solid var(--border-dim);
|
||
border-radius: var(--r);
|
||
padding: 20px 24px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.form-section-title {
|
||
font-family: var(--font-display);
|
||
font-size: 12px;
|
||
letter-spacing: 2.5px;
|
||
color: var(--accent);
|
||
text-transform: uppercase;
|
||
margin-bottom: 16px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid var(--border-dim);
|
||
}
|
||
.form-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
margin-bottom: 14px;
|
||
}
|
||
.form-group { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 160px; }
|
||
.form-group.fixed { flex: 0 0 auto; }
|
||
label {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
letter-spacing: 1.5px;
|
||
text-transform: uppercase;
|
||
}
|
||
input, textarea, select {
|
||
background: var(--bg-input);
|
||
border: 1px solid var(--border-dim);
|
||
border-radius: var(--r);
|
||
padding: 9px 12px;
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: var(--text-bright);
|
||
outline: none;
|
||
transition: border-color .2s;
|
||
width: 100%;
|
||
}
|
||
input:focus, textarea:focus { border-color: var(--accent); box-shadow: var(--glow-cyan); }
|
||
input::placeholder { color: var(--text-dim); }
|
||
textarea { resize: vertical; min-height: 60px; }
|
||
|
||
/* ─── Alert Banner ───────────────────────────────────────────── */
|
||
.alert {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
border-radius: var(--r);
|
||
margin-bottom: 16px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
animation: slideIn .2s ease;
|
||
}
|
||
@keyframes slideIn { from { opacity:0; transform: translateX(-10px); } }
|
||
.alert-success { background: rgba(0,255,136,.1); border: 1px solid var(--accent-green); color: var(--accent-green); }
|
||
.alert-error { background: rgba(255,62,108,.1); border: 1px solid var(--accent-hot); color: var(--accent-hot); }
|
||
|
||
#alert-zone { min-height: 0; }
|
||
|
||
/* ─── Tag chips ──────────────────────────────────────────────── */
|
||
.chip {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 3px 9px;
|
||
background: rgba(0,245,255,.08);
|
||
border: 1px solid rgba(0,245,255,.2);
|
||
border-radius: 20px;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.drag-handle { cursor: grab; color: var(--text-dim); font-size: 14px; padding: 0 6px; user-select: none; }
|
||
.drag-handle:active { cursor: grabbing; }
|
||
tr.drag-over { outline: 2px solid var(--accent); }
|
||
|
||
.currency-opt { padding: 6px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--text-hi); cursor: pointer; }
|
||
.currency-opt:hover { background: rgba(0,245,255,.1); color: var(--accent); }
|
||
|
||
/* ─── Empty state ────────────────────────────────────────────── */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 48px;
|
||
color: var(--text-dim);
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
letter-spacing: 1px;
|
||
}
|
||
.empty-state .big-icon { font-size: 40px; margin-bottom: 12px; }
|
||
|
||
/* ─── Grid 2-col ─────────────────────────────────────────────── */
|
||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||
@media(max-width:900px){ .grid-2 { grid-template-columns: 1fr; } }
|
||
|
||
/* ─── Tooltip ────────────────────────────────────────────────── */
|
||
[data-tip] { position: relative; cursor: help; }
|
||
[data-tip]:hover::after {
|
||
content: attr(data-tip);
|
||
position: absolute;
|
||
bottom: 120%; left: 50%;
|
||
transform: translateX(-50%);
|
||
white-space: nowrap;
|
||
background: #0d1525;
|
||
border: 1px solid var(--border-dim);
|
||
color: var(--text-mid);
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
padding: 4px 8px;
|
||
border-radius: 3px;
|
||
pointer-events: none;
|
||
z-index: 200;
|
||
}
|
||
|
||
/* ─── Loader spinner ─────────────────────────────────────────── */
|
||
.spinner {
|
||
width: 16px; height: 16px;
|
||
border: 2px solid var(--border-dim);
|
||
border-top-color: var(--accent);
|
||
border-radius: 50%;
|
||
animation: spin .7s linear infinite;
|
||
display: inline-block;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ── Browser selector cards ─────────────────────────── */
|
||
.browser-card {
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(255,255,255,.08);
|
||
background: rgba(255,255,255,.03);
|
||
transition: border-color .15s, background .15s;
|
||
}
|
||
.browser-card:hover { border-color: rgba(0,245,255,.35); background: rgba(0,245,255,.05); }
|
||
.browser-card.selected {
|
||
border-color: var(--accent);
|
||
background: rgba(0,245,255,.1);
|
||
box-shadow: 0 0 10px rgba(0,245,255,.15);
|
||
}
|
||
.browser-card-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 12px 8px;
|
||
gap: 4px;
|
||
}
|
||
.browser-icon { font-size: 22px; line-height: 1; }
|
||
.browser-name { font-family: var(--font-mono); font-size: 11px; font-weight: 700;
|
||
color: var(--text-hi); letter-spacing: .5px; }
|
||
.browser-desc { font-family: var(--font-mono); font-size: 9px;
|
||
color: var(--text-dim); text-align: center; }
|
||
|
||
|
||
/* ── Incognito toggle switch ─────────────────────────── */
|
||
.toggle-wrap { display:inline-flex; align-items:center; cursor:pointer; }
|
||
.toggle-input { display:none; }
|
||
.toggle-track {
|
||
width:48px; height:26px; border-radius:13px;
|
||
background:rgba(255,255,255,.08);
|
||
border:1px solid rgba(255,255,255,.12);
|
||
position:relative; transition:background .2s, border-color .2s;
|
||
}
|
||
.toggle-input:checked ~ .toggle-track {
|
||
background:rgba(0,245,255,.25);
|
||
border-color:var(--accent);
|
||
box-shadow:0 0 8px rgba(0,245,255,.3);
|
||
}
|
||
.toggle-thumb {
|
||
width:20px; height:20px; border-radius:50%;
|
||
background:var(--text-mid);
|
||
position:absolute; top:2px; left:3px;
|
||
transition:left .2s, background .2s;
|
||
}
|
||
.toggle-input:checked ~ .toggle-track .toggle-thumb {
|
||
left:23px; background:var(--accent);
|
||
}
|
||
|
||
|
||
/* ── Delay config cards ─────────────────────────────── */
|
||
.delay-hint {
|
||
font-family: var(--font-mono); font-size: 10px;
|
||
color: var(--text-dim); margin-top: 4px; line-height: 1.6;
|
||
}
|
||
.delay-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
}
|
||
.delay-card-wide { grid-column: 1 / -1; }
|
||
.delay-card {
|
||
display: flex; gap: 10px; align-items: flex-start;
|
||
background: rgba(255,255,255,.03);
|
||
border: 1px solid rgba(255,255,255,.07);
|
||
border-radius: 6px; padding: 12px;
|
||
transition: border-color .15s;
|
||
}
|
||
.delay-card:hover { border-color: rgba(0,245,255,.25); }
|
||
.delay-card-num {
|
||
font-family: var(--font-mono); font-size: 18px;
|
||
color: var(--accent); min-width: 24px; padding-top: 2px;
|
||
text-shadow: var(--glow-cyan);
|
||
}
|
||
.delay-card-body { flex: 1; }
|
||
.delay-label {
|
||
display: flex; align-items: baseline; gap: 6px;
|
||
font-family: var(--font-mono); font-size: 11px;
|
||
font-weight: 700; color: var(--text-hi);
|
||
letter-spacing: .5px; margin-bottom: 6px;
|
||
}
|
||
.delay-unit {
|
||
font-size: 9px; color: var(--accent);
|
||
border: 1px solid var(--accent); border-radius: 3px;
|
||
padding: 1px 4px; letter-spacing: 1px;
|
||
}
|
||
.delay-card input[type=number] {
|
||
width: 100%; margin-bottom: 8px;
|
||
}
|
||
/* hide number spinners — only digits via oninput */
|
||
.delay-card input[type=number]::-webkit-inner-spin-button,
|
||
.delay-card input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; }
|
||
.delay-card input[type=number] { -moz-appearance: textfield; }
|
||
.delay-desc {
|
||
font-family: var(--font-mono); font-size: 9px;
|
||
color: var(--text-dim); line-height: 1.65;
|
||
}
|
||
.delay-desc b { color: var(--text-mid); }
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ─── Header ─────────────────────────────────────────────────────────────── -->
|
||
<header>
|
||
<div class="logo">
|
||
<div class="logo-icon">👻</div>
|
||
<div>
|
||
<div class="logo-text">GHOST NODE</div>
|
||
<div class="logo-sub">AUCTION SNIPER v1.0 — RTX 3050 NODE</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-status">
|
||
<div class="status-pill">
|
||
<div class="status-dot" id="engine-dot"></div>
|
||
<span id="engine-status-label">INITIALIZING…</span>
|
||
</div>
|
||
<button class="btn-control btn-pause" onclick="enginePause()">⏸ PAUSE</button>
|
||
<button class="btn-control btn-resume" onclick="engineResume()">▶ RESUME</button>
|
||
<button class="btn-control btn-restart" id="btn-restart" onclick="engineRestart()">🔄 RESTART</button>
|
||
<button class="btn-control btn-kill" id="btn-kill" onclick="engineKill()">☠ KILL</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ─── Shell ──────────────────────────────────────────────────────────────── -->
|
||
<div class="shell">
|
||
|
||
<!-- Sidebar -->
|
||
<nav>
|
||
<div class="nav-item active" data-tab="dashboard">
|
||
<span class="nav-icon">📡</span> Dashboard
|
||
</div>
|
||
<div class="nav-item" data-tab="listings">
|
||
<span class="nav-icon">🎯</span> Listings
|
||
</div>
|
||
<div class="nav-divider"></div>
|
||
<div class="nav-item" data-tab="keywords">
|
||
<span class="nav-icon">🔍</span> Keywords
|
||
</div>
|
||
<div class="nav-item" data-tab="sites">
|
||
<span class="nav-icon">🌐</span> Target Sites
|
||
</div>
|
||
<div class="nav-divider"></div>
|
||
<div class="nav-item" data-tab="settings">
|
||
<span class="nav-icon">⚙️</span> Settings
|
||
</div>
|
||
<div class="nav-divider"></div>
|
||
<div class="nav-item" data-tab="aidebug">
|
||
<span class="nav-icon">🧠</span> AI Log
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Main -->
|
||
<main>
|
||
<div id="alert-zone"></div>
|
||
|
||
<!-- ═══ TAB: Dashboard ═══════════════════════════════════════════════════ -->
|
||
<div class="tab-panel active" id="tab-dashboard">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">SYSTEM DASHBOARD</div>
|
||
<div class="page-sub">REAL-TIME NODE TELEMETRY</div>
|
||
</div>
|
||
<div class="status-pill">
|
||
<span id="last-cycle-label" style="font-family:var(--font-mono);font-size:11px;color:var(--text-dim)">
|
||
Last cycle: —
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stat-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-label">// TOTAL SCANNED</div>
|
||
<div class="stat-value" id="stat-scanned">0</div>
|
||
<div class="stat-unit">listings processed</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">// ALERTS FIRED</div>
|
||
<div class="stat-value" id="stat-alerts">0</div>
|
||
<div class="stat-unit">qualifying hits</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">// UPTIME</div>
|
||
<div class="stat-value" id="stat-uptime">0h</div>
|
||
<div class="stat-unit">continuous operation</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">// ENGINE</div>
|
||
<div class="stat-value" style="font-size:14px;padding-top:6px;" id="stat-engine">—</div>
|
||
<div class="stat-unit">current state</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Activity Log -->
|
||
<div class="form-section">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<div class="form-section-title" style="margin-bottom:0">// ACTIVITY LOG</div>
|
||
<div style="display:flex;gap:6px;align-items:center">
|
||
<input id="log-search" type="text" placeholder="🔍 filter log…" oninput="filterLog()" style="background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:4px 8px;font-family:var(--font-mono);font-size:11px;width:160px"/>
|
||
<button class="btn btn-ghost btn-sm" onclick="log_scrollBottom()" title="Scroll to bottom">↓</button>
|
||
<button class="btn btn-danger btn-sm" onclick="clearLog()" title="Clear log">🗑</button>
|
||
</div>
|
||
</div>
|
||
<div class="log-box" id="log-box">
|
||
<div class="log-line">
|
||
<span class="log-time">[BOOT]</span>
|
||
<span class="log-msg-ok">Ghost Node dashboard initialized.</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent hits mini table -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">// RECENT HITS (top 5)</div>
|
||
<div id="recent-hits-container">
|
||
<div class="empty-state"><div class="big-icon">🔍</div>No hits yet.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ TAB: Listings ════════════════════════════════════════════════════ -->
|
||
<div class="tab-panel" id="tab-listings">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">CAPTURED LISTINGS</div>
|
||
<div class="page-sub">ALL QUALIFYING AUCTION HITS</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-ghost" onclick="loadListings()">↺ Refresh</button>
|
||
<button class="btn btn-danger btn-sm" onclick="clearListings()">🗑 Clear All</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<div class="table-toolbar">
|
||
<span class="toolbar-title">LISTINGS TABLE</span>
|
||
<div class="toolbar-actions">
|
||
<input class="search-input" type="text" placeholder="filter titles…" id="listing-filter" oninput="filterListings()" />
|
||
</div>
|
||
</div>
|
||
<div id="listings-table-container">
|
||
<div class="empty-state"><div class="big-icon">👻</div>Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ TAB: Keywords ════════════════════════════════════════════════════ -->
|
||
<div class="tab-panel" id="tab-keywords">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">KEYWORD MATRIX</div>
|
||
<div class="page-sub">SEARCH TERMS & SCORING WEIGHTS</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<div class="form-section-title">// ADD KEYWORD</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Search Term</label>
|
||
<input type="text" id="kw-term" placeholder="e.g. RTX 4090" />
|
||
</div>
|
||
<div class="form-group fixed" style="max-width:100px">
|
||
<label>Weight <span data-tip="Score multiplier">ℹ</span></label>
|
||
<input type="number" id="kw-weight" value="1" min="1" max="10" />
|
||
</div>
|
||
<div class="form-group fixed" style="align-self:flex-end">
|
||
<button class="btn btn-primary" onclick="addKeyword()">+ ADD</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Batch import -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">// BATCH IMPORT KEYWORDS</div>
|
||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:8px">
|
||
One keyword per line. Optional weight: <b style="color:var(--accent)">laptop:3</b> or just <b style="color:var(--accent)">laptop</b> (defaults to weight 1)
|
||
</div>
|
||
<textarea id="kw-batch" rows="5" placeholder="RTX 4090:5 PS5:3 MacBook Pro Steam Deck:4" style="width:100%;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box;resize:vertical;margin-bottom:8px"></textarea>
|
||
<button class="btn btn-primary" onclick="batchImportKeywords()">⬇ Import All</button>
|
||
<span id="batch-result" style="font-family:var(--font-mono);font-size:11px;color:var(--accent-green);margin-left:12px"></span>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<div class="table-toolbar">
|
||
<span class="toolbar-title">ACTIVE KEYWORDS</span>
|
||
</div>
|
||
<div id="keywords-table-container">
|
||
<div class="empty-state"><div class="big-icon">🔍</div>Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ TAB: Target Sites ════════════════════════════════════════════════ -->
|
||
<div class="tab-panel" id="tab-sites">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">TARGET SITES</div>
|
||
<div class="page-sub">SITE URL TEMPLATES & SELECTORS</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<div class="form-section-title">// REGISTER NEW SITE</div>
|
||
|
||
<!-- Mode explanation banner -->
|
||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:220px;background:rgba(0,245,255,.05);border:1px solid rgba(0,245,255,.15);border-radius:4px;padding:10px 14px">
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:1.5px;margin-bottom:4px">MODE A — DIRECT URL</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-mid);line-height:1.6">
|
||
Include <code style="color:var(--accent-gold)">{keyword}</code> in the URL.<br/>
|
||
e.g. <code>ebay.co.uk/sch/?_nkw={keyword}</code><br/>
|
||
Scraper substitutes the term and navigates directly.
|
||
</div>
|
||
</div>
|
||
<div style="flex:1;min-width:220px;background:rgba(0,255,136,.05);border:1px solid rgba(0,255,136,.15);border-radius:4px;padding:10px 14px">
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent-green);letter-spacing:1.5px;margin-bottom:4px">MODE B — HOMEPAGE SEARCH</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-mid);line-height:1.6">
|
||
Paste the homepage URL as-is.<br/>
|
||
e.g. <code>shopgoodwill.com/home</code><br/>
|
||
Scraper auto-discovers the search box and types.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group fixed" style="max-width:180px">
|
||
<label>Site Name</label>
|
||
<input type="text" id="site-name" placeholder="e.g. ShopGoodwill" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>
|
||
URL
|
||
<span id="mode-badge" style="margin-left:8px;font-size:9px;padding:2px 7px;border-radius:3px;font-weight:700;letter-spacing:1px;display:none"></span>
|
||
</label>
|
||
<input type="text" id="site-url"
|
||
placeholder="homepage URL or https://site.com/search?q={keyword}"
|
||
oninput="updateModeBadge(this.value)" />
|
||
</div>
|
||
<div class="form-group fixed" style="max-width:180px">
|
||
<label>Search Selector <span data-tip="Optional CSS selector, e.g. input#st — leave blank to let Ghost Node auto-discover the search box">ℹ</span></label>
|
||
<input type="text" id="site-selector" placeholder="optional — e.g. input#st" />
|
||
</div>
|
||
<div class="form-group fixed" style="max-width:80px">
|
||
<label>Pages <span data-tip="How many result pages to scrape per keyword (1 = first page only)">ℹ</span></label>
|
||
<input type="number" id="site-max-pages" value="1" min="1" max="20" style="text-align:center" />
|
||
</div>
|
||
<div class="form-group fixed" style="align-self:flex-end">
|
||
<button class="btn btn-primary" onclick="addSite()">+ ADD SITE</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Login / session options (collapsed by default) -->
|
||
<details style="margin-top:12px;font-family:var(--font-mono)">
|
||
<summary style="cursor:pointer;font-size:10px;color:var(--text-dim);letter-spacing:1px;list-style:none">
|
||
▶ LOGIN / SESSION OPTIONS (for sites requiring an account)
|
||
</summary>
|
||
<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;background:rgba(0,0,0,.3);border-radius:4px;border:1px solid var(--border-dim)">
|
||
<div style="grid-column:1/-1;display:flex;align-items:center;gap:10px">
|
||
<label class="toggle-wrap" for="site-requires-login">
|
||
<input type="checkbox" id="site-requires-login" class="toggle-input"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<span style="font-size:11px;color:var(--text-hi)">This site requires login before scraping</span>
|
||
</div>
|
||
<div>
|
||
<label style="font-size:10px;color:var(--text-dim)">LOGIN PAGE URL</label>
|
||
<input type="text" id="site-login-url" placeholder="https://site.com/signin" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:7px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
<div>
|
||
<label style="font-size:10px;color:var(--text-dim)">LOGGED-IN SELECTOR <span data-tip="CSS selector of an element that only exists when logged in, e.g. #user-menu">ℹ</span></label>
|
||
<input type="text" id="site-login-check" placeholder="e.g. #user-menu or .logout-btn" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:7px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
<div style="grid-column:1/-1;font-size:10px;color:var(--text-dim);line-height:1.6">
|
||
After adding the site, use the <span style="color:var(--accent-gold)">🔑 Login</span> button in the table below.
|
||
Ghost Node will open a visible browser window — log in manually, close it, and your session is saved automatically for all future scrapes.
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<div class="table-toolbar">
|
||
<span class="toolbar-title">REGISTERED SITES</span>
|
||
</div>
|
||
<div id="sites-table-container">
|
||
<div class="empty-state"><div class="big-icon">🌐</div>Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ TAB: Settings ════════════════════════════════════════════════════ -->
|
||
<div class="tab-panel" id="tab-settings">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">SYSTEM SETTINGS</div>
|
||
<div class="page-sub">TELEGRAM C2 & SCRAPER CONFIGURATION</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="form-section">
|
||
<div class="form-section-title">// TELEGRAM C2 CONFIG</div>
|
||
<div class="form-group" style="margin-bottom:14px">
|
||
<label>Bot Token</label>
|
||
<input type="password" id="cfg-token" placeholder="123456:ABC-DEF1234…" />
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:14px">
|
||
<label>Chat ID</label>
|
||
<input type="text" id="cfg-chatid" placeholder="-100123456789" />
|
||
</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:14px;line-height:1.7">
|
||
Commands: /status · /pause · /resume · /listings
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<div class="form-section-title">// SCRAPER TIMING</div>
|
||
|
||
<div class="form-group" style="margin-bottom:14px">
|
||
<label>Cycle Timer (seconds) <span data-tip="Delay between full scrape cycles">ℹ</span></label>
|
||
<input type="number" id="cfg-timer" placeholder="120" min="30" max="3600"
|
||
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
|
||
<div class="delay-hint">Minimum: 30s · Recommended: 120–300s · Jitter ±5–15s between keywords</div>
|
||
</div>
|
||
|
||
<div class="delay-grid">
|
||
|
||
<div class="delay-card">
|
||
<div class="delay-card-num">①</div>
|
||
<div class="delay-card-body">
|
||
<label class="delay-label">
|
||
Post-Launch Delay <span class="delay-unit">sec</span>
|
||
</label>
|
||
<input type="number" id="cfg-delay-launch" placeholder="0" min="0" max="300"
|
||
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
|
||
<div class="delay-desc">
|
||
Wait <b>after the browser opens</b> but before the first page navigation.<br/>
|
||
Lets the browser fully initialise. Set 0 to skip.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="delay-card">
|
||
<div class="delay-card-num">②</div>
|
||
<div class="delay-card-body">
|
||
<label class="delay-label">
|
||
Website-Launch Delay <span class="delay-unit">sec</span>
|
||
</label>
|
||
<input type="number" id="cfg-delay-site-open" placeholder="0" min="0" max="300"
|
||
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
|
||
<div class="delay-desc">
|
||
Applied <b>once per site launch</b> — fires when the browser first opens a
|
||
target website, before any keyword is processed.<br/>
|
||
When the engine moves to the next site, it fires once again for that site.<br/>
|
||
<b>Does not repeat</b> between keywords on the same site. Set 0 to skip.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="delay-card">
|
||
<div class="delay-card-num">③</div>
|
||
<div class="delay-card-body">
|
||
<label class="delay-label">
|
||
Post-Search Delay <span class="delay-unit">sec</span>
|
||
</label>
|
||
<input type="number" id="cfg-delay-search" placeholder="0" min="0" max="300"
|
||
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
|
||
<div class="delay-desc">
|
||
Wait <b>after the results page loads</b> but before extracting listings.<br/>
|
||
Useful for lazy-loaded pages. Set 0 to scrape immediately.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="delay-card">
|
||
<div class="delay-card-num">④</div>
|
||
<div class="delay-card-body">
|
||
<label class="delay-label">
|
||
Page-Hold Timer <span class="delay-unit">sec</span>
|
||
</label>
|
||
<input type="number" id="cfg-delay-hold" placeholder="0" min="0" max="3600"
|
||
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
|
||
<div class="delay-desc">
|
||
<b>Hold the results page open</b> for this many seconds, re-scraping each time
|
||
a pass completes. New dynamic listings are captured. Duplicates suppressed.
|
||
Set 0 to scrape once and move on.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="delay-card delay-card-wide" style="border-left:2px solid rgba(0,245,255,.15);background:rgba(0,245,255,.02)">
|
||
<div class="delay-card-num" style="color:var(--text-dim)">ℹ</div>
|
||
<div class="delay-card-body">
|
||
<div class="delay-desc" style="color:var(--text-mid);font-size:10px;line-height:1.8">
|
||
<b style="color:var(--accent)">Page-Hold re-scrape behaviour:</b>
|
||
When ④ is active the scraper finishes each pass then <b>immediately</b> starts the
|
||
next — no idle gap. Passes repeat until the timer expires. Each listing link is
|
||
saved exactly once — clones across all passes are automatically suppressed.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// BROWSER ENGINE</div>
|
||
<div class="form-group" style="margin-bottom:10px">
|
||
<label>Scraper Browser <span data-tip="Ghost Node will search for this browser on your machine. Falls back to Playwright Chromium if not found.">ℹ</span></label>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:6px" id="browser-selector">
|
||
<label class="browser-card" data-value="auto">
|
||
<input type="radio" name="browser_choice" value="auto" style="display:none" />
|
||
<div class="browser-card-inner">
|
||
<span class="browser-icon">🔍</span>
|
||
<span class="browser-name">Auto-Detect</span>
|
||
<span class="browser-desc">Edge → Yandex → Chromium</span>
|
||
</div>
|
||
</label>
|
||
<label class="browser-card" data-value="chrome">
|
||
<input type="radio" name="browser_choice" value="chrome" style="display:none" />
|
||
<div class="browser-card-inner">
|
||
<span class="browser-icon">🟡</span>
|
||
<span class="browser-name">Google Chrome</span>
|
||
<span class="browser-desc">chrome / google-chrome</span>
|
||
</div>
|
||
</label>
|
||
<label class="browser-card" data-value="edge">
|
||
<input type="radio" name="browser_choice" value="edge" style="display:none" />
|
||
<div class="browser-card-inner">
|
||
<span class="browser-icon">🔵</span>
|
||
<span class="browser-name">Microsoft Edge</span>
|
||
<span class="browser-desc">msedge.exe</span>
|
||
</div>
|
||
</label>
|
||
<label class="browser-card" data-value="yandex">
|
||
<input type="radio" name="browser_choice" value="yandex" style="display:none" />
|
||
<div class="browser-card-inner">
|
||
<span class="browser-icon">🔴</span>
|
||
<span class="browser-name">Yandex Browser</span>
|
||
<span class="browser-desc">browser.exe / yandex-browser</span>
|
||
</div>
|
||
</label>
|
||
<label class="browser-card" data-value="brave">
|
||
<input type="radio" name="browser_choice" value="brave" style="display:none" />
|
||
<div class="browser-card-inner">
|
||
<span class="browser-icon">🦁</span>
|
||
<span class="browser-name">Brave</span>
|
||
<span class="browser-desc">brave.exe / brave-browser</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="browser-status" style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:8px;line-height:1.7">
|
||
Restart Ghost Node after changing the browser for it to take effect.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// INCOGNITO MODE</div>
|
||
<div style="display:flex;align-items:center;gap:16px;margin-top:8px">
|
||
<label class="toggle-wrap" for="cfg-incognito">
|
||
<input type="checkbox" id="cfg-incognito" class="toggle-input" />
|
||
<span class="toggle-track">
|
||
<span class="toggle-thumb"></span>
|
||
</span>
|
||
</label>
|
||
<div>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="incognito-label">OFF</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:3px">
|
||
When ON: each scrape session launches with <code>--incognito</code> — no cookies,<br/>
|
||
no cached sessions, no login state. Harder to track, slower to warm up.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// BROWSER VISIBILITY</div>
|
||
<div style="display:flex;align-items:center;gap:16px;margin-top:8px">
|
||
<label class="toggle-wrap" for="cfg-headless">
|
||
<input type="checkbox" id="cfg-headless" class="toggle-input" />
|
||
<span class="toggle-track">
|
||
<span class="toggle-thumb"></span>
|
||
</span>
|
||
</label>
|
||
<div>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="headless-label">HEADLESS — Browser hidden</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:3px">
|
||
Toggle ON to open a <b style="color:var(--accent-gold)">visible browser window</b> so you can watch<br/>
|
||
every page load, search and result in real time. Useful for debugging.<br/>
|
||
Takes effect on the next scrape cycle — no restart needed.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// HUMANIZE LEVEL</div>
|
||
<div style="margin-top:10px">
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px" id="humanize-btns">
|
||
<button class="btn humanize-btn" data-val="raw" onclick="selectHumanize('raw')" title="No simulation — fastest, zero protection">
|
||
⚡ RAW
|
||
</button>
|
||
<button class="btn humanize-btn" data-val="low" onclick="selectHumanize('low')" title="One quick mouse move + scroll">
|
||
🐇 LOW
|
||
</button>
|
||
<button class="btn humanize-btn" data-val="medium" onclick="selectHumanize('medium')" title="Mouse + scroll + idle pauses">
|
||
🧍 MEDIUM
|
||
</button>
|
||
<button class="btn humanize-btn" data-val="heavy" onclick="selectHumanize('heavy')" title="Full simulation — bezier mouse, typos, read pauses, homepage pre-visit">
|
||
🐢 HEAVY
|
||
</button>
|
||
</div>
|
||
<div style="margin-top:8px;font-family:var(--font-mono);font-size:10px;color:var(--text-dim)" id="humanize-desc">
|
||
Select a level above.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- N2: CAPTCHA Solver -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// CAPTCHA SOLVER</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SERVICE</label>
|
||
<select id="cfg-captcha-solver" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px">
|
||
<option value="none">None (disabled)</option>
|
||
<option value="2captcha">2captcha.com</option>
|
||
<option value="capsolver">CapSolver.com</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">API KEY</label>
|
||
<input id="cfg-captcha-key" type="password" placeholder="Paste your API key…" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
</div>
|
||
</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px">When enabled, Ghost Node will auto-solve CAPTCHAs via the chosen service. Get an API key at 2captcha.com or capsolver.com.</div>
|
||
</div>
|
||
|
||
<!-- N16: AI Filter -->
|
||
<div class="form-section" style="margin-top:20px;border-color:var(--accent-green)">
|
||
<div class="form-section-title" style="color:var(--accent-green)">// AI SMART FILTER <span style="color:var(--text-dim);font-size:10px;font-weight:400">— powered by Groq (free) or Ollama (local)</span></div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:12px;line-height:1.7">
|
||
When enabled, the AI reads each lot title and decides if it matches your natural-language target description per keyword.<br>
|
||
Eliminates accessories, cases, chargers, wrong generations, etc. that keywords alone miss.<br>
|
||
Set an <b style="color:var(--accent-green)">AI Target</b> on each keyword in the Keywords tab.
|
||
</div>
|
||
|
||
<!-- Master toggle -->
|
||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:14px">
|
||
<label class="toggle-wrap" for="cfg-ai-enabled">
|
||
<input type="checkbox" id="cfg-ai-enabled" class="toggle-input" onchange="document.getElementById('ai-enabled-label').textContent = this.checked ? 'ON' : 'OFF'"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent-green);font-weight:700" id="ai-enabled-label">OFF</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Only affects keywords that have an AI Target set</div>
|
||
</div>
|
||
|
||
<!-- Debug mode toggle -->
|
||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:14px;padding:8px 10px;background:rgba(255,165,0,.05);border:1px solid rgba(255,165,0,.15);border-radius:4px">
|
||
<label class="toggle-wrap" for="cfg-ai-debug">
|
||
<input type="checkbox" id="cfg-ai-debug" class="toggle-input" onchange="document.getElementById('ai-debug-label').textContent = this.checked ? 'ON' : 'OFF'"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:#ff9900;font-weight:700" id="ai-debug-label">OFF</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">
|
||
<b style="color:#ff9900">DEBUG MODE</b> — prints every prompt & raw response to the console. Turn on to inspect AI behaviour, turn off in production.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Provider + Model -->
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">PROVIDER</label>
|
||
<select id="cfg-ai-provider" onchange="onAiProviderChange()" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px">
|
||
<option value="groq">Groq (free cloud API — best quality)</option>
|
||
<option value="ollama">Ollama (local — unlimited, offline)</option>
|
||
<option value="none">None (disabled)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MODEL</label>
|
||
<input id="cfg-ai-model" type="text" placeholder="llama-3.3-70b-versatile" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Groq section -->
|
||
<div id="ai-groq-section">
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">GROQ API KEY <span style="color:var(--accent-gold)">(free — get one at console.groq.com → API Keys)</span></label>
|
||
<input id="cfg-ai-api-key" type="password" placeholder="gsk_…" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:4px">Free tier: ~1,300 requests/day. Ghost Node only calls AI on NEW, unique listings — so this is more than enough for normal use.</div>
|
||
</div>
|
||
|
||
<!-- Ollama section -->
|
||
<div id="ai-ollama-section" style="display:none">
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">OLLAMA BASE URL</label>
|
||
<input id="cfg-ai-base-url" type="text" placeholder="http://localhost:11434" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:4px">Install Ollama from <b style="color:var(--accent)">ollama.com/download/windows</b> then run: <code style="color:var(--accent-green)">ollama pull llama3.2:3b</code></div>
|
||
</div>
|
||
|
||
<!-- N17: Auto-Adapter toggle -->
|
||
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border-dim)">
|
||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--accent-gold);font-weight:700;margin-bottom:6px;letter-spacing:1px">AUTO-ADAPTER <span style="font-size:9px;color:var(--text-dim);font-weight:400">— N17</span></div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.7">
|
||
When enabled, Ghost Node uses the AI to auto-generate CSS selectors for each target site.<br>
|
||
It reads the page HTML, asks the AI to identify the listing container, title, price, time and link selectors,<br>
|
||
validates them live, then stores them for reuse. Works with any site — Angular, React, Vue, or plain HTML.<br>
|
||
Use the <b style="color:var(--accent-gold)">🤖 Adapt</b> button in the Sites tab to run immediately on any site.
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px">
|
||
<label class="toggle-wrap" for="cfg-auto-adapt-enabled">
|
||
<input type="checkbox" id="cfg-auto-adapt-enabled" class="toggle-input" onchange="document.getElementById('auto-adapt-label').textContent = this.checked ? 'ON' : 'OFF'"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent-gold);font-weight:700" id="auto-adapt-label">OFF</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Automatically re-adapts stale sites during scrape cycles</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- N9+: Closing Soon Alerts (multi-interval) -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// CLOSING SOON ALERTS</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px;line-height:1.7">
|
||
Only fires for lots that have a captured countdown timer. Each threshold fires once per lot.<br>
|
||
Set to <b style="color:var(--accent-gold)">0</b> or leave schedule empty to get <b>no</b> closing alerts (capture alert only).
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px">
|
||
<label class="toggle-wrap" for="cfg-closing-enabled">
|
||
<input type="checkbox" id="cfg-closing-enabled" class="toggle-input" onchange="document.getElementById('closing-enabled-label').textContent = this.checked ? 'ON' : 'OFF'"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="closing-enabled-label">OFF</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Enable closing-soon alerts</div>
|
||
</div>
|
||
<!-- Alert schedule presets -->
|
||
<div style="margin-bottom:8px">
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">ALERT SCHEDULE — minutes before closing (comma-separated)</label>
|
||
<div style="display:flex;gap:6px;margin:6px 0;flex-wrap:wrap">
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='60,30,10,5'" style="font-size:9px">60,30,10,5</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='30,10,5'" style="font-size:9px">30,10,5</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='30'" style="font-size:9px">30 only</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='10'" style="font-size:9px">10 only</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='0'" style="font-size:9px;color:#888;border-color:#444">None (0)</button>
|
||
</div>
|
||
<input id="cfg-closing-schedule" type="text" placeholder="e.g. 60,30,10,5 or 0 for no closing alerts" style="width:100%;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:4px">Example: <b style="color:var(--accent)">60,30,10,5</b> fires 4 alerts at 60 min, 30 min, 10 min, and 5 min left. <b style="color:var(--accent)">0</b> = capture alert only, no countdown alerts.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- N10: Alert Channels -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// ALERT CHANNELS</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px">Choose one or multiple channels. All enabled channels receive every alert simultaneously.</div>
|
||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px" id="channel-btns">
|
||
<button class="btn channel-btn" data-val="telegram" onclick="toggleChannel('telegram')" id="ch-telegram">📱 TELEGRAM</button>
|
||
<button class="btn channel-btn" data-val="discord" onclick="toggleChannel('discord')" id="ch-discord">🎮 DISCORD</button>
|
||
<button class="btn channel-btn" data-val="email" onclick="toggleChannel('email')" id="ch-email">📧 EMAIL</button>
|
||
</div>
|
||
<div style="margin-top:12px">
|
||
<div id="discord-section" style="display:none">
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">DISCORD WEBHOOK URL</label>
|
||
<input id="cfg-discord-webhook" type="text" placeholder="https://discord.com/api/webhooks/…" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
<div id="email-section" style="display:none;margin-top:10px">
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.7">
|
||
Uses Gmail via an <b style="color:var(--accent-gold)">App Password</b> (not your main Gmail password).<br>
|
||
1. Enable 2-Step Verification on your Google account.<br>
|
||
2. Go to <b style="color:var(--accent)">myaccount.google.com/apppasswords</b> → create an app password → paste it below.
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">GMAIL ADDRESS <span style="color:var(--accent-gold)">(sender)</span></label>
|
||
<input id="cfg-gmail-address" type="text" placeholder="yourname@gmail.com" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">APP PASSWORD <span style="color:var(--accent-gold)">(16-char code)</span></label>
|
||
<input id="cfg-gmail-app-password" type="password" placeholder="xxxx xxxx xxxx xxxx" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SEND ALERTS TO <span style="color:var(--accent-gold)">(destination email)</span></label>
|
||
<input id="cfg-email-to" type="text" placeholder="alerts@youremail.com" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- N4: Currency Display -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// CURRENCY DISPLAY <span style="color:var(--text-dim);font-size:10px;font-weight:400">— N4</span></div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px;line-height:1.7">
|
||
Prices are shown in their <b>raw scraped currency</b> by default.<br>
|
||
Optionally choose a target currency — all prices will also show a converted value using daily rates from <b style="color:var(--accent)">frankfurter.app</b> (free, no key needed).<br>
|
||
Leave blank to keep raw currency display.
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);white-space:nowrap">DISPLAY CURRENCY</label>
|
||
<div style="position:relative">
|
||
<input id="cfg-display-currency" type="text" maxlength="3" placeholder="blank = raw or type / click ▾" autocomplete="off"
|
||
oninput="this.value=this.value.toUpperCase();showCurrencyDropdown(this.value)"
|
||
onfocus="showCurrencyDropdown(this.value)"
|
||
onblur="setTimeout(()=>hideCurrencyDropdown(),200)"
|
||
style="width:220px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
<div id="currency-dropdown" style="display:none;position:absolute;top:100%;left:0;width:280px;background:var(--bg-panel);border:1px solid var(--accent);border-radius:4px;max-height:200px;overflow-y:auto;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.6)"></div>
|
||
</div>
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-display-currency').value='';hideCurrencyDropdown();" title="Clear — show raw prices">✕ Raw</button>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Rates refresh every 6 hours via frankfurter.app</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- N1: Proxy -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// PROXY ROTATION <span style="color:var(--text-dim);font-size:10px;font-weight:400">— N1 optional</span></div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px;line-height:1.7">
|
||
Optional — provide your own proxy list. Ghost Node rotates through them round-robin per scrape.<br>
|
||
Format: one proxy per line — <b style="color:var(--accent)">http://host:port</b> or <b style="color:var(--accent)">http://user:pass@host:port</b><br>
|
||
Leave blank to scrape without proxy. Free proxy lists can be found at free-proxy-list.net (quality varies).
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:10px">
|
||
<label class="toggle-wrap" for="cfg-proxy-enabled">
|
||
<input type="checkbox" id="cfg-proxy-enabled" class="toggle-input" onchange="document.getElementById('proxy-enabled-label').textContent = this.checked ? 'ON' : 'OFF'"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="proxy-enabled-label">OFF</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Enable proxy rotation</div>
|
||
</div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">PROXY LIST (one per line)</label>
|
||
<textarea id="cfg-proxy-list" rows="4" placeholder="http://1.2.3.4:8080 http://user:pass@5.6.7.8:3128 socks5://9.10.11.12:1080" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box;resize:vertical"></textarea>
|
||
</div>
|
||
|
||
<!-- N13: Site Auto-Disable -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// SITE HEALTH — AUTO COOLDOWN</div>
|
||
<div style="display:flex;align-items:center;gap:12px;margin-top:10px">
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);white-space:nowrap">COOLDOWN AFTER</label>
|
||
<input id="cfg-auto-disable" type="number" min="0" max="20" value="5" style="width:60px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:13px;text-align:center"/>
|
||
<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">consecutive failures (0 = never). Site enters 30-min cooldown then retries.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- N15: Export -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// EXPORT LISTINGS</div>
|
||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:10px">
|
||
<a href="/api/export/csv" class="btn btn-ghost" style="text-align:center;text-decoration:none">⬇ CSV</a>
|
||
<a href="/api/export/json" class="btn btn-ghost" style="text-align:center;text-decoration:none">⬇ JSON</a>
|
||
<a href="/api/export/html" class="btn btn-ghost" style="text-align:center;text-decoration:none">⬇ HTML Report</a>
|
||
</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px">Downloads all captured listings. HTML is a self-contained report you can share.</div>
|
||
</div>
|
||
|
||
<!-- DATABASE BACKUP & RESTORE -->
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// DATABASE BACKUP & RESTORE</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:12px;line-height:1.7">
|
||
Your <span style="color:var(--accent-gold)">sniper.db</span> contains all listings, sites, keywords, settings and your Telegram token.
|
||
Back it up regularly. Restore to move Ghost Node to a new machine with all data intact.
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||
<!-- Backup -->
|
||
<div style="background:rgba(0,255,136,.04);border:1px solid rgba(0,255,136,.2);border-radius:4px;padding:14px">
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent-green);letter-spacing:1.5px;margin-bottom:8px">BACKUP</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.6">
|
||
Downloads a clean timestamped copy of your database file. Store it somewhere safe.
|
||
</div>
|
||
<a href="/api/backup/download" class="btn btn-ghost" style="display:block;text-align:center;text-decoration:none;color:var(--accent-green);border-color:var(--accent-green);width:100%;box-sizing:border-box">
|
||
📦 DOWNLOAD BACKUP
|
||
</a>
|
||
</div>
|
||
<!-- Restore -->
|
||
<div style="background:rgba(255,153,0,.04);border:1px solid rgba(255,153,0,.2);border-radius:4px;padding:14px">
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent-gold);letter-spacing:1.5px;margin-bottom:8px">RESTORE</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.6">
|
||
Upload a backup .db file. Current database is auto-saved before overwriting.
|
||
</div>
|
||
<label style="display:block;cursor:pointer">
|
||
<input type="file" id="restore-file-input" accept=".db" style="display:none" onchange="restoreDatabase(this)"/>
|
||
<span class="btn btn-ghost" style="display:block;text-align:center;color:var(--accent-gold);border-color:var(--accent-gold);width:100%;box-sizing:border-box">
|
||
🔁 UPLOAD & RESTORE
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="backup-status" style="font-family:var(--font-mono);font-size:11px;margin-top:10px;display:none"></div>
|
||
</div>
|
||
|
||
<div class="form-section" style="margin-top:20px">
|
||
<div class="form-section-title">// DASHBOARD PREFERENCES</div>
|
||
<div style="display:flex;align-items:center;gap:16px">
|
||
<label class="toggle-wrap" for="cfg-listing-detail">
|
||
<input type="checkbox" id="cfg-listing-detail" class="toggle-input" checked/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<div>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700">Listing Detail View</div>
|
||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Click listing title to open full detail panel</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:20px">
|
||
<button class="btn btn-primary" onclick="saveSettings()" style="padding:10px 28px;font-size:13px;letter-spacing:2px">
|
||
💾 SAVE CONFIGURATION
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ TAB: AI Debug Log ════════════════════════════════════════════════ -->
|
||
<div class="tab-panel" id="tab-aidebug">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">AI DEBUG LOG</div>
|
||
<div class="page-sub">LIVE PROMPT / RESPONSE INSPECTOR</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;flex-direction:column;align-items:flex-start">
|
||
<!-- Live indicator -->
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%">
|
||
<div id="aidebug-live-dot" style="width:8px;height:8px;border-radius:50%;background:#444;display:inline-block;margin-right:2px"></div>
|
||
<span id="aidebug-live-label" style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">POLLING OFF</span>
|
||
<!-- Filter buttons -->
|
||
<button class="btn btn-ghost btn-sm aidebug-filter active" data-filter="all" onclick="setAiFilter('all')">ALL</button>
|
||
<button class="btn btn-ghost btn-sm aidebug-filter" data-filter="filter" onclick="setAiFilter('filter')">🔍 FILTER</button>
|
||
<button class="btn btn-ghost btn-sm aidebug-filter" data-filter="adapt" onclick="setAiFilter('adapt')">🤖 ADAPT</button>
|
||
<button class="btn btn-ghost btn-sm aidebug-filter" data-filter="error" onclick="setAiFilter('error')">⚠ ERRORS</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="clearAiDebugLog()" style="color:#888;border-color:#444">🗑 Clear</button>
|
||
</div>
|
||
<!-- Search and refresh -->
|
||
<div style="display:flex;gap:8px;align-items:center;width:100%">
|
||
<input id="aidl-search" type="text" placeholder="🔍 search AI log…" oninput="filterAiLog()" style="background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:5px 10px;font-family:var(--font-mono);font-size:11px;flex:1;max-width:300px"/>
|
||
<button class="btn btn-ghost btn-sm" onclick="_fetchAiDebugLog(true)" title="Refresh without clearing">↺ Refresh</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Warning if debug is off -->
|
||
<div id="aidebug-off-warn" style="display:none;font-family:var(--font-mono);font-size:11px;color:#ff9900;background:rgba(255,153,0,.08);border:1px solid rgba(255,153,0,.25);border-radius:4px;padding:10px 14px;margin-bottom:14px">
|
||
⚠ <b>AI Debug Mode is OFF.</b> No new entries will be captured. Go to <b>Settings → AI Smart Filter → DEBUG MODE</b> and turn it ON, then save.
|
||
</div>
|
||
|
||
<!-- Token counter bar -->
|
||
<div style="display:flex;gap:20px;margin-bottom:14px;flex-wrap:wrap">
|
||
<div class="stat-card" style="padding:10px 16px;min-width:130px">
|
||
<div class="stat-value" id="aidebug-stat-calls" style="font-size:22px">0</div>
|
||
<div class="stat-unit">total calls</div>
|
||
</div>
|
||
<div class="stat-card" style="padding:10px 16px;min-width:130px">
|
||
<div class="stat-value" id="aidebug-stat-tokens-p" style="font-size:22px">0</div>
|
||
<div class="stat-unit">prompt tokens</div>
|
||
</div>
|
||
<div class="stat-card" style="padding:10px 16px;min-width:130px">
|
||
<div class="stat-value" id="aidebug-stat-tokens-c" style="font-size:22px">0</div>
|
||
<div class="stat-unit">completion tokens</div>
|
||
</div>
|
||
<div class="stat-card" style="padding:10px 16px;min-width:130px">
|
||
<div class="stat-value" id="aidebug-stat-yes" style="font-size:22px;color:var(--accent-green)">0</div>
|
||
<div class="stat-unit">YES verdicts</div>
|
||
</div>
|
||
<div class="stat-card" style="padding:10px 16px;min-width:130px">
|
||
<div class="stat-value" id="aidebug-stat-no" style="font-size:22px;color:#ff4444">0</div>
|
||
<div class="stat-unit">NO verdicts</div>
|
||
</div>
|
||
<div class="stat-card" style="padding:10px 16px;min-width:130px">
|
||
<div class="stat-value" id="aidebug-stat-errors" style="font-size:22px;color:#ff9900">0</div>
|
||
<div class="stat-unit">errors</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Log entries -->
|
||
<div id="aidebug-log" style="display:flex;flex-direction:column;gap:8px;max-height:calc(100vh - 320px);overflow-y:auto;padding-right:4px">
|
||
<div class="empty-state" id="aidebug-empty">
|
||
<div class="big-icon">🧠</div>
|
||
<div>No AI calls captured yet.</div>
|
||
<div style="font-size:11px;color:var(--text-dim);margin-top:6px">Enable Debug Mode in Settings and trigger an AI Filter or Auto-Adapt call.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
|
||
<!-- ─── JavaScript ─────────────────────────────────────────────────────────── -->
|
||
<script>
|
||
const API = ''; // same-origin
|
||
|
||
/* ─── Tabs ──────────────────────────────────────────────────────── */
|
||
let currentTab = 'dashboard';
|
||
document.querySelectorAll('.nav-item').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
document.querySelectorAll('.nav-item').forEach(x => x.classList.remove('active'));
|
||
document.querySelectorAll('.tab-panel').forEach(x => x.classList.remove('active'));
|
||
el.classList.add('active');
|
||
const id = el.dataset.tab;
|
||
document.getElementById('tab-' + id).classList.add('active');
|
||
currentTab = id;
|
||
if (id === 'listings') loadListings();
|
||
if (id === 'keywords') loadKeywords();
|
||
if (id === 'sites') loadSites();
|
||
if (id === 'settings') loadSettings();
|
||
if (id === 'aidebug') startAiDebugPolling();
|
||
else stopAiDebugPolling();
|
||
});
|
||
});
|
||
|
||
/* ─── Alert banner ──────────────────────────────────────────────── */
|
||
let alertTimer;
|
||
function showAlert(msg, type='success') {
|
||
clearTimeout(alertTimer);
|
||
const z = document.getElementById('alert-zone');
|
||
z.innerHTML = `<div class="alert alert-${type}">${type === 'success' ? '✅' : '❌'} ${msg}</div>`;
|
||
alertTimer = setTimeout(() => { z.innerHTML = ''; }, 3500);
|
||
}
|
||
|
||
/* ─── Log ───────────────────────────────────────────────────────── */
|
||
function addLog(msg, cls='log-msg-ok') {
|
||
const box = document.getElementById('log-box');
|
||
const q = (document.getElementById('log-search')?.value || '').toLowerCase();
|
||
const t = new Date().toTimeString().slice(0,8);
|
||
const line = document.createElement('div');
|
||
line.className = 'log-line';
|
||
line.dataset.msg = msg.toLowerCase();
|
||
line.innerHTML = `<span class="log-time">[${t}]</span><span class="${cls}">${msg}</span>`;
|
||
if (q && !msg.toLowerCase().includes(q)) line.style.display = 'none';
|
||
box.appendChild(line);
|
||
box.scrollTop = box.scrollHeight;
|
||
while (box.children.length > 200) box.removeChild(box.firstChild);
|
||
}
|
||
function clearLog() {
|
||
const box = document.getElementById('log-box');
|
||
box.innerHTML = '';
|
||
addLog('Log cleared.', 'log-msg-warn');
|
||
}
|
||
function log_scrollBottom() {
|
||
const box = document.getElementById('log-box');
|
||
box.scrollTop = box.scrollHeight;
|
||
}
|
||
function filterLog() {
|
||
const q = (document.getElementById('log-search')?.value || '').toLowerCase();
|
||
document.querySelectorAll('#log-box .log-line').forEach(line => {
|
||
line.style.display = (!q || (line.dataset.msg || '').includes(q)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
/* ─── Stats poller ──────────────────────────────────────────────── */
|
||
let prevScanned = 0;
|
||
async function pollStats() {
|
||
try {
|
||
const r = await fetch(API + '/api/stats');
|
||
const d = await r.json();
|
||
document.getElementById('stat-scanned').textContent = d.total_scanned;
|
||
document.getElementById('stat-alerts').textContent = d.total_alerts;
|
||
const secs = d.uptime_seconds || 0;
|
||
const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60);
|
||
document.getElementById('stat-uptime').textContent = `${h}h ${m}m`;
|
||
document.getElementById('stat-engine').textContent = d.engine_status || '—';
|
||
document.getElementById('last-cycle-label').textContent = 'Last cycle: ' + (d.last_cycle || '—');
|
||
|
||
const lbl = document.getElementById('engine-status-label');
|
||
const dot = document.getElementById('engine-dot');
|
||
const st = (d.engine_status || '').toLowerCase();
|
||
lbl.textContent = (d.engine_status || 'UNKNOWN').toUpperCase();
|
||
if (st.includes('running')) {
|
||
dot.style.background = 'var(--accent-green)';
|
||
} else if (st.includes('paused')) {
|
||
dot.style.background = 'var(--accent-hot)';
|
||
dot.style.animation = 'none';
|
||
} else {
|
||
dot.style.background = 'var(--accent-gold)';
|
||
}
|
||
|
||
if (d.total_scanned > prevScanned) {
|
||
addLog(`Scanned ${d.total_scanned - prevScanned} new listings (total: ${d.total_scanned})`, 'log-msg-hit');
|
||
prevScanned = d.total_scanned;
|
||
}
|
||
} catch(e) {
|
||
document.getElementById('engine-status-label').textContent = 'OFFLINE';
|
||
addLog('Dashboard cannot reach backend.', 'log-msg-warn');
|
||
}
|
||
}
|
||
setInterval(pollStats, 5000);
|
||
pollStats();
|
||
|
||
/* Recent hits on dashboard */
|
||
async function loadRecentHits() {
|
||
try {
|
||
const r = await fetch(API + '/api/listings?limit=5');
|
||
const data = await r.json();
|
||
const c = document.getElementById('recent-hits-container');
|
||
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">🔍</div>No hits yet.</div>'; return; }
|
||
c.innerHTML = `<table><thead><tr><th>Title</th><th>Price</th><th>Score</th><th>Keyword</th></tr></thead><tbody>
|
||
${data.map(l => `
|
||
<tr>
|
||
<td><a class="listing-link" href="${l.link}" target="_blank">${escHtml(l.title.slice(0,55))}…</a></td>
|
||
<td class="price-tag">${fmtPrice(l)}</td>
|
||
<td><span class="score-badge ${l.score > 0 ? 'score-pos' : 'score-zero'}">${l.score}</span></td>
|
||
<td><span class="chip">${escHtml(l.keyword || '—')}</span></td>
|
||
</tr>`).join('')}
|
||
</tbody></table>`;
|
||
} catch(e) {}
|
||
}
|
||
setInterval(loadRecentHits, 8000);
|
||
loadRecentHits();
|
||
|
||
/* ─── Listings ──────────────────────────────────────────────────── */
|
||
let _allListings = [];
|
||
async function loadListings() {
|
||
document.getElementById('listings-table-container').innerHTML = '<div class="empty-state"><span class="spinner"></span></div>';
|
||
const r = await fetch(API + '/api/listings?limit=200');
|
||
_allListings = await r.json();
|
||
renderListings(_allListings);
|
||
}
|
||
|
||
// ── Live countdown — 1-second in-place cell ticker ───────────────────────────
|
||
// Instead of re-rendering the whole table every 30s (causes flicker + loses
|
||
// scroll position), we stamp data-tlmins and data-captured onto each TD at
|
||
// render time and then just rewrite the text/colour of those cells every second.
|
||
//
|
||
// Format rules:
|
||
// > 1 day : "2d 4h" (days + hours, no mins/secs needed)
|
||
// 1h – 24h : "4h 22m" (hours + minutes)
|
||
// 5m – 1h : "44m 32s" (minutes + seconds — clearly urgent)
|
||
// 0 – 5m : "4m 12s" 🔴 bold (pulse class added)
|
||
// expired : "CLOSED" dim
|
||
|
||
function _tickCountdowns() {
|
||
const now = Date.now();
|
||
document.querySelectorAll('.tl-cell').forEach(td => {
|
||
const tlMins = parseFloat(td.dataset.tlmins);
|
||
const captured = td.dataset.captured ? new Date(td.dataset.captured).getTime() : null;
|
||
|
||
// No data — leave whatever static text was rendered at load time
|
||
if (!captured || isNaN(tlMins)) return;
|
||
|
||
const elapsedMins = (now - captured) / 60000;
|
||
const remainMins = Math.max(0, tlMins - elapsedMins);
|
||
const remainSecs = Math.round(remainMins * 60);
|
||
|
||
let text, color, weight, pulse;
|
||
|
||
if (remainSecs <= 0) {
|
||
text = 'CLOSED';
|
||
color = 'var(--text-dim)';
|
||
weight = 'normal';
|
||
pulse = false;
|
||
} else if (remainMins >= 1440) {
|
||
// > 1 day — coarse display
|
||
const d = Math.floor(remainMins / 1440);
|
||
const h = Math.floor((remainMins % 1440) / 60);
|
||
text = d + 'd' + (h ? ' ' + h + 'h' : '');
|
||
color = 'var(--accent-gold)';
|
||
weight = 'normal';
|
||
pulse = false;
|
||
} else if (remainMins >= 60) {
|
||
// 1h – 24h
|
||
const h = Math.floor(remainMins / 60);
|
||
const m = Math.floor(remainMins % 60);
|
||
text = h + 'h' + (m ? ' ' + m + 'm' : '');
|
||
color = remainMins < 360 ? '#ff9900' : 'var(--accent-gold)';
|
||
weight = 'normal';
|
||
pulse = false;
|
||
} else if (remainMins >= 5) {
|
||
// 5m – 1h: show live seconds
|
||
const m = Math.floor(remainSecs / 60);
|
||
const s = remainSecs % 60;
|
||
text = m + 'm ' + String(s).padStart(2,'0') + 's';
|
||
color = '#ff9900';
|
||
weight = 'bold';
|
||
pulse = false;
|
||
} else {
|
||
// 0 – 5m: critical — red pulse
|
||
const m = Math.floor(remainSecs / 60);
|
||
const s = remainSecs % 60;
|
||
text = m + 'm ' + String(s).padStart(2,'0') + 's';
|
||
color = '#ff4444';
|
||
weight = 'bold';
|
||
pulse = true;
|
||
}
|
||
|
||
// Only touch the DOM if something actually changed (avoids layout thrash)
|
||
if (td.textContent !== text) td.textContent = text;
|
||
if (td.style.color !== color) td.style.color = color;
|
||
if (td.style.fontWeight !== weight) td.style.fontWeight = weight;
|
||
td.classList.toggle('tl-pulse', pulse);
|
||
});
|
||
}
|
||
|
||
setInterval(_tickCountdowns, 1000);
|
||
_tickCountdowns(); // run immediately so cells are right on load
|
||
|
||
// ── Countdown sync — patches data-tlmins+data-captured every 60s ─────────────
|
||
// When Thread D (price refresh) updates time_left_mins in the DB, the cells
|
||
// still have the old values stamped in data-* attributes from the last render.
|
||
// This poll silently patches those attributes in-place so the ticker always
|
||
// uses the freshest measurement from the backend — no re-render needed.
|
||
async function _syncCountdownData() {
|
||
try {
|
||
const r = await fetch(API + '/api/listings/countdown-sync');
|
||
if (!r.ok) return;
|
||
const rows = await r.json();
|
||
rows.forEach(row => {
|
||
const td = document.querySelector(`.tl-cell[data-id="${row.id}"]`);
|
||
if (!td) return;
|
||
// Only patch if the backend has data for this listing
|
||
if (row.time_left_mins != null) {
|
||
td.dataset.tlmins = row.time_left_mins;
|
||
td.dataset.captured = row.price_updated_at || row.timestamp || '';
|
||
}
|
||
});
|
||
// Re-tick immediately after patching so cells show the updated value at once
|
||
_tickCountdowns();
|
||
} catch(e) { /* silent — never break the page */ }
|
||
}
|
||
setInterval(_syncCountdownData, 60_000); // patch every 60s
|
||
_syncCountdownData(); // run immediately on page load
|
||
|
||
// ── Price refresh poll (every 5 min checks if backend updated prices) ─────────
|
||
let _lastPriceUpdate = null;
|
||
setInterval(async () => {
|
||
try {
|
||
const r = await fetch(API + '/api/listings/refresh-status');
|
||
const d = await r.json();
|
||
if (d.last_price_update && d.last_price_update !== _lastPriceUpdate) {
|
||
_lastPriceUpdate = d.last_price_update;
|
||
// Prices changed — reload listing data and re-render with flash
|
||
const lr = await fetch(API + '/api/listings?limit=200');
|
||
_allListings = await lr.json();
|
||
renderListings(_allListings.filter(l => l.title.toLowerCase().includes(
|
||
(document.getElementById('listing-filter').value || '').toLowerCase()
|
||
)));
|
||
// Re-apply live countdown immediately after re-render
|
||
// (renderListings stamps fresh data-tlmins / data-captured onto each TD,
|
||
// so the ticker now has the latest scraped time_left_mins to work from)
|
||
_tickCountdowns();
|
||
// Brief green flash on the price column header to signal refresh
|
||
const priceTh = document.querySelector('th.sortable');
|
||
if (priceTh) {
|
||
priceTh.style.transition = 'color .3s';
|
||
priceTh.style.color = 'var(--accent-green)';
|
||
setTimeout(() => priceTh.style.color = '', 1500);
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}, 300000); // 5 minutes
|
||
|
||
/* ── Sort state ─────────────────────────────────────────────────── */
|
||
let _sortCol = null; // 'price' | 'time'
|
||
let _sortDir = 'asc'; // 'asc' | 'desc'
|
||
|
||
/* ── N4 Currency display ─────────────────────────────────────────── */
|
||
let _displayCurrency = ''; // e.g. 'USD', 'EUR', 'IQD' — blank = raw only
|
||
let _fxRates = {}; // USD-based rates cache, e.g. { EUR: 0.92, GBP: 0.79 }
|
||
let _listingDetailEnabled = true;
|
||
|
||
async function _fetchFxRates() {
|
||
try {
|
||
const r = await fetch('https://api.frankfurter.app/latest?from=USD');
|
||
if (r.ok) { const d = await r.json(); _fxRates = d.rates || {}; _fxRates['USD'] = 1.0; }
|
||
} catch(e) {}
|
||
}
|
||
|
||
function _convertToDisplay(priceUsd) {
|
||
if (priceUsd == null || priceUsd === '') return null;
|
||
const n = Number(priceUsd);
|
||
if (!_displayCurrency) return null; // display_currency not set — show nothing extra
|
||
if (_displayCurrency === 'USD') return `≈ ${n.toFixed(2)} USD`;
|
||
const rate = _fxRates[_displayCurrency];
|
||
if (!rate) return `≈ ${n.toFixed(2)} USD`; // rate not loaded yet
|
||
return `≈ ${(n * rate).toFixed(2)} ${_displayCurrency}`;
|
||
}
|
||
|
||
function sortListings(col) {
|
||
if (_sortCol === col) {
|
||
_sortDir = _sortDir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
_sortCol = col;
|
||
_sortDir = col === 'time' ? 'asc' : 'desc'; // time: soonest first; price: highest first
|
||
}
|
||
const q = document.getElementById('listing-filter').value.toLowerCase();
|
||
const filtered = _allListings.filter(l => l.title.toLowerCase().includes(q));
|
||
renderListings(filtered);
|
||
}
|
||
|
||
/* Convert "2d 4h 30m" → total minutes for numeric sort */
|
||
function timeLeftToMins(tl) {
|
||
if (!tl || !tl.trim()) return Infinity; // no time = treat as furthest away
|
||
let mins = 0;
|
||
const d = tl.match(/(\d+)d/); if (d) mins += parseInt(d[1]) * 1440;
|
||
const h = tl.match(/(\d+)h/); if (h) mins += parseInt(h[1]) * 60;
|
||
const m = tl.match(/(\d+)m/); if (m) mins += parseInt(m[1]);
|
||
return mins || Infinity;
|
||
}
|
||
|
||
/* ─── Drag & Drop Reorder ───────────────────────────────────────── */
|
||
let _dragSrcId = null, _dragType = null;
|
||
function dragStart(e) {
|
||
_dragSrcId = parseInt(e.currentTarget.dataset.id);
|
||
_dragType = e.currentTarget.dataset.type;
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.currentTarget.closest('tr').style.opacity = '0.4';
|
||
}
|
||
function dragOver(e) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
const tr = e.currentTarget.closest('tr');
|
||
document.querySelectorAll('tr.drag-over').forEach(r => r.classList.remove('drag-over'));
|
||
tr.classList.add('drag-over');
|
||
}
|
||
function dragEnd(e) {
|
||
e.currentTarget.closest('tr').style.opacity = '';
|
||
document.querySelectorAll('tr.drag-over').forEach(r => r.classList.remove('drag-over'));
|
||
}
|
||
async function dragDrop(e) {
|
||
e.preventDefault();
|
||
const targetId = parseInt(e.currentTarget.dataset.id);
|
||
const targetType = e.currentTarget.dataset.type;
|
||
if (_dragSrcId === null || _dragSrcId === targetId || _dragType !== targetType) return;
|
||
// Collect all row IDs in current DOM order
|
||
const handles = Array.from(document.querySelectorAll(`.drag-handle[data-type="${_dragType}"]`));
|
||
const ids = handles.map(h => parseInt(h.dataset.id));
|
||
const fromIdx = ids.indexOf(_dragSrcId);
|
||
const toIdx = ids.indexOf(targetId);
|
||
if (fromIdx < 0 || toIdx < 0) return;
|
||
ids.splice(fromIdx, 1);
|
||
ids.splice(toIdx, 0, _dragSrcId);
|
||
const endpoint = _dragType === 'keyword' ? '/api/keywords/reorder' : '/api/sites/reorder';
|
||
await fetch(API + endpoint, {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ order: ids })
|
||
});
|
||
if (_dragType === 'keyword') loadKeywords(); else loadSites();
|
||
addLog(`Reordered ${_dragType}s`, 'log-msg-ok');
|
||
_dragSrcId = null;
|
||
}
|
||
|
||
function renderListings(data) {
|
||
const c = document.getElementById('listings-table-container');
|
||
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">👻</div>No listings captured yet.</div>'; return; }
|
||
|
||
/* Apply sort */
|
||
let sorted = [...data];
|
||
if (_sortCol === 'price') {
|
||
sorted.sort((a, b) => {
|
||
const pa = a.price ?? -Infinity;
|
||
const pb = b.price ?? -Infinity;
|
||
return _sortDir === 'asc' ? pa - pb : pb - pa;
|
||
});
|
||
} else if (_sortCol === 'time') {
|
||
sorted.sort((a, b) => {
|
||
const ta = timeLeftToMins(a.time_left);
|
||
const tb = timeLeftToMins(b.time_left);
|
||
return _sortDir === 'asc' ? ta - tb : tb - ta;
|
||
});
|
||
}
|
||
|
||
const priceClass = _sortCol === 'price' ? (_sortDir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
||
const timeClass = _sortCol === 'time' ? (_sortDir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
||
|
||
c.innerHTML = `<table><thead><tr>
|
||
<th>Title</th>
|
||
<th class="sortable ${priceClass}" onclick="sortListings('price')" title="Sort by price">Price</th>
|
||
<th class="sortable ${timeClass}" onclick="sortListings('time')" title="Sort by time left">Time Left</th>
|
||
<th>Score</th><th>AI</th><th>Keyword</th><th>Site</th><th>Captured</th><th></th>
|
||
</tr></thead><tbody>
|
||
${sorted.map(l => {
|
||
const locHtml = l.location ? `<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:2px">📍 ${escHtml(l.location)}</div>` : '';
|
||
const convPrice = _convertToDisplay(l.price_usd);
|
||
const convHtml = convPrice ? `<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:1px">${escHtml(convPrice)}</div>` : '';
|
||
let thumbHtml = '';
|
||
try {
|
||
const imgs = JSON.parse(l.images || '[]');
|
||
if (imgs.length > 0) {
|
||
thumbHtml = `<img src="${escHtml(imgs[0])}" alt="" loading="lazy" onerror="this.style.display='none'" style="width:48px;height:48px;object-fit:cover;border-radius:4px;flex-shrink:0;background:var(--bg-card)">`;
|
||
}
|
||
} catch(e) {}
|
||
return `
|
||
<tr style="${l.ai_match === 0 ? 'opacity:0.45' : ''}">
|
||
<td onclick="${_listingDetailEnabled ? `openListingDetail(${l.id})` : ''}" style="${_listingDetailEnabled ? 'cursor:pointer' : ''}">
|
||
<div style="display:flex;align-items:flex-start;gap:8px">
|
||
${thumbHtml}
|
||
<div>
|
||
<span class="listing-title">${escHtml(l.title)}</span>
|
||
<a class="listing-link" href="${escHtml(l.link)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">↗ Open</a>
|
||
${locHtml}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="price-tag">${fmtPrice(l)}${convHtml}</td>
|
||
<td class="tl-cell"
|
||
data-id="${l.id}"
|
||
data-tlmins="${l.time_left_mins != null ? l.time_left_mins : ''}"
|
||
data-captured="${l.price_updated_at || l.timestamp || ''}"
|
||
style="font-family:var(--font-mono);font-size:11px"
|
||
>${fmtTimeLeft(l)}</td>
|
||
<td><span class="score-badge ${l.score > 0 ? 'score-pos' : 'score-zero'}">${l.score}</span></td>
|
||
<td title="${escHtml(l.ai_reason || '')}" style="font-size:15px;cursor:${l.ai_match != null ? 'help' : 'default'}">
|
||
${l.ai_match === 1 ? '🤖✅' : l.ai_match === 0 ? '🤖❌' : '<span style="color:var(--text-dim);font-size:10px">—</span>'}
|
||
</td>
|
||
<td><span class="chip">${escHtml(l.keyword || '—')}</span></td>
|
||
<td style="color:var(--text-mid);font-size:11px">${escHtml(l.site_name || '—')}</td>
|
||
<td style="color:var(--text-dim);font-family:var(--font-mono);font-size:10px">${fmtDate(l.timestamp)}</td>
|
||
<td><button class="btn btn-danger btn-sm" onclick="deleteListing(${l.id})">×</button></td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody></table>`;
|
||
}
|
||
|
||
function filterListings() {
|
||
const q = document.getElementById('listing-filter').value.toLowerCase();
|
||
const filtered = _allListings.filter(l => l.title.toLowerCase().includes(q));
|
||
renderListings(filtered);
|
||
}
|
||
|
||
function openListingDetail(id) {
|
||
const l = _allListings.find(x => x.id === id);
|
||
if (!l) return;
|
||
const convPrice = _convertToDisplay(l.price_usd);
|
||
document.getElementById('ld-title').textContent = l.title;
|
||
document.getElementById('ld-link').href = l.link;
|
||
document.getElementById('ld-price').textContent = (l.price_raw || '—') + (convPrice ? ' ' + convPrice : '');
|
||
document.getElementById('ld-location').textContent= l.location || '—';
|
||
document.getElementById('ld-site').textContent = l.site_name || '—';
|
||
document.getElementById('ld-keyword').textContent = l.keyword || '—';
|
||
document.getElementById('ld-score').textContent = l.score;
|
||
document.getElementById('ld-ai').textContent = l.ai_match === 1 ? '✅ Match' : l.ai_match === 0 ? '❌ Rejected' : '— Not analysed';
|
||
document.getElementById('ld-ai-reason').textContent = l.ai_reason || '';
|
||
document.getElementById('ld-time-left').textContent = l.time_left || '—';
|
||
document.getElementById('ld-captured').textContent = fmtDate(l.timestamp);
|
||
document.getElementById('ld-refreshed').textContent = l.price_updated_at ? fmtDate(l.price_updated_at) : '—';
|
||
// Image gallery
|
||
const ldImgs = document.getElementById('ld-images');
|
||
const ldImgsWrap = document.getElementById('ld-images-wrap');
|
||
let imgList = [];
|
||
try { imgList = JSON.parse(l.images || '[]'); } catch(e) {}
|
||
if (imgList.length > 0) {
|
||
ldImgs.innerHTML = imgList.map(src =>
|
||
`<a href="${escHtml(src)}" target="_blank" rel="noopener noreferrer" style="display:block;flex-shrink:0">` +
|
||
`<img src="${escHtml(src)}" alt="" loading="lazy" onerror="this.parentElement.style.display='none'" ` +
|
||
`style="width:140px;height:110px;object-fit:cover;border-radius:6px;border:1px solid var(--border-dim);background:var(--bg-card);display:block"></a>`
|
||
).join('');
|
||
ldImgsWrap.style.display = '';
|
||
} else {
|
||
ldImgs.innerHTML = '';
|
||
ldImgsWrap.style.display = 'none';
|
||
}
|
||
document.getElementById('listing-detail-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeListingDetail() {
|
||
document.getElementById('listing-detail-modal').style.display = 'none';
|
||
}
|
||
|
||
async function deleteListing(id) {
|
||
await fetch(API + `/api/listings/${id}`, { method: 'DELETE' });
|
||
loadListings();
|
||
}
|
||
|
||
async function clearListings() {
|
||
if (!confirm('Clear ALL listings? This is irreversible.')) return;
|
||
await fetch(API + '/api/listings', { method: 'DELETE' });
|
||
loadListings();
|
||
showAlert('All listings cleared.');
|
||
}
|
||
|
||
/* ─── Keywords ──────────────────────────────────────────────────── */
|
||
async function loadKeywords() {
|
||
document.getElementById('keywords-table-container').innerHTML = '<div class="empty-state"><span class="spinner"></span></div>';
|
||
const r = await fetch(API + '/api/keywords');
|
||
const data = await r.json();
|
||
const c = document.getElementById('keywords-table-container');
|
||
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">🔍</div>No keywords yet.</div>'; return; }
|
||
c.innerHTML = `<table><thead><tr><th style="width:24px"></th><th>#</th><th>Keyword</th><th>Weight</th><th>Price Filter</th><th>AI Target</th><th></th></tr></thead><tbody>
|
||
${data.map(k => {
|
||
const minTxt = (k.min_price != null && k.min_price !== '') ? k.min_price : null;
|
||
const maxTxt = (k.max_price != null && k.max_price !== '') ? k.max_price : null;
|
||
const filterBadge = (minTxt !== null || maxTxt !== null)
|
||
? `<span style="font-family:var(--font-mono);font-size:11px;color:var(--accent-green)">` +
|
||
(minTxt !== null ? `≥${minTxt}` : '') +
|
||
(minTxt !== null && maxTxt !== null ? ' / ' : '') +
|
||
(maxTxt !== null ? `≤${maxTxt}` : '') +
|
||
`</span>`
|
||
: `<span style="color:var(--text-dim);font-size:11px">none</span>`;
|
||
return `
|
||
<tr>
|
||
<td class="drag-handle" draggable="true" data-id="${k.id}" data-type="keyword" ondragstart="dragStart(event)" ondragover="dragOver(event)" ondrop="dragDrop(event)" ondragend="dragEnd(event)">⋮⋮</td>
|
||
<td style="color:var(--text-dim);font-family:var(--font-mono);font-size:11px">${k.id}</td>
|
||
<td><span class="chip kw-inline-edit" title="Click to rename" onclick="inlineEditKw(${k.id}, 'term', this, ${JSON.stringify(escHtml(k.term))})">${escHtml(k.term)}</span></td>
|
||
<td style="font-family:var(--font-mono);color:var(--accent-gold);cursor:pointer" title="Click to change weight" onclick="inlineEditKw(${k.id}, 'weight', this, '${k.weight}')">${k.weight}×</td>
|
||
<td>${filterBadge}</td>
|
||
<td style="max-width:240px">
|
||
${k.ai_target
|
||
? `<span style="color:var(--accent-green);font-size:11px;font-family:var(--font-mono)">🤖 ${escHtml(k.ai_target.length > 50 ? k.ai_target.slice(0,50)+'…' : k.ai_target)}</span>`
|
||
: `<span style="color:var(--text-dim);font-size:11px">not set</span>`}
|
||
</td>
|
||
<td style="white-space:nowrap">
|
||
<button class="btn btn-secondary btn-sm" onclick="editPriceFilter(${k.id}, ${JSON.stringify(escHtml(k.term))}, ${minTxt !== null ? minTxt : 'null'}, ${maxTxt !== null ? maxTxt : 'null'})" style="margin-right:4px">💰 Price</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="editAiTarget(${k.id}, ${JSON.stringify(escHtml(k.term))}, ${JSON.stringify(escHtml(k.ai_target||''))})" style="margin-right:4px">🤖 AI</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteKeyword(${k.id})">× Remove</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody></table>`;
|
||
}
|
||
|
||
async function addKeyword() {
|
||
const term = document.getElementById('kw-term').value.trim();
|
||
const weight = parseInt(document.getElementById('kw-weight').value) || 1;
|
||
if (!term) { showAlert('Please enter a search term.', 'error'); return; }
|
||
const r = await fetch(API + '/api/keywords', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ term, weight })
|
||
});
|
||
if (r.ok) {
|
||
document.getElementById('kw-term').value = '';
|
||
document.getElementById('kw-weight').value = '1';
|
||
loadKeywords();
|
||
showAlert(`Keyword "${term}" added.`);
|
||
addLog(`Keyword added: ${term} (weight ${weight})`, 'log-msg-hit');
|
||
} else {
|
||
const e = await r.json();
|
||
showAlert(e.error || 'Failed to add keyword.', 'error');
|
||
}
|
||
}
|
||
|
||
async function batchImportKeywords() {
|
||
const raw = document.getElementById('kw-batch').value.trim();
|
||
if (!raw) { showAlert('Paste at least one keyword.', 'error'); return; }
|
||
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||
let added = 0, skipped = 0;
|
||
for (const line of lines) {
|
||
const parts = line.split(':');
|
||
const term = parts[0].trim();
|
||
const weight = parts[1] ? (parseInt(parts[1]) || 1) : 1;
|
||
if (!term) continue;
|
||
const r = await fetch(API + '/api/keywords', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ term, weight })
|
||
});
|
||
if (r.ok) added++; else skipped++;
|
||
}
|
||
document.getElementById('kw-batch').value = '';
|
||
document.getElementById('batch-result').textContent = `✅ ${added} added${skipped ? ', ' + skipped + ' skipped (duplicate)' : ''}`;
|
||
setTimeout(() => { const el = document.getElementById('batch-result'); if(el) el.textContent = ''; }, 5000);
|
||
loadKeywords();
|
||
addLog(`Batch import: ${added} keywords added, ${skipped} skipped.`, 'log-msg-ok');
|
||
}
|
||
|
||
async function deleteKeyword(id) {
|
||
await fetch(API + `/api/keywords/${id}`, { method: 'DELETE' });
|
||
loadKeywords();
|
||
showAlert('Keyword removed.');
|
||
}
|
||
|
||
async function inlineEditKw(id, field, cell, currentVal) {
|
||
if (cell.querySelector('input')) return; // already editing
|
||
const isWeight = field === 'weight';
|
||
const inp = document.createElement('input');
|
||
inp.type = isWeight ? 'number' : 'text';
|
||
if (isWeight) { inp.min = '1'; inp.max = '10'; inp.style.width = '50px'; }
|
||
else { inp.style.width = '120px'; }
|
||
inp.value = currentVal;
|
||
inp.style.cssText += ';background:var(--bg-card);border:1px solid var(--accent);color:var(--text-hi);padding:2px 6px;font-family:var(--font-mono);font-size:12px;border-radius:3px';
|
||
const orig = cell.innerHTML;
|
||
cell.innerHTML = '';
|
||
cell.appendChild(inp);
|
||
inp.focus(); inp.select();
|
||
const save = async () => {
|
||
const val = inp.value.trim();
|
||
if (!val || val === String(currentVal)) { cell.innerHTML = orig; return; }
|
||
const body = {};
|
||
body[field] = isWeight ? parseInt(val) : val;
|
||
const r = await fetch(API + `/api/keywords/${id}`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (r.ok) {
|
||
loadKeywords();
|
||
addLog(`Keyword #${id} ${field} updated to "${val}"`, 'log-msg-ok');
|
||
} else {
|
||
const e = await r.json();
|
||
showAlert(e.error || 'Failed to update.', 'error');
|
||
cell.innerHTML = orig;
|
||
}
|
||
};
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cell.innerHTML = orig; });
|
||
inp.addEventListener('blur', save);
|
||
}
|
||
|
||
function editPriceFilter(id, term, currentMin, currentMax) {
|
||
document.getElementById('pf-kw-label').textContent = term;
|
||
document.getElementById('pf-min-input').value = (currentMin != null) ? currentMin : '';
|
||
document.getElementById('pf-max-input').value = (currentMax != null) ? currentMax : '';
|
||
document.getElementById('pf-kw-id').value = id;
|
||
document.getElementById('price-filter-modal').style.display = 'flex';
|
||
}
|
||
function closePriceFilterModal() {
|
||
document.getElementById('price-filter-modal').style.display = 'none';
|
||
}
|
||
async function savePriceFilter() {
|
||
const id = parseInt(document.getElementById('pf-kw-id').value);
|
||
const min = document.getElementById('pf-min-input').value.trim();
|
||
const max = document.getElementById('pf-max-input').value.trim();
|
||
const r = await fetch(API + `/api/keywords/${id}`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({
|
||
min_price: min !== '' ? parseFloat(min) : null,
|
||
max_price: max !== '' ? parseFloat(max) : null
|
||
})
|
||
});
|
||
if (r.ok) {
|
||
closePriceFilterModal();
|
||
loadKeywords();
|
||
showAlert('Price filter saved.');
|
||
addLog(`Price filter updated for keyword ID ${id} — min:${min||'none'} max:${max||'none'}`, 'log-msg-ok');
|
||
} else {
|
||
showAlert('Failed to save price filter.', 'error');
|
||
}
|
||
}
|
||
|
||
function editAiTarget(id, term, currentTarget) {
|
||
const modal = document.getElementById('ai-target-modal');
|
||
document.getElementById('ai-target-kw-label').textContent = term;
|
||
document.getElementById('ai-target-input').value = currentTarget || '';
|
||
document.getElementById('ai-target-kw-id').value = id;
|
||
document.getElementById('ai-test-result').textContent = '';
|
||
document.getElementById('ai-test-title-input').value = '';
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function closeAiTargetModal() {
|
||
document.getElementById('ai-target-modal').style.display = 'none';
|
||
}
|
||
|
||
async function saveAiTarget() {
|
||
const id = parseInt(document.getElementById('ai-target-kw-id').value);
|
||
const aiTarget = document.getElementById('ai-target-input').value.trim();
|
||
const r = await fetch(API + `/api/keywords/${id}`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ ai_target: aiTarget })
|
||
});
|
||
if (r.ok) {
|
||
closeAiTargetModal();
|
||
loadKeywords();
|
||
showAlert(aiTarget ? `AI target saved for keyword.` : `AI target cleared.`);
|
||
addLog(`AI target updated for keyword ID ${id}`, 'log-msg-ok');
|
||
} else {
|
||
showAlert('Failed to save AI target.', 'error');
|
||
}
|
||
}
|
||
|
||
async function testAiTarget() {
|
||
const aiTarget = document.getElementById('ai-target-input').value.trim();
|
||
const testTitle = document.getElementById('ai-test-title-input').value.trim();
|
||
const resultEl = document.getElementById('ai-test-result');
|
||
if (!aiTarget || !testTitle) {
|
||
resultEl.textContent = '⚠️ Fill in both AI Target and a test title first.';
|
||
resultEl.style.color = 'var(--accent-gold)';
|
||
return;
|
||
}
|
||
resultEl.textContent = '⏳ Testing…';
|
||
resultEl.style.color = 'var(--text-mid)';
|
||
try {
|
||
const r = await fetch(API + '/api/ai/test', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ title: testTitle, ai_target: aiTarget })
|
||
});
|
||
const d = await r.json();
|
||
if (d.error) {
|
||
resultEl.textContent = `❌ Error: ${d.error}`;
|
||
resultEl.style.color = 'var(--danger)';
|
||
} else {
|
||
const icon = d.match ? '✅ MATCH' : '❌ NO MATCH';
|
||
resultEl.textContent = `${icon} — ${d.reason} (via ${d.provider || 'ai'})`;
|
||
resultEl.style.color = d.match ? 'var(--accent-green)' : 'var(--danger)';
|
||
}
|
||
} catch(e) {
|
||
resultEl.textContent = `❌ Request failed: ${e.message}`;
|
||
resultEl.style.color = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
/* ─── Target Sites ──────────────────────────────────────────────── */
|
||
let _lastSitesData = [];
|
||
|
||
async function loadSites() {
|
||
document.getElementById('sites-table-container').innerHTML = '<div class="empty-state"><span class="spinner"></span></div>';
|
||
const r = await fetch(API + '/api/sites');
|
||
const data = await r.json();
|
||
_lastSitesData = data;
|
||
const c = document.getElementById('sites-table-container');
|
||
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">🌐</div>No sites configured.</div>'; return; }
|
||
// Load selectors for all sites in one batch, then render
|
||
const siteIds = data.map(s => s.id);
|
||
const selectorMap = {};
|
||
await Promise.all(siteIds.map(async id => {
|
||
try {
|
||
const r = await fetch(`/api/sites/${id}/selectors`);
|
||
const j = await r.json();
|
||
selectorMap[id] = j.selectors;
|
||
} catch(e) { selectorMap[id] = null; }
|
||
}));
|
||
|
||
c.innerHTML = `<table><thead><tr><th style="width:24px"></th><th>Name</th><th>Mode</th><th>URL</th><th>Selector</th><th>Status</th><th>Health</th><th>AI Adapt</th><th></th></tr></thead><tbody>
|
||
${data.map(s => {
|
||
const isDirect = s.url_template.includes('{keyword}');
|
||
const modeLabel = isDirect
|
||
? '<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent);background:rgba(0,245,255,.1);border:1px solid rgba(0,245,255,.2);border-radius:3px;padding:2px 6px;letter-spacing:1px">DIRECT</span>'
|
||
: '<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent-green);background:rgba(0,255,136,.1);border:1px solid rgba(0,255,136,.2);border-radius:3px;padding:2px 6px;letter-spacing:1px">HOMEPAGE</span>';
|
||
const selDisplay = s.search_selector
|
||
? `<span style="font-family:var(--font-mono);font-size:11px;color:var(--accent-gold)">${escHtml(s.search_selector)}</span>`
|
||
: `<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);font-style:italic">auto-discover</span>`;
|
||
// N13: Health column
|
||
let healthHtml = '<span class="site-health-ok">✓ OK</span>';
|
||
if (s.cooldown_until && new Date(s.cooldown_until) > new Date()) {
|
||
const cd = new Date(s.cooldown_until);
|
||
healthHtml = `<span class="site-cooldown">⏳ COOLDOWN<br><small>${cd.toLocaleTimeString()}</small></span>`;
|
||
} else if (s.consecutive_failures >= 3) {
|
||
healthHtml = `<span class="site-health-err">⚠ ${s.consecutive_failures} fails<br><small style="color:#888">${escHtml((s.last_error||'').slice(0,40))}</small></span>`;
|
||
} else if (s.consecutive_failures > 0) {
|
||
healthHtml = `<span class="site-health-warn">⚠ ${s.consecutive_failures} fail(s)</span>`;
|
||
} else if (s.error_count > 0 && s.last_success_at) {
|
||
healthHtml = `<span class="site-health-ok">✓ OK (${s.error_count} total errors)</span>`;
|
||
}
|
||
// N14: Login button
|
||
const loginBtn = s.requires_login && s.login_enabled
|
||
? `<button class="btn btn-ghost btn-sm" onclick="triggerLogin(${s.id})" title="Open browser to log in to this site" style="color:var(--accent-gold);border-color:var(--accent-gold)">🔑 Login</button>`
|
||
: '';
|
||
const pgsBadge = (s.max_pages || 1) > 1
|
||
? `<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent-gold);margin-left:4px">${s.max_pages}p</span>`
|
||
: '';
|
||
const loginStatus = s.requires_login
|
||
? (s.login_enabled
|
||
? `<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent-gold)">🔑 login</span>`
|
||
: `<span style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim)">🔑 off</span>`)
|
||
: '';
|
||
// N17: AI Adapt column
|
||
const ss = selectorMap[s.id];
|
||
let aiAdaptHtml = '';
|
||
if (ss) {
|
||
const conf = ss.confidence || 0;
|
||
const stale = ss.stale;
|
||
const confColor = stale ? '#888' : (conf >= 70 ? 'var(--accent-green)' : conf >= 40 ? '#ff9900' : '#ff4444');
|
||
const confLabel = stale ? 'STALE' : `${Math.round(conf)}%`;
|
||
const genAt = ss.generated_at ? new Date(ss.generated_at).toLocaleDateString() : '';
|
||
const tooltip = `Container: ${ss.container_sel||'—'} | Items: ${ss.container_count} | Title: ${ss.title_rate}% | Price: ${ss.price_rate}%${genAt?' | '+genAt:''}`;
|
||
aiAdaptHtml = `<div style="display:flex;flex-direction:column;gap:3px;align-items:flex-start">
|
||
<span style="font-family:var(--font-mono);font-size:10px;color:${confColor};font-weight:700;cursor:help" title="${escHtml(tooltip)}">🤖 ${confLabel}</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="triggerAdapt(${s.id})" style="font-size:9px;padding:2px 6px;color:var(--accent-gold);border-color:var(--accent-gold)">↺ Re-adapt</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="deleteSelectors(${s.id})" style="font-size:9px;padding:2px 6px;color:#888;border-color:#444">× Clear</button>
|
||
</div>`;
|
||
} else {
|
||
aiAdaptHtml = `<button class="btn btn-ghost btn-sm" onclick="triggerAdapt(${s.id})" style="color:var(--accent-gold);border-color:var(--accent-gold)">🤖 Adapt</button>`;
|
||
}
|
||
return `
|
||
<tr>
|
||
<td class="drag-handle" draggable="true" data-id="${s.id}" data-type="site" ondragstart="dragStart(event)" ondragover="dragOver(event)" ondrop="dragDrop(event)" ondragend="dragEnd(event)">⋮⋮</td>
|
||
<td>
|
||
<strong style="color:var(--accent);cursor:pointer" title="Click to rename" onclick="inlineEditSite(${s.id}, 'name', this, ${JSON.stringify(escHtml(s.name))})">${escHtml(s.name)}</strong>
|
||
${pgsBadge} ${loginStatus}
|
||
</td>
|
||
<td>${modeLabel}</td>
|
||
<td style="font-family:var(--font-mono);font-size:10px;color:var(--text-mid);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" title="Click to edit URL" onclick="inlineEditSite(${s.id}, 'url_template', this, ${JSON.stringify(escHtml(s.url_template))})">
|
||
${escHtml(s.url_template)}
|
||
</td>
|
||
<td>${selDisplay}</td>
|
||
<td>
|
||
<span class="score-badge ${s.enabled ? 'score-pos' : 'score-zero'}">${s.enabled ? '● ACTIVE' : '○ OFF'}</span>
|
||
</td>
|
||
<td>${healthHtml}</td>
|
||
<td>${aiAdaptHtml}</td>
|
||
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
||
${loginBtn}
|
||
<button class="btn btn-secondary btn-sm" onclick="openEditSite(${s.id})" style="margin-right:2px">✏ Edit</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="toggleSite(${s.id}, ${s.enabled})">${s.enabled ? 'Disable' : 'Enable'}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteSite(${s.id})">× Del</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody></table>`;
|
||
}
|
||
|
||
/* Live mode badge while typing the URL */
|
||
function updateModeBadge(val) {
|
||
const badge = document.getElementById('mode-badge');
|
||
if (!val.trim()) { badge.style.display = 'none'; return; }
|
||
const isDirect = val.includes('{keyword}');
|
||
badge.style.display = 'inline';
|
||
badge.textContent = isDirect ? 'DIRECT' : 'HOMEPAGE';
|
||
badge.style.background = isDirect ? 'rgba(0,245,255,.15)' : 'rgba(0,255,136,.15)';
|
||
badge.style.color = isDirect ? 'var(--accent)' : 'var(--accent-green)';
|
||
badge.style.border = isDirect ? '1px solid rgba(0,245,255,.3)' : '1px solid rgba(0,255,136,.3)';
|
||
}
|
||
|
||
async function addSite() {
|
||
const name = document.getElementById('site-name').value.trim();
|
||
const template = document.getElementById('site-url').value.trim();
|
||
const selector = document.getElementById('site-selector').value.trim();
|
||
const maxPages = parseInt(document.getElementById('site-max-pages').value) || 1;
|
||
const requiresLogin = document.getElementById('site-requires-login').checked;
|
||
const loginUrl = document.getElementById('site-login-url').value.trim();
|
||
const loginCheck = document.getElementById('site-login-check').value.trim();
|
||
|
||
if (!name) { showAlert('Site name is required.', 'error'); return; }
|
||
if (!template) { showAlert('URL is required.', 'error'); return; }
|
||
|
||
const isDirect = template.includes('{keyword}');
|
||
const mode = isDirect ? 'DIRECT' : 'HOMEPAGE';
|
||
|
||
const r = await fetch(API + '/api/sites', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({
|
||
name, url_template: template, search_selector: selector,
|
||
max_pages: maxPages,
|
||
requires_login: requiresLogin, login_url: loginUrl,
|
||
login_check_selector: loginCheck, login_enabled: requiresLogin,
|
||
})
|
||
});
|
||
if (r.ok) {
|
||
document.getElementById('site-name').value = '';
|
||
document.getElementById('site-url').value = '';
|
||
document.getElementById('site-selector').value = '';
|
||
document.getElementById('site-max-pages').value = '1';
|
||
document.getElementById('site-requires-login').checked = false;
|
||
document.getElementById('site-login-url').value = '';
|
||
document.getElementById('site-login-check').value = '';
|
||
document.getElementById('mode-badge').style.display = 'none';
|
||
loadSites();
|
||
showAlert(`Site "${name}" registered in ${mode} mode${maxPages > 1 ? ` (${maxPages} pages)` : ''}.`);
|
||
addLog(`New target site: ${name} [${mode}]`, 'log-msg-hit');
|
||
} else {
|
||
const e = await r.json();
|
||
showAlert(e.error || 'Failed to add site.', 'error');
|
||
}
|
||
}
|
||
|
||
async function toggleSite(id, currentlyEnabled) {
|
||
await fetch(API + `/api/sites/${id}`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ enabled: currentlyEnabled ? 0 : 1 })
|
||
});
|
||
loadSites();
|
||
}
|
||
|
||
async function deleteSite(id) {
|
||
if (!confirm('Remove this target site?')) return;
|
||
await fetch(API + `/api/sites/${id}`, { method: 'DELETE' });
|
||
loadSites();
|
||
showAlert('Site removed.');
|
||
}
|
||
|
||
function openEditSite(id) {
|
||
const data = _lastSitesData && _lastSitesData.find(s => s.id === id);
|
||
if (!data) { showAlert('Reload sites first.', 'error'); return; }
|
||
document.getElementById('es-id').value = id;
|
||
document.getElementById('es-name').value = data.name;
|
||
document.getElementById('es-url').value = data.url_template;
|
||
document.getElementById('es-selector').value = data.search_selector || '';
|
||
document.getElementById('es-pages').value = data.max_pages || 1;
|
||
document.getElementById('es-login-req').checked = data.requires_login;
|
||
document.getElementById('es-login-en').checked = data.login_enabled;
|
||
document.getElementById('es-login-url').value = data.login_url || '';
|
||
document.getElementById('es-login-check').value= data.login_check_selector || '';
|
||
document.getElementById('edit-site-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeEditSite() {
|
||
document.getElementById('edit-site-modal').style.display = 'none';
|
||
}
|
||
|
||
async function saveEditSite() {
|
||
const id = parseInt(document.getElementById('es-id').value);
|
||
const payload = {
|
||
name: document.getElementById('es-name').value.trim(),
|
||
url_template: document.getElementById('es-url').value.trim(),
|
||
search_selector: document.getElementById('es-selector').value.trim(),
|
||
max_pages: parseInt(document.getElementById('es-pages').value) || 1,
|
||
requires_login: document.getElementById('es-login-req').checked,
|
||
login_enabled: document.getElementById('es-login-en').checked,
|
||
login_url: document.getElementById('es-login-url').value.trim(),
|
||
login_check_selector: document.getElementById('es-login-check').value.trim(),
|
||
};
|
||
if (!payload.name || !payload.url_template) { showAlert('Name and URL are required.', 'error'); return; }
|
||
const r = await fetch(API + `/api/sites/${id}`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (r.ok) {
|
||
closeEditSite();
|
||
loadSites();
|
||
showAlert('Site updated.');
|
||
addLog(`Site #${id} updated: ${payload.name}`, 'log-msg-ok');
|
||
} else {
|
||
showAlert('Failed to update site.', 'error');
|
||
}
|
||
}
|
||
|
||
async function inlineEditSite(id, field, cell, currentVal) {
|
||
if (cell.querySelector('input')) return;
|
||
const inp = document.createElement('input');
|
||
inp.type = 'text';
|
||
inp.value = currentVal;
|
||
const isUrl = field === 'url_template';
|
||
inp.style.cssText = `width:${isUrl?'280px':'140px'};background:var(--bg-card);border:1px solid var(--accent);color:var(--text-hi);padding:2px 6px;font-family:var(--font-mono);font-size:11px;border-radius:3px`;
|
||
const orig = cell.innerHTML;
|
||
cell.innerHTML = '';
|
||
cell.appendChild(inp);
|
||
inp.focus(); inp.select();
|
||
const save = async () => {
|
||
const val = inp.value.trim();
|
||
if (!val || val === currentVal) { cell.innerHTML = orig; return; }
|
||
const r = await fetch(API + `/api/sites/${id}`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ [field]: val })
|
||
});
|
||
if (r.ok) {
|
||
loadSites();
|
||
addLog(`Site #${id} ${field} updated`, 'log-msg-ok');
|
||
} else {
|
||
showAlert('Failed to update site.', 'error');
|
||
cell.innerHTML = orig;
|
||
}
|
||
};
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cell.innerHTML = orig; });
|
||
inp.addEventListener('blur', save);
|
||
}
|
||
|
||
/* ─── Currency Picker ────────────────────────────────────────────── */
|
||
const _ALL_CURRENCIES = [
|
||
{code:'', label:'— None (show raw prices)'},
|
||
{code:'USD', label:'USD — US Dollar'},
|
||
{code:'EUR', label:'EUR — Euro'},
|
||
{code:'GBP', label:'GBP — British Pound'},
|
||
{code:'CAD', label:'CAD — Canadian Dollar'},
|
||
{code:'AUD', label:'AUD — Australian Dollar'},
|
||
{code:'JPY', label:'JPY — Japanese Yen'},
|
||
{code:'CHF', label:'CHF — Swiss Franc'},
|
||
{code:'SEK', label:'SEK — Swedish Krona'},
|
||
{code:'NOK', label:'NOK — Norwegian Krone'},
|
||
{code:'DKK', label:'DKK — Danish Krone'},
|
||
{code:'NZD', label:'NZD — New Zealand Dollar'},
|
||
{code:'HKD', label:'HKD — Hong Kong Dollar'},
|
||
{code:'SGD', label:'SGD — Singapore Dollar'},
|
||
{code:'MXN', label:'MXN — Mexican Peso'},
|
||
{code:'BRL', label:'BRL — Brazilian Real'},
|
||
{code:'INR', label:'INR — Indian Rupee'},
|
||
{code:'KRW', label:'KRW — South Korean Won'},
|
||
{code:'CNY', label:'CNY — Chinese Yuan'},
|
||
{code:'ZAR', label:'ZAR — South African Rand'},
|
||
{code:'AED', label:'AED — UAE Dirham'},
|
||
{code:'IQD', label:'IQD — Iraqi Dinar'},
|
||
];
|
||
|
||
function showCurrencyDropdown(query) {
|
||
const dd = document.getElementById('currency-dropdown');
|
||
const q = (query || '').toUpperCase();
|
||
const matches = _ALL_CURRENCIES.filter(c => !q || c.code.includes(q) || c.label.toUpperCase().includes(q));
|
||
if (!matches.length) { dd.style.display = 'none'; return; }
|
||
dd.innerHTML = matches.map(c =>
|
||
`<div class="currency-opt" onmousedown="selectCurrency('${c.code}')">${c.code ? '<b>' + c.code + '</b> — ' + c.label.split('— ')[1] : c.label}</div>`
|
||
).join('');
|
||
dd.style.display = 'block';
|
||
}
|
||
|
||
function hideCurrencyDropdown() {
|
||
const dd = document.getElementById('currency-dropdown');
|
||
if (dd) dd.style.display = 'none';
|
||
}
|
||
|
||
function selectCurrency(code) {
|
||
document.getElementById('cfg-display-currency').value = code;
|
||
hideCurrencyDropdown();
|
||
}
|
||
|
||
/* ─── Settings ──────────────────────────────────────────────────── */
|
||
async function loadSettings() {
|
||
try {
|
||
const r = await fetch(API + '/api/config');
|
||
const d = await r.json();
|
||
if (d.telegram_token) document.getElementById('cfg-token').value = d.telegram_token;
|
||
if (d.telegram_chat_id) document.getElementById('cfg-chatid').value = d.telegram_chat_id;
|
||
if (d.timer) document.getElementById('cfg-timer').value = d.timer;
|
||
if (d.delay_launch !== undefined) document.getElementById('cfg-delay-launch').value = d.delay_launch || '0';
|
||
if (d.delay_post_search !== undefined) document.getElementById('cfg-delay-search').value = d.delay_post_search || '0';
|
||
if (d.delay_page_hold !== undefined) document.getElementById('cfg-delay-hold').value = d.delay_page_hold || '0';
|
||
if (d.delay_site_open !== undefined) document.getElementById('cfg-delay-site-open').value = d.delay_site_open || '0';
|
||
selectBrowser(d.browser_choice || 'auto');
|
||
selectHumanize(d.humanize_level || 'heavy');
|
||
setChannelsFromString(d.alert_channels || 'telegram');
|
||
// CAPTCHA
|
||
if (d.captcha_solver) document.getElementById('cfg-captcha-solver').value = d.captcha_solver;
|
||
if (d.captcha_api_key) document.getElementById('cfg-captcha-key').value = d.captcha_api_key;
|
||
// Discord
|
||
if (d.discord_webhook) document.getElementById('cfg-discord-webhook').value = d.discord_webhook;
|
||
// Email — Gmail only (old SMTP keys ignored in UI)
|
||
// Closing alerts
|
||
const closingEnabled = (d.closing_alert_enabled === 'true' || d.closing_alert_enabled === '1');
|
||
document.getElementById('cfg-closing-enabled').checked = closingEnabled;
|
||
document.getElementById('closing-enabled-label').textContent = closingEnabled ? 'ON' : 'OFF';
|
||
// Multi-interval schedule (replaces single threshold)
|
||
if (d.closing_alert_schedule !== undefined)
|
||
document.getElementById('cfg-closing-schedule').value = d.closing_alert_schedule || '30';
|
||
// N4: currency display
|
||
if (d.display_currency !== undefined) {
|
||
const dc = (d.display_currency || '').toUpperCase();
|
||
document.getElementById('cfg-display-currency').value = dc;
|
||
_displayCurrency = dc;
|
||
if (dc) _fetchFxRates(); // pre-load rates for the listings table
|
||
}
|
||
// Listing detail view preference
|
||
if (d.listing_detail_enabled !== undefined)
|
||
_listingDetailEnabled = (d.listing_detail_enabled !== 'false');
|
||
if (d.listing_detail_enabled !== undefined)
|
||
document.getElementById('cfg-listing-detail').checked = (d.listing_detail_enabled !== 'false');
|
||
// N1: proxy
|
||
const proxyEnabled = (d.proxy_enabled === 'true' || d.proxy_enabled === '1');
|
||
document.getElementById('cfg-proxy-enabled').checked = proxyEnabled;
|
||
document.getElementById('proxy-enabled-label').textContent = proxyEnabled ? 'ON' : 'OFF';
|
||
if (d.proxy_list !== undefined) document.getElementById('cfg-proxy-list').value = d.proxy_list || '';
|
||
// Gmail
|
||
if (d.gmail_address) document.getElementById('cfg-gmail-address').value = d.gmail_address;
|
||
if (d.gmail_app_password) document.getElementById('cfg-gmail-app-password').value = d.gmail_app_password;
|
||
if (d.email_to) document.getElementById('cfg-email-to').value = d.email_to;
|
||
// Auto-disable
|
||
if (d.site_auto_disable_after !== undefined) document.getElementById('cfg-auto-disable').value = d.site_auto_disable_after || '5';
|
||
// N16: AI filter
|
||
const aiEnabled = (d.ai_filter_enabled === 'true' || d.ai_filter_enabled === '1');
|
||
document.getElementById('cfg-ai-enabled').checked = aiEnabled;
|
||
document.getElementById('ai-enabled-label').textContent = aiEnabled ? 'ON' : 'OFF';
|
||
if (d.ai_provider) { document.getElementById('cfg-ai-provider').value = d.ai_provider; onAiProviderChange(); }
|
||
if (d.ai_model) document.getElementById('cfg-ai-model').value = d.ai_model;
|
||
if (d.ai_api_key) document.getElementById('cfg-ai-api-key').value = d.ai_api_key;
|
||
if (d.ai_base_url) document.getElementById('cfg-ai-base-url').value = d.ai_base_url;
|
||
// AI Debug mode
|
||
const aiDebug = (d.ai_debug === 'true' || d.ai_debug === '1');
|
||
document.getElementById('cfg-ai-debug').checked = aiDebug;
|
||
document.getElementById('ai-debug-label').textContent = aiDebug ? 'ON' : 'OFF';
|
||
// N17: Auto-Adapter toggle
|
||
const autoAdapt = (d.auto_adapt_enabled === 'true' || d.auto_adapt_enabled === '1');
|
||
document.getElementById('cfg-auto-adapt-enabled').checked = autoAdapt;
|
||
document.getElementById('auto-adapt-label').textContent = autoAdapt ? 'ON' : 'OFF';
|
||
const incog = (d.incognito_mode === 'true' || d.incognito_mode === '1');
|
||
document.getElementById('cfg-incognito').checked = incog;
|
||
document.getElementById('incognito-label').textContent = incog ? 'ON — Incognito Active' : 'OFF';
|
||
const visible = (d.show_browser === 'true' || d.show_browser === '1');
|
||
document.getElementById('cfg-headless').checked = visible;
|
||
document.getElementById('headless-label').textContent = visible
|
||
? 'VISIBLE — Browser window open (debug mode)'
|
||
: 'HEADLESS — Browser hidden';
|
||
} catch(e) {}
|
||
}
|
||
|
||
function onAiProviderChange() {
|
||
const val = document.getElementById('cfg-ai-provider').value;
|
||
document.getElementById('ai-groq-section').style.display = (val === 'groq') ? '' : 'none';
|
||
document.getElementById('ai-ollama-section').style.display = (val === 'ollama') ? '' : 'none';
|
||
// Set sensible default models when switching provider
|
||
const modelEl = document.getElementById('cfg-ai-model');
|
||
if (!modelEl.value || modelEl.value === 'llama-3.3-70b-versatile' || modelEl.value === 'llama3.2:3b') {
|
||
if (val === 'groq') modelEl.value = 'llama-3.3-70b-versatile';
|
||
if (val === 'ollama') modelEl.value = 'llama3.2:3b';
|
||
}
|
||
}
|
||
|
||
function selectBrowser(val) {
|
||
document.querySelectorAll('.browser-card').forEach(card => {
|
||
const isMatch = card.dataset.value === val;
|
||
card.classList.toggle('selected', isMatch);
|
||
card.querySelector('input[type=radio]').checked = isMatch;
|
||
});
|
||
}
|
||
|
||
const _humanizeDescs = {
|
||
raw: `⚡ RAW — No simulation. Fastest scraping, zero bot protection. Use only if sites don't block you.`,
|
||
low: `🐇 LOW — Quick mouse move + one scroll pass. Minimal overhead, basic believability.`,
|
||
medium: `🧍 MEDIUM — Mouse + multi-step scroll + idle pauses. Good balance of speed and stealth.`,
|
||
heavy: `🐢 HEAVY — Full simulation: bezier mouse paths, read-rhythm scrolling, char-by-char typing with typos, homepage pre-visit. Hardest to detect.`,
|
||
};
|
||
|
||
async function restoreDatabase(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
const status = document.getElementById('backup-status');
|
||
status.style.display = 'block';
|
||
|
||
if (!file.name.endsWith('.db')) {
|
||
status.style.color = '#ff4444';
|
||
status.textContent = '✗ File must be a .db file (from a previous Ghost Node backup).';
|
||
input.value = '';
|
||
return;
|
||
}
|
||
|
||
const confirmed = confirm(
|
||
`Restore database from "${file.name}"?\n\n` +
|
||
`Your current database will be auto-saved as a backup before being replaced.\n` +
|
||
`Ghost Node will restart automatically after restore.\n\n` +
|
||
`Proceed?`
|
||
);
|
||
if (!confirmed) { input.value = ''; return; }
|
||
|
||
status.style.color = 'var(--accent-gold)';
|
||
status.textContent = `⏳ Uploading ${file.name} (${(file.size/1024).toFixed(1)} KB)…`;
|
||
|
||
try {
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
const r = await fetch(API + '/api/backup/restore', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
body: arrayBuffer,
|
||
});
|
||
const d = await r.json();
|
||
if (r.ok) {
|
||
status.style.color = 'var(--accent-green)';
|
||
status.textContent = `✓ ${d.message}`;
|
||
// Auto-refresh after 5s when server restarts
|
||
setTimeout(() => { window.location.reload(); }, 5500);
|
||
} else {
|
||
status.style.color = '#ff4444';
|
||
status.textContent = `✗ ${d.error || 'Restore failed.'}`;
|
||
}
|
||
} catch (exc) {
|
||
status.style.color = '#ff4444';
|
||
status.textContent = `✗ Network error: ${exc.message}`;
|
||
}
|
||
input.value = '';
|
||
}
|
||
|
||
async function triggerLogin(siteId) {
|
||
const r = await fetch(API + `/api/sites/${siteId}/login`, { method: 'POST' });
|
||
const d = await r.json();
|
||
if (r.ok) {
|
||
showAlert(d.message || 'Browser opening — log in and close it when done.', 'success');
|
||
} else {
|
||
showAlert(d.error || 'Login trigger failed.', 'error');
|
||
}
|
||
}
|
||
|
||
/* ─── N17: Auto-Adapter ──────────────────────────────────────────── */
|
||
async function triggerAdapt(siteId) {
|
||
const btn = event && event.target;
|
||
if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
|
||
const r = await fetch(API + `/api/sites/${siteId}/adapt`, { method: 'POST' });
|
||
const d = await r.json();
|
||
if (btn) { btn.disabled = false; btn.textContent = btn.dataset.label || '🤖 Adapt'; }
|
||
if (r.ok) {
|
||
showAlert(d.message || 'AI adaptation started — reload Sites in ~30s to see the result.', 'success');
|
||
addLog(`🤖 Auto-Adapt started for site #${siteId}`, 'log-msg-info');
|
||
} else {
|
||
showAlert(d.error || 'Adapt failed.', 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteSelectors(siteId) {
|
||
if (!confirm('Clear AI selectors for this site? It will fall back to the universal extractor until re-adapted.')) return;
|
||
const r = await fetch(API + `/api/sites/${siteId}/selectors`, { method: 'DELETE' });
|
||
const d = await r.json();
|
||
if (r.ok) {
|
||
showAlert(d.message || 'Selectors cleared.', 'success');
|
||
loadSites();
|
||
} else {
|
||
showAlert(d.error || 'Delete failed.', 'error');
|
||
}
|
||
}
|
||
|
||
/* ─── Alert Channel multi-select ────────────────────────────────── */
|
||
let _activeChannels = new Set(['telegram']);
|
||
|
||
function toggleChannel(ch) {
|
||
if (_activeChannels.has(ch)) {
|
||
if (_activeChannels.size === 1) { showAlert('At least one channel must be active.', 'error'); return; }
|
||
_activeChannels.delete(ch);
|
||
} else {
|
||
_activeChannels.add(ch);
|
||
}
|
||
_refreshChannelUI();
|
||
}
|
||
|
||
function _refreshChannelUI() {
|
||
['telegram','discord','email'].forEach(ch => {
|
||
const btn = document.getElementById('ch-' + ch);
|
||
if (btn) btn.classList.toggle('active', _activeChannels.has(ch));
|
||
});
|
||
document.getElementById('discord-section').style.display = _activeChannels.has('discord') ? 'block' : 'none';
|
||
document.getElementById('email-section').style.display = _activeChannels.has('email') ? 'block' : 'none';
|
||
}
|
||
|
||
function setChannelsFromString(csvStr) {
|
||
_activeChannels = new Set((csvStr || 'telegram').split(',').map(s => s.trim()).filter(Boolean));
|
||
_refreshChannelUI();
|
||
}
|
||
|
||
function selectHumanize(val) {
|
||
document.querySelectorAll('.humanize-btn').forEach(b => {
|
||
b.className = b.className.replace(/\bactive-\S+/g, '').trim();
|
||
if (b.dataset.val === val) b.classList.add('active-' + val);
|
||
});
|
||
const desc = document.getElementById('humanize-desc');
|
||
if (desc) desc.textContent = _humanizeDescs[val] || '';
|
||
let inp = document.getElementById('cfg-humanize');
|
||
if (!inp) {
|
||
inp = document.createElement('input');
|
||
inp.type = 'hidden'; inp.id = 'cfg-humanize';
|
||
document.body.appendChild(inp);
|
||
}
|
||
inp.value = val;
|
||
}
|
||
|
||
// Wire click events on the browser cards
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
document.querySelectorAll('.browser-card').forEach(card => {
|
||
card.addEventListener('click', () => selectBrowser(card.dataset.value));
|
||
});
|
||
const tog = document.getElementById('cfg-incognito');
|
||
if (tog) {
|
||
tog.addEventListener('change', () => {
|
||
document.getElementById('incognito-label').textContent =
|
||
tog.checked ? 'ON — Incognito Active' : 'OFF';
|
||
});
|
||
}
|
||
const togH = document.getElementById('cfg-headless');
|
||
if (togH) {
|
||
togH.addEventListener('change', () => {
|
||
document.getElementById('headless-label').textContent = togH.checked
|
||
? 'VISIBLE — Browser window open (debug mode)'
|
||
: 'HEADLESS — Browser hidden';
|
||
});
|
||
}
|
||
});
|
||
|
||
async function saveSettings() {
|
||
const token = document.getElementById('cfg-token').value.trim();
|
||
const chatid = document.getElementById('cfg-chatid').value.trim();
|
||
const timer = document.getElementById('cfg-timer').value.trim() || '120';
|
||
const delayLaunch = document.getElementById('cfg-delay-launch').value.trim() || '0';
|
||
const delaySearch = document.getElementById('cfg-delay-search').value.trim() || '0';
|
||
const delayHold = document.getElementById('cfg-delay-hold').value.trim() || '0';
|
||
const siteOpenDelay = document.getElementById('cfg-delay-site-open').value.trim() || '0';
|
||
|
||
const selectedCard = document.querySelector('.browser-card.selected');
|
||
const incognito = document.getElementById('cfg-incognito').checked ? 'true' : 'false';
|
||
const showBrowser = document.getElementById('cfg-headless').checked ? 'true' : 'false';
|
||
const browser = selectedCard ? selectedCard.dataset.value : 'auto';
|
||
|
||
const payload = {
|
||
telegram_token: token,
|
||
telegram_chat_id: chatid,
|
||
timer: timer,
|
||
delay_launch: delayLaunch,
|
||
delay_post_search: delaySearch,
|
||
delay_page_hold: delayHold,
|
||
delay_site_open: siteOpenDelay,
|
||
browser_choice: browser,
|
||
humanize_level: (document.getElementById('cfg-humanize') || {}).value || 'heavy',
|
||
incognito_mode: incognito,
|
||
show_browser: showBrowser,
|
||
// N2 CAPTCHA
|
||
captcha_solver: document.getElementById('cfg-captcha-solver').value,
|
||
captcha_api_key: document.getElementById('cfg-captcha-key').value,
|
||
// N9 closing alerts
|
||
closing_alert_enabled: document.getElementById('cfg-closing-enabled').checked ? 'true' : 'false',
|
||
closing_alert_schedule: document.getElementById('cfg-closing-schedule').value || '30',
|
||
// N10 alert channels
|
||
alert_channels: Array.from(_activeChannels).join(','),
|
||
discord_webhook: document.getElementById('cfg-discord-webhook').value,
|
||
// Gmail (simplified email)
|
||
gmail_address: document.getElementById('cfg-gmail-address').value.trim(),
|
||
gmail_app_password:document.getElementById('cfg-gmail-app-password').value.trim(),
|
||
email_to: document.getElementById('cfg-email-to').value.trim(),
|
||
// N4 currency display
|
||
display_currency: (document.getElementById('cfg-display-currency').value || '').trim().toUpperCase(),
|
||
// N1 proxy rotation
|
||
proxy_enabled: document.getElementById('cfg-proxy-enabled').checked ? 'true' : 'false',
|
||
proxy_list: document.getElementById('cfg-proxy-list').value,
|
||
// N13 auto-disable
|
||
site_auto_disable_after: document.getElementById('cfg-auto-disable').value || '5',
|
||
// N16 AI filter
|
||
ai_filter_enabled: document.getElementById('cfg-ai-enabled').checked ? 'true' : 'false',
|
||
ai_provider: document.getElementById('cfg-ai-provider').value,
|
||
ai_model: document.getElementById('cfg-ai-model').value.trim() || '',
|
||
ai_api_key: document.getElementById('cfg-ai-api-key').value.trim(),
|
||
ai_base_url: document.getElementById('cfg-ai-base-url').value.trim() || 'http://localhost:11434',
|
||
ai_debug: document.getElementById('cfg-ai-debug').checked ? 'true' : 'false',
|
||
// N17 Auto-Adapter
|
||
auto_adapt_enabled: document.getElementById('cfg-auto-adapt-enabled').checked ? 'true' : 'false',
|
||
// Dashboard preferences
|
||
listing_detail_enabled: document.getElementById('cfg-listing-detail').checked ? 'true' : 'false',
|
||
};
|
||
|
||
const r = await fetch(API + '/api/config', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (r.ok) {
|
||
showAlert('Configuration saved to SSD.');
|
||
addLog(`Settings updated — Telegram C2, timer & browser (${browser}) saved.`, 'log-msg-ok');
|
||
// Refresh display currency cache
|
||
_displayCurrency = payload.display_currency || '';
|
||
if (_displayCurrency) _fetchFxRates();
|
||
// Update listing detail preference
|
||
_listingDetailEnabled = (payload.listing_detail_enabled === 'true');
|
||
} else {
|
||
showAlert('Failed to save configuration.', 'error');
|
||
}
|
||
}
|
||
|
||
/* ─── Engine Controls ───────────────────────────────────────────── */
|
||
async function enginePause() {
|
||
await fetch(API + '/api/engine/pause', { method: 'POST' });
|
||
showAlert('Engine paused.');
|
||
addLog('Engine paused by operator.', 'log-msg-warn');
|
||
}
|
||
async function engineResume() {
|
||
await fetch(API + '/api/engine/resume', { method: 'POST' });
|
||
showAlert('Engine resumed.');
|
||
addLog('Engine resumed.', 'log-msg-ok');
|
||
}
|
||
|
||
async function engineRestart() {
|
||
if (!confirm('Restart Ghost Node?\nThe current process will exit and a new one will start.\nThe dashboard will reconnect automatically.')) return;
|
||
|
||
const btn = document.getElementById('btn-restart');
|
||
const origText = btn ? btn.textContent : '';
|
||
if (btn) { btn.disabled = true; btn.textContent = '⏳ RESTARTING…'; }
|
||
|
||
addLog('Restart requested — sending signal to server…', 'log-msg-warn');
|
||
|
||
// Step 1 — fire the restart request
|
||
// We expect the fetch to either:
|
||
// (a) resolve with 200 before the process exits, or
|
||
// (b) reject because the server closed the connection mid-response.
|
||
// Either outcome is fine — we move straight to polling.
|
||
try {
|
||
await fetch(API + '/api/engine/restart', { method: 'POST' });
|
||
} catch (_) {
|
||
// Connection closed before response arrived — process already restarting
|
||
}
|
||
|
||
addLog('Process restarting — polling for reconnection…', 'log-msg-warn');
|
||
|
||
// Step 2 — wait 2 seconds for the new process to bind port 8000
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
|
||
// Step 3 — poll /api/stats every 500 ms until the server responds
|
||
let attempts = 0;
|
||
const MAX_ATTEMPTS = 30; // 15 seconds total
|
||
|
||
const poll = setInterval(async () => {
|
||
attempts++;
|
||
try {
|
||
const r = await fetch(API + '/api/stats', { cache: 'no-store' });
|
||
if (r.ok) {
|
||
clearInterval(poll);
|
||
if (btn) { btn.disabled = false; btn.textContent = origText; }
|
||
showAlert('Ghost Node restarted successfully — back online!');
|
||
addLog('Ghost Node back online after restart.', 'log-msg-hit');
|
||
loadStats(); // refresh the status panel immediately
|
||
}
|
||
} catch (_) {
|
||
// Server not up yet — keep polling
|
||
}
|
||
|
||
if (attempts >= MAX_ATTEMPTS) {
|
||
clearInterval(poll);
|
||
if (btn) { btn.disabled = false; btn.textContent = origText; }
|
||
showAlert('Restart is taking longer than expected — check the terminal.', 'error');
|
||
addLog('Restart timeout — server did not respond in 15s.', 'log-msg-warn');
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
|
||
|
||
async function engineKill() {
|
||
if (!confirm(
|
||
'KILL Ghost Node?\n\n' +
|
||
'This will immediately terminate the entire process.\n' +
|
||
'The dashboard will go offline and will NOT reconnect.\n' +
|
||
'You will need to restart manually from the terminal.\n\n' +
|
||
'Are you sure?'
|
||
)) return;
|
||
|
||
const btn = document.getElementById('btn-kill');
|
||
if (btn) { btn.disabled = true; btn.textContent = '💀 KILLING…'; }
|
||
addLog('KILL signal sent — process will terminate immediately.', 'log-msg-warn');
|
||
|
||
try {
|
||
await fetch(API + '/api/engine/kill', { method: 'POST' });
|
||
} catch (_) {
|
||
// Process died before responding — expected
|
||
}
|
||
|
||
// Dashboard goes dark — process is gone
|
||
addLog('Ghost Node process killed. Refresh this page will show offline.', 'log-msg-warn');
|
||
showAlert('Ghost Node has been killed. Restart from the terminal to bring it back online.', 'error');
|
||
|
||
// Grey out all control buttons — nothing to talk to anymore
|
||
document.querySelectorAll('.btn-control').forEach(b => {
|
||
b.disabled = true;
|
||
b.style.opacity = '0.35';
|
||
});
|
||
if (btn) { btn.textContent = '☠ KILLED'; }
|
||
}
|
||
|
||
/* ─── Helpers ───────────────────────────────────────────────────── */
|
||
// Show price as "50.00 USD" format.
|
||
// price_raw is already stored as "50.00 USD" from the scraper.
|
||
// Falls back to raw float if price_raw is missing (legacy rows).
|
||
function fmtPrice(l) {
|
||
if (l.price_raw && l.price_raw.trim()) return escHtml(l.price_raw.trim());
|
||
if (l.price != null) {
|
||
const cur = (l.currency && l.currency.trim()) ? l.currency.trim() : 'USD';
|
||
return Number(l.price).toFixed(2) + ' ' + cur;
|
||
}
|
||
return '—';
|
||
}
|
||
|
||
// Live countdown — computes remaining time from time_left_mins + timestamp
|
||
// Falls back to static time_left string if time_left_mins not available.
|
||
function calcLiveTimeLeft(l) {
|
||
if (l.time_left_mins == null) return l.time_left || '';
|
||
const capturedAt = l.timestamp ? new Date(l.timestamp).getTime() : null;
|
||
if (!capturedAt) return l.time_left || '';
|
||
const elapsedMins = (Date.now() - capturedAt) / 60000;
|
||
const remainMins = Math.max(0, l.time_left_mins - elapsedMins);
|
||
if (remainMins <= 0) return 'CLOSED';
|
||
const d = Math.floor(remainMins / 1440);
|
||
const h = Math.floor((remainMins % 1440) / 60);
|
||
const m = Math.floor(remainMins % 60);
|
||
const parts = [];
|
||
if (d) parts.push(d + 'd');
|
||
if (h) parts.push(h + 'h');
|
||
if (m || (!d && !h)) parts.push(m + 'm');
|
||
return parts.join(' ');
|
||
}
|
||
|
||
function fmtTimeLeft(l) {
|
||
const t = calcLiveTimeLeft(l);
|
||
if (!t || t === '—') return '—';
|
||
return escHtml(t);
|
||
}
|
||
|
||
// Urgency colour: red < 1h, orange < 6h, gold otherwise
|
||
function timeLeftColor(l) {
|
||
if (l.time_left_mins == null) return 'var(--accent-gold)';
|
||
const capturedAt = l.timestamp ? new Date(l.timestamp).getTime() : null;
|
||
if (!capturedAt) return 'var(--accent-gold)';
|
||
const remainMins = Math.max(0, l.time_left_mins - (Date.now() - capturedAt) / 60000);
|
||
if (remainMins <= 0) return 'var(--text-dim)';
|
||
if (remainMins <= 60) return '#ff4444';
|
||
if (remainMins <= 360) return '#ff9900';
|
||
return 'var(--accent-gold)';
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str).replace(/[&<>"']/g, c => ({
|
||
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||
}[c]));
|
||
}
|
||
function fmtDate(iso) {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso);
|
||
return d.toLocaleDateString('en-GB', { day:'2-digit', month:'short' }) + ' '
|
||
+ d.toTimeString().slice(0,5);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
AI DEBUG LOG — live panel for watching AI prompts & responses
|
||
═══════════════════════════════════════════════════════════════════ */
|
||
|
||
let _aiDebugPollTimer = null;
|
||
let _aiDebugLastId = 0;
|
||
let _aiDebugFilter = 'all'; // 'all' | 'filter' | 'adapt' | 'error'
|
||
let _aiDebugAllEntries = []; // full local cache (newest last)
|
||
|
||
// ── Counters ─────────────────────────────────────────────────────────
|
||
let _aiDbgTotalCalls = 0;
|
||
let _aiDbgTokP = 0;
|
||
let _aiDbgTokC = 0;
|
||
let _aiDbgYes = 0;
|
||
let _aiDbgNo = 0;
|
||
let _aiDbgErrors = 0;
|
||
|
||
function startAiDebugPolling() {
|
||
stopAiDebugPolling();
|
||
_fetchAiDebugLog(true); // immediate first fetch
|
||
_aiDebugPollTimer = setInterval(() => _fetchAiDebugLog(false), 2000);
|
||
document.getElementById('aidebug-live-dot').style.background = 'var(--accent-green)';
|
||
document.getElementById('aidebug-live-dot').style.boxShadow = '0 0 6px var(--accent-green)';
|
||
document.getElementById('aidebug-live-label').textContent = 'LIVE';
|
||
document.getElementById('aidebug-live-label').style.color = 'var(--accent-green)';
|
||
}
|
||
|
||
function stopAiDebugPolling() {
|
||
if (_aiDebugPollTimer) { clearInterval(_aiDebugPollTimer); _aiDebugPollTimer = null; }
|
||
const dot = document.getElementById('aidebug-live-dot');
|
||
if (dot) {
|
||
dot.style.background = '#444';
|
||
dot.style.boxShadow = 'none';
|
||
document.getElementById('aidebug-live-label').textContent = 'POLLING OFF';
|
||
document.getElementById('aidebug-live-label').style.color = 'var(--text-dim)';
|
||
}
|
||
}
|
||
|
||
async function _fetchAiDebugLog(force) {
|
||
if (force) { _aiDebugLastId = 0; document.getElementById('aidebug-log').innerHTML = ''; }
|
||
try {
|
||
const since = force ? 0 : _aiDebugLastId;
|
||
const r = await fetch(API + `/api/ai/debug/log?limit=300&since_id=${since}`);
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
|
||
// Warn if debug is off
|
||
document.getElementById('aidebug-off-warn').style.display = d.debug_enabled ? 'none' : 'block';
|
||
|
||
if (!d.entries || d.entries.length === 0) {
|
||
if (force) _renderAiDebugLog([]);
|
||
return;
|
||
}
|
||
|
||
if (force) {
|
||
_aiDebugAllEntries = d.entries;
|
||
// Recalculate counters from scratch
|
||
_aiDbgTotalCalls = _aiDbgTokP = _aiDbgTokC = _aiDbgYes = _aiDbgNo = _aiDbgErrors = 0;
|
||
} else {
|
||
_aiDebugAllEntries = _aiDebugAllEntries.concat(d.entries);
|
||
}
|
||
|
||
// Update counters from new entries
|
||
for (const e of d.entries) {
|
||
if (e.direction === 'request') _aiDbgTotalCalls++;
|
||
if (e.direction === 'response') {
|
||
_aiDbgTokP += (e.tokens_prompt || 0);
|
||
_aiDbgTokC += (e.tokens_completion || 0);
|
||
if (e.verdict === 'YES') _aiDbgYes++;
|
||
if (e.verdict === 'NO') _aiDbgNo++;
|
||
}
|
||
if (e.direction === 'error') _aiDbgErrors++;
|
||
}
|
||
|
||
// Track highest id for incremental polling
|
||
const maxId = Math.max(...d.entries.map(e => e.id || 0));
|
||
if (maxId > 0) _aiDebugLastId = maxId;
|
||
|
||
_updateAiDebugStats();
|
||
_renderAiDebugLog(force ? _aiDebugAllEntries : null);
|
||
} catch(e) { /* network error — silently skip */ }
|
||
}
|
||
|
||
function _updateAiDebugStats() {
|
||
document.getElementById('aidebug-stat-calls').textContent = _aiDbgTotalCalls.toLocaleString();
|
||
document.getElementById('aidebug-stat-tokens-p').textContent = _aiDbgTokP.toLocaleString();
|
||
document.getElementById('aidebug-stat-tokens-c').textContent = _aiDbgTokC.toLocaleString();
|
||
document.getElementById('aidebug-stat-yes').textContent = _aiDbgYes.toLocaleString();
|
||
document.getElementById('aidebug-stat-no').textContent = _aiDbgNo.toLocaleString();
|
||
document.getElementById('aidebug-stat-errors').textContent = _aiDbgErrors.toLocaleString();
|
||
}
|
||
|
||
function _renderAiDebugLog(entries) {
|
||
const container = document.getElementById('aidebug-log');
|
||
const empty = document.getElementById('aidebug-empty');
|
||
|
||
// If entries is null, we're in incremental mode — append only new cards
|
||
const toRender = entries !== null ? entries : _aiDebugAllEntries.slice(-(_aiDebugAllEntries.length));
|
||
const filtered = _applyAiFilter(toRender);
|
||
|
||
if (filtered.length === 0) {
|
||
empty.style.display = 'flex';
|
||
if (entries !== null) container.innerHTML = '';
|
||
container.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
empty.style.display = 'none';
|
||
|
||
if (entries !== null) {
|
||
// Full re-render
|
||
container.innerHTML = '';
|
||
for (const e of [...filtered].reverse()) { // newest first
|
||
container.appendChild(_buildAiDebugCard(e));
|
||
}
|
||
} else {
|
||
// Incremental: prepend new cards (new entries come at end of array, newest first on screen)
|
||
const latest = _applyAiFilter(_aiDebugAllEntries).slice(-10); // last 10 new
|
||
for (const e of [...latest].reverse()) {
|
||
const existing = document.getElementById(`aidl-${e.id}`);
|
||
if (!existing) container.insertBefore(_buildAiDebugCard(e), container.firstChild);
|
||
}
|
||
}
|
||
}
|
||
|
||
function _applyAiFilter(entries) {
|
||
if (_aiDebugFilter === 'all') return entries;
|
||
if (_aiDebugFilter === 'error') return entries.filter(e => e.direction === 'error');
|
||
return entries.filter(e => e.call_type === _aiDebugFilter);
|
||
}
|
||
|
||
function _buildAiDebugCard(e) {
|
||
const div = document.createElement('div');
|
||
div.id = `aidl-${e.id}`;
|
||
div.className = `aidl-card ${e.direction}` +
|
||
(e.verdict === 'YES' ? ' verdict-yes' : e.verdict === 'NO' ? ' verdict-no' : '');
|
||
|
||
const dirLabel = { request: '→ SENT', response: '← RECEIVED', error: '⚠ ERROR' }[e.direction] || e.direction;
|
||
const dirClass = { request: 'req', response: 'resp', error: 'err' }[e.direction] || 'req';
|
||
const typeClass = e.call_type === 'adapt' ? 'adapt' : 'filter';
|
||
const typeLabel = e.call_type === 'adapt' ? '🤖 AUTO-ADAPT' : '🔍 AI FILTER';
|
||
|
||
const ts = e.ts ? new Date(e.ts).toLocaleTimeString() : '';
|
||
const model = e.model ? `<span style="color:var(--text-dim)">${escHtml(e.model)}</span>` : '';
|
||
|
||
// Meta line
|
||
let meta = '';
|
||
if (e.direction === 'response') {
|
||
const tokStr = (e.tokens_prompt != null && e.tokens_completion != null)
|
||
? `<span style="color:#888;margin-left:8px">tokens: <b style="color:var(--accent)">${e.tokens_prompt}</b> prompt + <b style="color:var(--accent-green)">${e.tokens_completion}</b> completion</span>`
|
||
: '';
|
||
const verdict = e.verdict === 'YES'
|
||
? `<span style="color:var(--accent-green);font-weight:700;margin-left:8px">✅ YES</span>`
|
||
: e.verdict === 'NO'
|
||
? `<span style="color:#ff4444;font-weight:700;margin-left:8px">❌ NO</span>`
|
||
: '';
|
||
meta = tokStr + verdict;
|
||
}
|
||
|
||
// Context info (title for filter, site for adapt)
|
||
let ctx = '';
|
||
if (e.title) ctx = `<span style="color:var(--accent-gold);margin-left:8px" title="Lot title">📦 ${escHtml(e.title.slice(0, 60))}${e.title.length > 60 ? '…' : ''}</span>`;
|
||
if (e.site) ctx += `<span style="color:#88aaff;margin-left:8px" title="Site">🌐 ${escHtml(e.site)}</span>`;
|
||
|
||
const isLong = (e.content || '').length > 300;
|
||
const bodyId = `aidl-body-${e.id}`;
|
||
|
||
div.innerHTML = `
|
||
<div class="aidl-header">
|
||
<span class="aidl-badge ${dirClass}">${dirLabel}</span>
|
||
<span class="aidl-badge ${typeClass}">${typeLabel}</span>
|
||
${model}
|
||
<span style="color:var(--text-dim);font-size:10px;margin-left:auto">${ts}</span>
|
||
${meta}
|
||
${ctx}
|
||
</div>
|
||
<div class="aidl-body ${isLong ? 'collapsed' : ''}" id="${bodyId}">${escHtml(e.content || '')}</div>
|
||
${isLong ? `<button class="aidl-expand" onclick="toggleAiCard('${bodyId}', this)">▼ Show full (${(e.content||'').length} chars)</button>` : ''}
|
||
`;
|
||
return div;
|
||
}
|
||
|
||
function toggleAiCard(bodyId, btn) {
|
||
const body = document.getElementById(bodyId);
|
||
if (!body) return;
|
||
const collapsed = body.classList.toggle('collapsed');
|
||
btn.textContent = collapsed ? `▼ Show full` : `▲ Collapse`;
|
||
}
|
||
|
||
function setAiFilter(f) {
|
||
_aiDebugFilter = f;
|
||
document.querySelectorAll('.aidebug-filter').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.filter === f);
|
||
});
|
||
_renderAiDebugLog(_aiDebugAllEntries);
|
||
}
|
||
|
||
function filterAiLog() {
|
||
const q = (document.getElementById('aidl-search')?.value || '').toLowerCase();
|
||
document.querySelectorAll('#aidebug-log .aidl-card').forEach(card => {
|
||
const text = card.textContent.toLowerCase();
|
||
card.style.display = (!q || text.includes(q)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function clearAiDebugLog() {
|
||
if (!confirm('Clear the AI debug log? This only clears the in-memory buffer — it does not affect your listings or settings.')) return;
|
||
await fetch(API + '/api/ai/debug/log', { method: 'DELETE' });
|
||
_aiDebugAllEntries = [];
|
||
_aiDebugLastId = 0;
|
||
_aiDbgTotalCalls = _aiDbgTokP = _aiDbgTokC = _aiDbgYes = _aiDbgNo = _aiDbgErrors = 0;
|
||
_updateAiDebugStats();
|
||
_renderAiDebugLog([]);
|
||
showAlert('AI debug log cleared.', 'success');
|
||
}
|
||
</script>
|
||
|
||
<!-- ═══ Listing Detail Modal ════════════════════════════════════════════════ -->
|
||
<div id="listing-detail-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.8);align-items:center;justify-content:center" onclick="if(event.target===this)closeListingDetail()">
|
||
<div style="background:var(--bg-panel);border:1px solid var(--accent);border-radius:8px;padding:28px 32px;min-width:540px;max-width:760px;width:92vw;max-height:88vh;overflow-y:auto">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:18px">
|
||
<div style="font-family:var(--font-mono);color:var(--accent);font-size:13px;letter-spacing:2px">📦 LISTING DETAIL</div>
|
||
<button class="btn btn-sm" onclick="closeListingDetail()" style="font-size:16px;padding:2px 10px">×</button>
|
||
</div>
|
||
<div style="font-size:15px;color:var(--text-hi);font-weight:600;margin-bottom:16px;line-height:1.4" id="ld-title"></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px">
|
||
<div class="bg-card" style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">PRICE</div>
|
||
<div style="font-family:var(--font-mono);font-size:14px;color:var(--accent-gold);font-weight:700" id="ld-price"></div>
|
||
</div>
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">TIME LEFT</div>
|
||
<div style="font-family:var(--font-mono);font-size:13px;color:var(--accent-gold)" id="ld-time-left"></div>
|
||
</div>
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">LOCATION</div>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi)" id="ld-location"></div>
|
||
</div>
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">SITE</div>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi)" id="ld-site"></div>
|
||
</div>
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">KEYWORD</div>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent)" id="ld-keyword"></div>
|
||
</div>
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">SCORE</div>
|
||
<div style="font-family:var(--font-mono);font-size:13px;color:var(--accent-gold);font-weight:700" id="ld-score"></div>
|
||
</div>
|
||
</div>
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px;margin-bottom:14px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">AI VERDICT</div>
|
||
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent-green)" id="ld-ai"></div>
|
||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-mid);margin-top:4px;font-style:italic" id="ld-ai-reason"></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px">
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">CAPTURED</div>
|
||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-mid)" id="ld-captured"></div>
|
||
</div>
|
||
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">LAST REFRESHED</div>
|
||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-mid)" id="ld-refreshed"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Image gallery — hidden when no images captured -->
|
||
<div id="ld-images-wrap" style="margin-bottom:18px">
|
||
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:8px">LOT IMAGES</div>
|
||
<div id="ld-images" style="display:flex;flex-wrap:wrap;gap:8px"></div>
|
||
</div>
|
||
<a id="ld-link" href="#" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="display:inline-block;text-decoration:none">↗ Open Listing</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Edit Site Modal ═════════════════════════════════════════════════════ -->
|
||
<div id="edit-site-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.8);align-items:center;justify-content:center">
|
||
<div style="background:var(--bg-panel);border:1px solid var(--accent);border-radius:8px;padding:28px 32px;min-width:560px;max-width:720px;width:92vw;max-height:90vh;overflow-y:auto">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||
<div style="font-family:var(--font-mono);color:var(--accent);font-size:13px;letter-spacing:2px">✏ EDIT SITE</div>
|
||
<button class="btn btn-sm" onclick="closeEditSite()" style="font-size:16px;padding:2px 10px">×</button>
|
||
</div>
|
||
<input type="hidden" id="es-id">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SITE NAME</label>
|
||
<input id="es-name" type="text" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
</div>
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SEARCH SELECTOR <span style="color:var(--text-dim)">(optional)</span></label>
|
||
<input id="es-selector" type="text" placeholder="e.g. input#st or blank for auto-discover" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
</div>
|
||
</div>
|
||
<div style="margin-bottom:12px">
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">URL TEMPLATE <span style="color:var(--accent-gold)">(use {keyword} for direct mode)</span></label>
|
||
<input id="es-url" type="text" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:80px 1fr 1fr;gap:12px;margin-bottom:16px;align-items:end">
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MAX PAGES</label>
|
||
<input id="es-pages" type="number" min="1" max="20" value="1" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:10px;padding-bottom:2px">
|
||
<label class="toggle-wrap" for="es-login-req">
|
||
<input type="checkbox" id="es-login-req" class="toggle-input"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-hi)">Requires Login</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:10px;padding-bottom:2px">
|
||
<label class="toggle-wrap" for="es-login-en">
|
||
<input type="checkbox" id="es-login-en" class="toggle-input"/>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
</label>
|
||
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-hi)">Login Enabled</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px">
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">LOGIN URL</label>
|
||
<input id="es-login-url" type="text" placeholder="https://site.com/login" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">LOGIN CHECK SELECTOR</label>
|
||
<input id="es-login-check" type="text" placeholder=".user-avatar or #logout-btn" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end">
|
||
<button class="btn" onclick="closeEditSite()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="saveEditSite()">💾 Save Changes</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Price Filter Modal ═══════════════════════════════════════════════════ -->
|
||
<div id="price-filter-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.75);align-items:center;justify-content:center">
|
||
<div style="background:var(--bg-panel);border:1px solid var(--accent-gold);border-radius:8px;padding:28px 32px;min-width:400px;max-width:520px;width:90vw">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px">
|
||
<div style="font-family:var(--font-mono);color:var(--accent-gold);font-size:13px;letter-spacing:2px">
|
||
💰 PRICE FILTER — <span id="pf-kw-label"></span>
|
||
</div>
|
||
<button class="btn btn-sm" onclick="closePriceFilterModal()" style="font-size:16px;padding:2px 10px">×</button>
|
||
</div>
|
||
<input type="hidden" id="pf-kw-id">
|
||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:14px">
|
||
Filter listings for this keyword by price. Leave blank for no limit. Values are in the <b style="color:var(--text-hi)">raw listing currency</b> — comparison is done after converting both sides to USD.
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px">
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MIN PRICE (USD equivalent)</label>
|
||
<input id="pf-min-input" type="number" step="0.01" min="0" placeholder="e.g. 50" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:13px;box-sizing:border-box"/>
|
||
</div>
|
||
<div>
|
||
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MAX PRICE (USD equivalent)</label>
|
||
<input id="pf-max-input" type="number" step="0.01" min="0" placeholder="e.g. 500" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:13px;box-sizing:border-box"/>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end">
|
||
<button class="btn btn-ghost" onclick="document.getElementById('pf-min-input').value='';document.getElementById('pf-max-input').value=''">Clear</button>
|
||
<button class="btn" onclick="closePriceFilterModal()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="savePriceFilter()">💾 Save Filter</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ AI Target Modal ═════════════════════════════════════════════════════ -->
|
||
<div id="ai-target-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.75);align-items:center;justify-content:center">
|
||
<div style="background:var(--bg-panel);border:1px solid var(--accent-green);border-radius:8px;padding:28px 32px;min-width:520px;max-width:680px;width:90vw">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px">
|
||
<div style="font-family:var(--font-mono);color:var(--accent-green);font-size:13px;letter-spacing:2px">
|
||
🤖 AI TARGET — <span id="ai-target-kw-label"></span>
|
||
</div>
|
||
<button class="btn btn-sm" onclick="closeAiTargetModal()" style="font-size:16px;padding:2px 10px">×</button>
|
||
</div>
|
||
<input type="hidden" id="ai-target-kw-id">
|
||
<div style="margin-bottom:8px;color:var(--text-mid);font-size:12px;line-height:1.6">
|
||
Describe in plain English exactly what you want to find. The AI reads each lot title and decides YES or NO.<br>
|
||
Be specific: mention generation/model range, condition, and what to exclude.
|
||
</div>
|
||
<textarea id="ai-target-input"
|
||
placeholder="e.g. iPad Pro 4th generation (2020) or newer, actual device only — not cases, covers, chargers, screen protectors, keyboards, or any accessory"
|
||
style="width:100%;height:90px;background:var(--bg-input);color:var(--text-bright);border:1px solid var(--border);border-radius:4px;padding:10px;font-family:var(--font-mono);font-size:12px;resize:vertical;box-sizing:border-box"
|
||
></textarea>
|
||
<div style="margin-top:14px;padding:12px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px">
|
||
<div style="color:var(--text-mid);font-size:11px;margin-bottom:8px;font-family:var(--font-mono)">// TEST BEFORE SAVING — paste a real lot title below</div>
|
||
<div style="display:flex;gap:8px">
|
||
<input id="ai-test-title-input" type="text" placeholder="e.g. Apple iPad Pro 12.9-inch 6th Gen 256GB Wi-Fi Space Gray"
|
||
style="flex:1;background:var(--bg-input);color:var(--text-bright);border:1px solid var(--border);border-radius:4px;padding:8px;font-family:var(--font-mono);font-size:12px">
|
||
<button class="btn btn-secondary" onclick="testAiTarget()" style="white-space:nowrap;font-size:12px">▶ Test</button>
|
||
</div>
|
||
<div id="ai-test-result" style="margin-top:8px;font-family:var(--font-mono);font-size:12px;min-height:18px"></div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px">
|
||
<button class="btn" onclick="closeAiTargetModal()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="saveAiTarget()">💾 Save AI Target</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|