← back to Ventura Corridor

src/enrich/linkedin_via_brave.ts

110 lines

/**
 * Find LinkedIn company pages for businesses on the corridor that don't have one.
 *
 * Brave search: `"<name>" site:linkedin.com/company` — first /company/<slug> hit wins.
 * Insert into business_contacts with role='linkedin_company'.
 *
 * Brave free tier: 2000/month total, ~1 q/sec.
 */
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { query, pool } from '../../db/pool.ts';

function loadKey(): string {
  if (process.env.BRAVE_SEARCH_API_KEY) return process.env.BRAVE_SEARCH_API_KEY.trim();
  for (const p of [
    join(homedir(), 'Projects/secrets-manager/.env'),
    join(homedir(), 'Projects/_ventura-recon/.env'),
  ]) {
    try {
      const m = readFileSync(p, 'utf8').match(/^BRAVE_SEARCH_API_KEY=(.+)$/m);
      if (m) return m[1].trim();
    } catch {}
  }
  return '';
}

const KEY = loadKey();

interface Row { id: number; name: string; city: string | null; }

async function pick(limit: number): Promise<Row[]> {
  // Pick businesses on corridor with website (signal of being a real business)
  // but no linkedin_company contact yet.
  const r = await query<Row>(`
    SELECT b.id, b.name, b.city FROM businesses b
    WHERE b.on_corridor
      AND b.website IS NOT NULL
      AND length(b.name) >= 5
      AND b.name NOT LIKE '% LLC'
      AND NOT EXISTS (
        SELECT 1 FROM business_contacts c
        WHERE c.business_id = b.id AND c.role = 'linkedin_company'
      )
    ORDER BY b.id
    LIMIT $1
  `, [limit]);
  return r.rows;
}

async function brave(q: string): Promise<string[]> {
  const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(q)}&count=10`;
  const r = await fetch(url, {
    headers: { 'X-Subscription-Token': KEY, Accept: 'application/json' },
    signal: AbortSignal.timeout(12_000),
  });
  if (r.status === 429) return [];
  if (!r.ok) throw new Error(`brave ${r.status}`);
  const data: any = await r.json();
  return (data?.web?.results || []).map((x: any) => x.url).filter(Boolean);
}

const COMPANY_RE = /linkedin\.com\/company\/([a-zA-Z0-9_-]+)/i;

async function main() {
  if (!KEY) {
    console.error('[brave-li] BRAVE_SEARCH_API_KEY missing');
    process.exit(1);
  }
  const argLimit = process.argv.find((a) => a.startsWith('--limit='));
  const limit = argLimit ? parseInt(argLimit.split('=')[1], 10) : 100;

  const queue = await pick(limit);
  console.log(`[brave-li] querying ${queue.length} businesses · throttling 1.1s`);
  let ok = 0, miss = 0, err = 0;
  for (const row of queue) {
    const q = `"${row.name}" site:linkedin.com/company`;
    try {
      const urls = await brave(q);
      const hit = urls.find((u) => COMPANY_RE.test(u));
      if (hit) {
        const m = hit.match(COMPANY_RE);
        const slug = m![1].toLowerCase();
        const liUrl = 'https://www.linkedin.com/company/' + slug;
        await query(
          `
          INSERT INTO business_contacts (
            business_id, role, person_linkedin, source, source_url, confidence, notes
          ) VALUES ($1, 'linkedin_company', $2, 'brave_search', $3, 0.7, 'discovered via Brave query for company on linkedin')
          ON CONFLICT DO NOTHING
        `,
          [row.id, liUrl, hit]
        );
        ok++;
        console.log(`  ✓ ${row.name.slice(0,40).padEnd(40)} → ${slug}`);
      } else {
        miss++;
      }
    } catch (e: any) {
      err++;
      console.log(`  ⚠ ${row.name.slice(0,40)} ${e.message?.slice(0,60)}`);
    }
    await new Promise((r) => setTimeout(r, 1100));
  }
  console.log(`[brave-li] done · ${ok} found · ${miss} no-result · ${err} errors`);
  await pool.end();
}

main().catch((e) => { console.error(e); process.exit(1); });