← back to Domain Sniper

watch-drops.js

141 lines

#!/usr/bin/env node
// watch-drops.js — daily pull of pending-delete CSVs from public drop sources.
//
// STATUS: SCAFFOLD. Refuses to fetch unless WATCH_DROPS_SOURCES is set.
//
// Why scaffolded-not-shipped:
//   1. Anti-bot walls on pool.com / dropcatch.com / snapnames.com / expireddomains.net.
//      Cloudflare / Akamai challenge pages bounce anonymous GETs.
//   2. Same hygiene rule that bans WHOIS/RDAP probing applies here: any GET against
//      a drop-house auction page is a "we're interested" signal. If we ever hit a
//      drop list and then go register one, the request stream is correlatable.
//   3. The only TOS-clean source is ICANN CZDS (Centralized Zone Data Service),
//      which requires per-TLD application + API key. Once Steve has CZDS creds
//      routed via the secrets skill, the czds adapter below activates.
//
// Adapter interface:
//   { name, source, fetch(opts) -> { ok, names: [...domain], fetchedAt, raw } }
//
// Usage:
//   node watch-drops.js list-sources         # show what's configured
//   WATCH_DROPS_SOURCES=czds node watch-drops.js pull   # pull enabled adapters
//
// Output (per enabled adapter):
//   data/drops/<source>-<YYYY-MM-DD>.jsonl   # one {ts, domain, source} per line
//   data/drops/index.json                    # tally per day / source

const fs = require('fs');
const path = require('path');
const https = require('https');

const DATA_DIR = path.join(__dirname, 'data', 'drops');
const INDEX_FILE = path.join(DATA_DIR, 'index.json');

// Adapters keyed by short name. fetch() returns names ready to dedupe.
const ADAPTERS = {
  // ICANN CZDS — daily zone-file diff against yesterday's snapshot.
  // Requires CZDS_API_KEY (routed via secrets skill once Steve approves a TLD).
  // Each .com/.net/.app/.dev zone is a tab-separated NSDNAME record file ~GB-sized;
  // diff yields adds (registrations) and deletes (drops).
  czds: {
    name: 'czds',
    requiresEnv: 'CZDS_API_KEY',
    description: 'ICANN Centralized Zone Data Service zone-diff (drops = yesterday \\ today)',
    fetch: async () => {
      const apiKey = process.env.CZDS_API_KEY;
      if (!apiKey) return { ok: false, error: 'CZDS_API_KEY not set — route via secrets skill' };
      // TODO: Implement per-TLD zone fetch + diff once a key is provisioned.
      // CZDS flow: POST /api/authenticate -> JWT -> GET /czds/downloads/links -> per-zone GET
      return { ok: false, error: 'czds adapter is a stub — implement after CZDS-TLD approval' };
    },
  },

  // Pool.com / DropCatch / SnapNames / ExpiredDomains.net are intentionally NOT
  // listed as adapters here. They have anti-bot walls AND visiting them is a
  // leak signal per the same rule that bans WHOIS probing. Don't add them
  // without explicit Steve sign-off.
};

function ensureDir(p) {
  fs.mkdirSync(p, { recursive: true });
}

function loadIndex() {
  try { return JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8')); }
  catch { return { byDay: {} }; }
}

function saveIndex(ix) {
  ensureDir(DATA_DIR);
  fs.writeFileSync(INDEX_FILE, JSON.stringify(ix, null, 2));
}

function listSources() {
  console.log('\n  watch-drops :: configured adapters');
  console.log('  ' + '-'.repeat(78));
  for (const [k, a] of Object.entries(ADAPTERS)) {
    const envOk = a.requiresEnv ? (process.env[a.requiresEnv] ? '\x1b[32mYES\x1b[0m' : '\x1b[31mno\x1b[0m') : '\x1b[32mYES\x1b[0m';
    console.log(`  ${k.padEnd(12)} env-ready=${envOk}  ${a.description}`);
    if (a.requiresEnv && !process.env[a.requiresEnv]) {
      console.log(`               requires ${a.requiresEnv} (route via secrets skill: ~/Projects/secrets-manager/cli.js)`);
    }
  }
  console.log('\n  policy: drop-house URLs (pool.com / dropcatch.com / snapnames.com / expireddomains.net)');
  console.log('          are intentionally NOT registered as adapters — they are leak channels per the');
  console.log('          DoH-only rule and are anti-bot-walled. Add only with explicit Steve sign-off.\n');
}

async function pull() {
  const enabled = (process.env.WATCH_DROPS_SOURCES || '').split(',').map((s) => s.trim()).filter(Boolean);
  if (!enabled.length) {
    console.error('\n  WATCH_DROPS_SOURCES is empty — refusing to fetch.');
    console.error('  Run:  node watch-drops.js list-sources    to see what\'s available.\n');
    process.exit(2);
  }
  ensureDir(DATA_DIR);
  const ix = loadIndex();
  const day = new Date().toISOString().slice(0, 10);
  ix.byDay[day] = ix.byDay[day] || {};
  let totalNames = 0;
  for (const name of enabled) {
    const a = ADAPTERS[name];
    if (!a) {
      console.error(`  unknown adapter: ${name}`);
      continue;
    }
    console.log(`  [${name}] fetching…`);
    try {
      const r = await a.fetch({});
      if (!r.ok) {
        console.error(`  [${name}] skipped: ${r.error}`);
        ix.byDay[day][name] = { count: 0, error: r.error };
        continue;
      }
      const out = path.join(DATA_DIR, `${name}-${day}.jsonl`);
      const ts = new Date().toISOString();
      const lines = (r.names || []).map((d) => JSON.stringify({ ts, domain: d, source: name }) + '\n');
      fs.writeFileSync(out, lines.join(''));
      ix.byDay[day][name] = { count: lines.length, file: out };
      totalNames += lines.length;
      console.log(`  [${name}] wrote ${lines.length} names → ${out}`);
    } catch (e) {
      console.error(`  [${name}] error: ${e.message}`);
      ix.byDay[day][name] = { count: 0, error: e.message };
    }
  }
  saveIndex(ix);
  console.log(`\n  total names pulled today: ${totalNames}`);
}

const cmd = process.argv[2] || 'help';
if (cmd === 'list-sources') {
  listSources();
} else if (cmd === 'pull') {
  pull().catch((e) => { console.error(e); process.exit(1); });
} else {
  console.error('usage:');
  console.error('  node watch-drops.js list-sources');
  console.error('  WATCH_DROPS_SOURCES=czds[,dropcatch...] node watch-drops.js pull');
  process.exit(2);
}