← 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); });