← 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 &lt;domain&gt;</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>