← back to Ventura Corridor
src/enrich/website_via_exa.ts
171 lines
/**
* Website discovery for BTRC rows via EXA web search.
*
* Picks businesses on the corridor with NO website, runs an EXA query
* `<name> <city>` + a Ventura Blvd hint, takes the first credible non-social
* result domain, and writes it to businesses.website.
*
* Sample-first by default: --limit=20. Pass --limit=N to scale.
*
* Requires EXA_API_KEY in the environment OR ~/Projects/secrets-manager/.env.
*
* No Claude / Anthropic. Pure HTTP to api.exa.ai.
*/
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { query, pool } from '../../db/pool.ts';
const EXA = 'https://api.exa.ai/search';
function loadKey(): string {
if (process.env.EXA_API_KEY) return process.env.EXA_API_KEY.trim();
try {
const txt = readFileSync(join(homedir(), 'Projects/secrets-manager/.env'), 'utf8');
const m = txt.match(/^EXA_API_KEY=(.+)$/m);
if (m) return m[1].trim();
} catch {}
return '';
}
const KEY = loadKey();
// Domain blocks: social, aggregators, gov, mapping. We want the business's
// own site, not a Yelp/Facebook/Mapquest mirror.
const BLOCK_DOMAINS = new Set([
'yelp.com', 'facebook.com', 'instagram.com', 'twitter.com', 'x.com',
'linkedin.com', 'tiktok.com', 'youtube.com', 'pinterest.com',
'mapquest.com', 'google.com', 'maps.google.com', 'foursquare.com',
'tripadvisor.com', 'opentable.com', 'doordash.com', 'ubereats.com',
'zillow.com', 'realtor.com', 'redfin.com', 'apartments.com',
'bbb.org', 'manta.com', 'bizapedia.com', 'opencorporates.com',
'lacity.org', 'data.lacity.org', 'lavote.gov', 'lacounty.gov',
'avvo.com', 'lawyers.com', 'martindale.com', 'findlaw.com',
'healthgrades.com', 'zocdoc.com', 'vitals.com', 'webmd.com',
'glassdoor.com', 'indeed.com', 'crunchbase.com',
]);
function hostOf(url: string): string | null {
try {
return new URL(url).hostname.replace(/^www\./, '').toLowerCase();
} catch {
return null;
}
}
function isCredibleSite(url: string): boolean {
const h = hostOf(url);
if (!h) return false;
if (BLOCK_DOMAINS.has(h)) return false;
// Block all subdomains of blocked roots too.
for (const d of BLOCK_DOMAINS) if (h.endsWith('.' + d)) return false;
return true;
}
interface ExaResult {
url: string;
title?: string;
publishedDate?: string;
}
async function exaSearch(q: string): Promise<ExaResult[]> {
const r = await fetch(EXA, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({ query: q, type: 'auto', numResults: 5 }),
signal: AbortSignal.timeout(20_000),
});
if (!r.ok) throw new Error(`EXA ${r.status}: ${(await r.text()).slice(0, 150)}`);
const data: any = await r.json();
return Array.isArray(data?.results) ? data.results : [];
}
interface Row {
id: number;
name: string;
city: string | null;
address: string | null;
}
async function pickQueue(limit: number): Promise<Row[]> {
// BTRC rows on corridor with no website, biased toward names that look like
// real businesses (not just LLC pass-throughs of single-person filings).
const r = await query<Row>(
`
SELECT id, name, city, address
FROM businesses
WHERE on_corridor
AND source = 'la_btrc'
AND website IS NULL
AND length(name) >= 5
AND name NOT LIKE '% LLC' -- LLC-only names rarely have a website
AND name NOT ILIKE 'POST OFFICE BOX%'
ORDER BY id
LIMIT $1
`,
[limit]
);
return r.rows;
}
async function enrichOne(row: Row): Promise<{ ok: boolean; website?: string; reason?: string }> {
// Build a focused query: name + Ventura Blvd + city.
const q = `"${row.name}" Ventura Blvd ${row.city ?? 'Sherman Oaks'}`;
let results: ExaResult[];
try {
results = await exaSearch(q);
} catch (e: any) {
return { ok: false, reason: 'exa-err: ' + (e.message || String(e)).slice(0, 100) };
}
for (const hit of results) {
if (isCredibleSite(hit.url)) {
const host = hostOf(hit.url);
if (host) {
const website = 'https://' + host;
await query(`UPDATE businesses SET website = $1, last_seen_at = now() WHERE id = $2`, [
website,
row.id,
]);
return { ok: true, website };
}
}
}
return { ok: false, reason: 'no credible result' };
}
async function main() {
if (!KEY) {
console.error('[exa] EXA_API_KEY not set. Add via /secrets, or pass EXA_API_KEY=… env.');
process.exit(1);
}
const argLimit = process.argv.find((a) => a.startsWith('--limit='));
const limit = argLimit ? parseInt(argLimit.split('=')[1], 10) : 20;
const queue = await pickQueue(limit);
console.log(`[exa] enriching ${queue.length} BTRC rows (no website)`);
let ok = 0,
miss = 0,
err = 0;
for (const row of queue) {
const r = await enrichOne(row);
if (r.ok) {
ok++;
console.log(` ✓ ${row.name.padEnd(45).slice(0, 45)} → ${r.website}`);
} else if ((r.reason || '').startsWith('exa-err')) {
err++;
console.log(` ⚠ ${row.name.padEnd(45).slice(0, 45)} ${r.reason}`);
} else {
miss++;
}
// 200ms throttle — courteous to EXA + avoids rate spikes.
await new Promise((res) => setTimeout(res, 200));
}
console.log(`[exa] done · ${ok} found · ${miss} no-site-found · ${err} errors`);
await pool.end();
}
main().catch((e) => {
console.error('[exa]', e);
process.exit(1);
});