← back to Domain Sniper
public/index.html
377 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>domain-sniper — live</title>
<style>
:root {
--bg: #0a0a0a;
--fg: #d4d4d4;
--dim: #6b6b6b;
--hot: #ffb454;
--brand: #ff3b3b;
--accent: #4ec9b0;
--row: rgba(255, 255, 255, 0.02);
--row-hover: rgba(255, 255, 255, 0.06);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%;
background: var(--bg); color: var(--fg);
font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
font-size: 13px;
}
header {
padding: 14px 20px;
border-bottom: 1px solid #1f1f1f;
display: flex; align-items: center; justify-content: space-between;
gap: 20px;
}
h1 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0.5px; }
h1 .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4ec9b0; margin-right: 8px; animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.stats { display: flex; gap: 28px; color: var(--dim); font-size: 12px; }
.stats span b { color: var(--fg); margin-right: 6px; font-weight: 600; }
.ctrl {
padding: 12px 20px;
border-bottom: 1px solid #1f1f1f;
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.ctrl input[type="text"] {
background: #161616; border: 1px solid #2a2a2a; color: var(--fg);
padding: 6px 10px; border-radius: 4px; width: 240px; font-family: inherit;
}
.ctrl label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: var(--dim); font-size: 12px; }
.ctrl label input { accent-color: var(--accent); }
.ctrl .sortgrp { display: flex; gap: 0; border: 1px solid #2a2a2a; border-radius: 4px; overflow: hidden; }
.ctrl .sortgrp button {
background: #161616; color: var(--dim); border: none; padding: 5px 10px;
font-family: inherit; font-size: 11px; cursor: pointer; border-right: 1px solid #2a2a2a;
}
.ctrl .sortgrp button:last-child { border-right: none; }
.ctrl .sortgrp button.active { background: #1f1f1f; color: var(--accent); }
.ctrl .sortgrp button:hover:not(.active) { color: var(--fg); }
.ctrl .chip {
padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600;
letter-spacing: 0.5px; text-transform: uppercase;
}
.chip.hot { background: rgba(255, 180, 84, 0.12); color: var(--hot); border: 1px solid rgba(255, 180, 84, 0.3); }
.chip.brand { background: rgba(255, 59, 59, 0.12); color: var(--brand); border: 1px solid rgba(255, 59, 59, 0.3); }
main {
height: calc(100vh - 110px);
overflow-y: auto;
}
.row {
display: grid;
grid-template-columns: 100px 80px 1fr 200px;
padding: 6px 20px;
border-bottom: 1px solid #141414;
align-items: center;
gap: 16px;
transition: background 0.1s;
cursor: pointer;
}
.row:hover { background: var(--row-hover); }
.row.hot { background: rgba(255, 180, 84, 0.04); }
.row.brand { background: rgba(255, 59, 59, 0.06); border-left: 3px solid var(--brand); padding-left: 17px; }
.row.flash { animation: flash 0.4s ease-out; }
@keyframes flash { 0% { background: rgba(78, 201, 176, 0.25); } 100% {} }
.row .t { color: var(--dim); font-size: 11px; }
.row .tag { font-size: 10px; font-weight: 600; letter-spacing: 0.5px; }
.row.hot .tag { color: var(--hot); }
.row.brand .tag { color: var(--brand); }
.row .domain { font-weight: 500; color: var(--fg); }
.row .actions { display: flex; gap: 8px; justify-content: flex-end; }
.row .actions a, .row .actions button {
color: var(--dim); text-decoration: none; font-size: 11px;
border: 1px solid #2a2a2a; padding: 3px 8px; border-radius: 3px;
background: transparent; font-family: inherit; cursor: pointer;
}
.row .actions a:hover, .row .actions button:hover { color: var(--accent); border-color: var(--accent); }
.empty { padding: 40px; color: var(--dim); text-align: center; }
.note {
padding: 12px 20px; background: #141414; color: var(--dim); font-size: 11px;
border-bottom: 1px solid #1f1f1f; line-height: 1.5;
}
.note b { color: var(--fg); }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #1f1f1f; color: var(--accent); border: 1px solid var(--accent);
padding: 10px 16px; border-radius: 4px; font-size: 12px;
opacity: 0; transition: opacity 0.2s; pointer-events: none;
max-width: 80vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.toast.show { opacity: 1; }
</style>
</head>
<body>
<header>
<h1><span class="dot"></span>DOMAIN-SNIPER · LIVE FIREHOSE</h1>
<div class="stats">
<span><b id="stat-seen">0</b>seen</span>
<span><b id="stat-hot">0</b>hot</span>
<span><b id="stat-brand">0</b>brand</span>
<span><b id="stat-rate">0</b>/sec</span>
<span><b id="stat-uptime">0s</b>uptime</span>
</div>
</header>
<div class="note">
Data source — <b>Certificate Transparency logs</b> via CertStream. Every TLS cert issued worldwide flows through here, ~1 min after a domain gets registered. This is the closest free public real-time analog to a registrar's check-stream. Rows marked <span class="chip brand">BRAND</span> match one of your watched keywords. Rows marked <span class="chip hot">HOT</span> fit the short-pronounceable-mainstream-TLD pattern aggregators target. <b>Click any row to copy a <code>node register.js <domain></code> command to the clipboard.</b>
</div>
<div class="ctrl">
<input id="filter" type="text" placeholder="filter (substring match)" autocomplete="off" />
<label><input type="checkbox" id="show-hot" checked /> hot</label>
<label><input type="checkbox" id="show-brand" checked /> brand</label>
<label><input type="checkbox" id="autoscroll" checked /> auto-scroll</label>
<label><input type="checkbox" id="audio" checked /> audio on brand</label>
<span style="color: var(--dim); font-size: 11px;">sort:</span>
<div class="sortgrp" id="sortgrp">
<button data-sort="time" class="active">time</button>
<button data-sort="brand">brand-first</button>
<button data-sort="hot">hot-first</button>
</div>
<span style="margin-left:auto; color: var(--dim); font-size: 11px;">
<span id="conn">connecting…</span>
</span>
</div>
<div id="perlog-band" style="display:flex; gap:14px; padding:6px 14px; border-bottom:1px solid #1a1a1a; background:#0a0a0a; font-size:11px; color:var(--dim); overflow-x:auto; white-space:nowrap;">
<span style="color:#4ec9b0;">CT sources:</span>
<span id="perlog-empty">waiting…</span>
</div>
<main id="feed"><div class="empty">waiting for the firehose…</div></main>
<div id="toast" class="toast">copied</div>
<script>
const feed = document.getElementById('feed');
const conn = document.getElementById('conn');
const filterEl = document.getElementById('filter');
const showHot = document.getElementById('show-hot');
const showBrand = document.getElementById('show-brand');
const autoscroll = document.getElementById('autoscroll');
const audioEl = document.getElementById('audio');
const toast = document.getElementById('toast');
const sortgrp = document.getElementById('sortgrp');
const stat = {
seen: document.getElementById('stat-seen'),
hot: document.getElementById('stat-hot'),
brand: document.getElementById('stat-brand'),
rate: document.getElementById('stat-rate'),
uptime: document.getElementById('stat-uptime'),
};
let lastSeen = 0;
let lastTs = Date.now();
let startedAt = Date.now();
const hits = []; // newest-first
const MAX_HITS = 500;
let sortMode = localStorage.getItem('sniper.sort') || 'time';
// Persist filter/checkbox state too
for (const [el, key, attr] of [
[filterEl, 'sniper.filter', 'value'],
[showHot, 'sniper.showHot', 'checked'],
[showBrand, 'sniper.showBrand', 'checked'],
[autoscroll, 'sniper.autoscroll', 'checked'],
[audioEl, 'sniper.audio', 'checked'],
]) {
const v = localStorage.getItem(key);
if (v !== null) el[attr] = attr === 'checked' ? v === '1' : v;
el.addEventListener('change', () => localStorage.setItem(key, attr === 'checked' ? (el[attr] ? '1' : '0') : el[attr]));
el.addEventListener('input', () => localStorage.setItem(key, attr === 'checked' ? (el[attr] ? '1' : '0') : el[attr]));
}
function fmt(ts) {
const d = new Date(ts);
return d.toTimeString().slice(0, 8);
}
function fmtUptime(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's';
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
}
function shouldShow(rec) {
if (rec.brand && !showBrand.checked) return false;
if (rec.hot && !rec.brand && !showHot.checked) return false;
if (!rec.hot && !rec.brand) return false;
const q = filterEl.value.trim().toLowerCase();
if (q && !rec.domain.toLowerCase().includes(q)) return false;
return true;
}
function sortedHits() {
if (sortMode === 'time') return hits;
if (sortMode === 'brand') return [...hits].sort((a, b) => {
const ab = a.brand ? 1 : 0, bb = b.brand ? 1 : 0;
if (ab !== bb) return bb - ab;
return b.ts - a.ts;
});
if (sortMode === 'hot') return [...hits].sort((a, b) => {
const ah = a.hot ? 1 : 0, bh = b.hot ? 1 : 0;
if (ah !== bh) return bh - ah;
return b.ts - a.ts;
});
return hits;
}
let toastTimer = null;
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('show'), 1600);
}
async function copyRegisterCmd(domain) {
const cmd = 'node register.js ' + domain;
try {
await navigator.clipboard.writeText(cmd);
showToast('copied: ' + cmd);
} catch {
// Fallback if clipboard API blocked (insecure context)
const ta = document.createElement('textarea');
ta.value = cmd; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); showToast('copied (fallback): ' + cmd); }
catch { showToast('copy failed — ' + cmd); }
ta.remove();
}
}
let audioCtx = null;
function ping() {
if (!audioEl.checked) return;
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'sine'; o.frequency.value = 880;
g.gain.value = 0.0001;
o.connect(g); g.connect(audioCtx.destination);
const now = audioCtx.currentTime;
g.gain.exponentialRampToValueAtTime(0.18, now + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
o.start(now); o.stop(now + 0.24);
} catch {}
}
function buildRow(rec) {
const cls = rec.brand ? 'brand' : (rec.hot ? 'hot' : '');
const tag = rec.brand ? `BRAND · ${rec.brand}` : (rec.hot ? 'HOT' : '');
const row = document.createElement('div');
row.className = 'row ' + cls;
row.dataset.domain = rec.domain;
row.innerHTML = `
<span class="t">${fmt(rec.ts)}</span>
<span class="tag">${tag}</span>
<span class="domain">${rec.domain}</span>
<span class="actions">
<button data-act="copy">copy reg cmd</button>
<a href="https://${rec.domain}" target="_blank" rel="noopener noreferrer">visit</a>
<a href="https://crt.sh/?q=${encodeURIComponent(rec.domain)}" target="_blank" rel="noopener noreferrer">crt.sh</a>
</span>
`;
return row;
}
function render() {
const list = sortedHits().filter(shouldShow).slice(0, MAX_HITS);
if (!list.length) {
feed.innerHTML = '<div class="empty">waiting for matches…</div>';
return;
}
feed.innerHTML = '';
for (const rec of list) feed.appendChild(buildRow(rec));
if (sortMode === 'time' && autoscroll.checked) feed.scrollTop = 0;
}
function onHit(rec) {
hits.unshift(rec);
while (hits.length > MAX_HITS * 2) hits.pop();
if (rec.brand) ping();
if (sortMode === 'time') {
// Cheap path: just prepend/flash without re-rendering everything
if (!shouldShow(rec)) return;
if (feed.querySelector('.empty')) feed.innerHTML = '';
const row = buildRow(rec);
row.classList.add('flash');
feed.prepend(row);
while (feed.children.length > MAX_HITS) feed.removeChild(feed.lastChild);
if (autoscroll.checked) feed.scrollTop = 0;
} else {
// Re-sort throttled
scheduleRender();
}
}
let renderPending = false;
function scheduleRender() {
if (renderPending) return;
renderPending = true;
requestAnimationFrame(() => { renderPending = false; render(); });
}
// Row click — copy reg cmd. Don't hijack clicks on the <a> links.
feed.addEventListener('click', (e) => {
const row = e.target.closest('.row');
if (!row) return;
if (e.target.tagName === 'A') return;
const dom = row.dataset.domain;
if (dom) copyRegisterCmd(dom);
});
// Sort buttons
sortgrp.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-sort]');
if (!btn) return;
sortMode = btn.dataset.sort;
localStorage.setItem('sniper.sort', sortMode);
for (const b of sortgrp.querySelectorAll('button')) b.classList.toggle('active', b === btn);
render();
});
for (const b of sortgrp.querySelectorAll('button')) b.classList.toggle('active', b.dataset.sort === sortMode);
for (const el of [filterEl, showHot, showBrand]) el.addEventListener('input', scheduleRender);
for (const el of [filterEl, showHot, showBrand]) el.addEventListener('change', scheduleRender);
const perlogBand = document.getElementById('perlog-band');
function renderPerLog(p) {
const names = Object.keys(p).sort();
if (!names.length) return;
const parts = names.map((name) => {
const v = p[name];
const short = name.replace(/2026h1$/, '');
const stale = v.stale ? 'background:#ff3b3b;' : (v.idle > 0 && v.emitted === 0 ? 'background:#e0a050;' : 'background:#4ec9b0;');
const dot = `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;vertical-align:middle;${stale}"></span>`;
return `<span title="${name}">${dot}<b style="color:#d4d4d4;">${short}</b> ${v.emitted}<span style="color:#5a5a5a;">/</span><span style="color:#5a5a5a;">i</span>${v.idle || 0}</span>`;
});
perlogBand.innerHTML = '<span style="color:#4ec9b0;">CT sources:</span>' + parts.join('');
}
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(proto + '//' + location.host);
ws.onopen = () => { conn.textContent = 'connected'; conn.style.color = '#4ec9b0'; };
ws.onclose = () => { conn.textContent = 'reconnecting…'; conn.style.color = '#ff3b3b'; setTimeout(connect, 1500); };
ws.onerror = () => {};
ws.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.type === 'hit') onHit(m);
else if (m.type === 'stats') {
const now = Date.now();
const dt = (now - lastTs) / 1000;
const dseen = m.seen - lastSeen;
stat.rate.textContent = dt > 0 ? Math.max(0, Math.round(dseen / dt)) : 0;
lastSeen = m.seen; lastTs = now;
stat.seen.textContent = m.seen.toLocaleString();
stat.hot.textContent = m.hot.toLocaleString();
stat.brand.textContent = m.brand.toLocaleString();
startedAt = m.startedAt || startedAt;
stat.uptime.textContent = fmtUptime(Date.now() - startedAt);
if (m.perLog) renderPerLog(m.perLog);
}
};
}
connect();
</script>
</body>
</html>