← back to Ventura Corridor
src/enrich/website_via_ddg.ts
184 lines
/**
* Website discovery via DuckDuckGo HTML endpoint (html.duckduckgo.com/html).
*
* No API key, no monthly quota — polite alternative when Brave's free
* tier is exhausted. Lower precision than Brave's API (parses raw HTML),
* but we apply the same domain-credibility filter so false positives
* collapse to "no winner found".
*
* Run:
* npm run enrich:websites:ddg # default 30, high-value categories
* tsx src/enrich/website_via_ddg.ts -- --limit=100
* tsx src/enrich/website_via_ddg.ts -- --limit=50 --all # any BTRC row
*
* Polite: 2.5s gap between queries, 8s timeout, custom UA.
*/
import * as cheerio from 'cheerio';
import { query, pool } from '../../db/pool.ts';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 ventura-corridor/0.1 (website-discovery; +https://leads.venturaclaw.com)';
const GAP_MS = parseInt(process.env.DDG_GAP_MS || '2500', 10);
const TIMEOUT_MS = parseInt(process.env.DDG_TIMEOUT_MS || '8000', 10);
const BLOCK = new Set([
// Social
'yelp.com', 'facebook.com', 'instagram.com', 'twitter.com', 'x.com',
'linkedin.com', 'tiktok.com', 'youtube.com', 'pinterest.com', 'reddit.com',
// Maps / aggregators / dirs
'mapquest.com', 'google.com', 'maps.google.com', 'foursquare.com',
'tripadvisor.com', 'opentable.com', 'doordash.com', 'ubereats.com',
'grubhub.com', 'wikipedia.org', 'wikidata.org',
'angi.com', 'angieslist.com', 'thumbtack.com', 'alignable.com',
'yellowpages.com', 'whitepages.com', 'apollo.io', 'zoominfo.com',
'crunchbase.com', 'spokeo.com', 'fastpeoplesearch.com',
// Real estate / business records
'zillow.com', 'realtor.com', 'redfin.com', 'apartments.com',
'bbb.org', 'manta.com', 'bizapedia.com', 'opencorporates.com',
'dnb.com', 'corporationwiki.com',
// Reviews / health / legal
'avvo.com', 'lawyers.com', 'martindale.com', 'findlaw.com',
'healthgrades.com', 'zocdoc.com', 'vitals.com', 'webmd.com',
// Jobs / news (we want the biz site, not a press hit)
'glassdoor.com', 'indeed.com', 'duckduckgo.com',
// Gov
'lacity.org', 'data.lacity.org', 'lavote.gov', 'lacounty.gov', 'dca.lacity.org'
]);
function host(u: string): string | null {
try { return new URL(u).hostname.replace(/^www\./, '').toLowerCase(); } catch { return null; }
}
function credible(u: string): boolean {
const h = host(u);
if (!h) return false;
if (BLOCK.has(h)) return false;
// Block any subdomain of a blocked root.
for (const root of BLOCK) if (h === root || h.endsWith('.' + root)) return false;
if (h.endsWith('.gov') || h.endsWith('.gov.us')) return false;
return true;
}
function nameTokens(name: string): string[] {
return name.toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(t => t.length >= 3 && !['the','and','for','inc','llc','corp','of'].includes(t));
}
function domainMatchesName(host: string, name: string): boolean {
const stem = host.split('.').slice(0, -1).join('').replace(/[^a-z0-9]/g, '');
const tokens = nameTokens(name);
if (!tokens.length) return false;
// Strict: at least one name-token of >=4 chars appears in the domain stem
return tokens.some(t => t.length >= 4 && stem.includes(t));
}
const HIGH_VALUE_CATEGORY_PREFIXES = [
'amenity: restaurant', 'amenity: fast_food', 'amenity: cafe',
'amenity: bar', 'amenity: pub', 'amenity: nightclub',
'shop: hairdresser', 'shop: clothes', 'shop: shoes', 'shop: jewelry',
'shop: bakery', 'shop: butcher', 'shop: coffee', 'shop: tea',
'shop: cosmetics', 'shop: beauty', 'shop: tattoo', 'shop: pet',
'shop: bicycle', 'shop: bookstore', 'shop: convenience',
'shop: department_store', 'shop: gift', 'shop: hardware',
'shop: hifi', 'shop: optician', 'shop: photo', 'shop: stationery',
'shop: supermarket', 'shop: variety_store',
'leisure: fitness_centre', 'leisure: dance', 'leisure: bowling_alley',
'amenity: dentist', 'amenity: pharmacy', 'amenity: veterinary',
'tourism: hotel', 'tourism: motel', 'tourism: apartment',
'amenity: car_wash', 'shop: car_parts',
];
interface Row { id: number; name: string; city: string | null }
async function pick(limit: number, mode: 'high_value' | 'all'): Promise<Row[]> {
if (mode === 'high_value') {
const conds = HIGH_VALUE_CATEGORY_PREFIXES.map((_, i) => `category ILIKE $${i + 2}`).join(' OR ');
const params: any[] = [limit, ...HIGH_VALUE_CATEGORY_PREFIXES.map(p => p + '%')];
const r = await query<Row>(`
SELECT id, name, city FROM businesses
WHERE on_corridor AND website IS NULL
AND length(name) >= 5
AND name NOT LIKE '% LLC'
AND name NOT ILIKE 'POST OFFICE BOX%'
AND (${conds})
ORDER BY id
LIMIT $1
`, params);
return r.rows;
}
const r = await query<Row>(`
SELECT id, name, city FROM businesses
WHERE on_corridor AND website IS NULL
AND length(name) >= 5
AND name NOT LIKE '% LLC'
AND name NOT ILIKE 'POST OFFICE BOX%'
ORDER BY id LIMIT $1
`, [limit]);
return r.rows;
}
async function ddgSearch(q: string): Promise<string[]> {
// html.duckduckgo.com renders <a class="result__a"> with the target URL in
// the href (as a redirect through /l/?uddg=...). We unwrap that.
const url = 'https://html.duckduckgo.com/html/?q=' + encodeURIComponent(q);
const res = await fetch(url, {
headers: { 'User-Agent': UA, 'Accept': 'text/html', 'Accept-Language': 'en-US,en;q=0.9' },
redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT_MS)
});
if (!res.ok) throw new Error(`ddg ${res.status}`);
const html = await res.text();
const $ = cheerio.load(html);
const out: string[] = [];
$('a.result__a').each((_, el) => {
const href = String($(el).attr('href') || '');
if (!href) return;
// DDG sometimes returns absolute URLs, sometimes wraps them in /l/?uddg=...
let abs = href;
try {
const u = new URL(href, 'https://duckduckgo.com');
const uddg = u.searchParams.get('uddg');
if (uddg) abs = decodeURIComponent(uddg);
} catch {}
if (/^https?:\/\//i.test(abs)) out.push(abs);
});
return out.slice(0, 12);
}
async function main() {
const argLimit = process.argv.find(a => a.startsWith('--limit='));
const limit = argLimit ? parseInt(argLimit.split('=')[1], 10) : 30;
const mode = process.argv.includes('--all') ? 'all' : 'high_value';
const queue = await pick(limit, mode);
console.log(`[ddg] enriching ${queue.length} rows · category=${mode} · throttling ${GAP_MS}ms/query`);
let ok = 0, miss = 0, err = 0;
for (let i = 0; i < queue.length; i++) {
const row = queue[i];
const q = `"${row.name}" Ventura Blvd ${row.city || 'Sherman Oaks'}`;
try {
const urls = await ddgSearch(q);
const winner = urls.find(u => {
if (!credible(u)) return false;
const h = host(u);
return h && domainMatchesName(h, row.name);
});
if (winner) {
const h = host(winner);
const website = 'https://' + h;
await query(`UPDATE businesses SET website = $1, last_seen_at = now() WHERE id = $2`, [website, row.id]);
ok++;
console.log(` [${String(i + 1).padStart(3)}/${queue.length}] ✓ ${row.name.padEnd(40).slice(0, 40)} → ${website}`);
} else {
miss++;
console.log(` [${String(i + 1).padStart(3)}/${queue.length}] · ${row.name.padEnd(40).slice(0, 40)} (no credible match)`);
}
} catch (e: any) {
err++;
console.log(` [${String(i + 1).padStart(3)}/${queue.length}] ⚠ ${row.name.slice(0, 40)} ${e.message?.slice(0, 60)}`);
}
await new Promise(r => setTimeout(r, GAP_MS));
}
console.log(`[ddg] done · ${ok} found · ${miss} no-result · ${err} errors`);
await pool.end();
}
main().catch(e => { console.error(e); pool.end(); process.exit(1); });