← back to Ventura Corridor
src/ingest/here_discover.ts
233 lines
/**
* HERE Discover ingest — Ventura Blvd corridor.
*
* Strategy:
* 1. Walk a coarse polyline of Ventura Blvd west→east in ~600m steps.
* 2. For each anchor point, call HERE /discover with q="businesses" and a small radius.
* 3. UPSERT each unique result into businesses (source='here', source_id=HERE id).
* 4. Mark on_corridor=TRUE only when address.street matches /VENTURA BLVD/i.
* 5. Log the run in ingest_runs.
*
* Free tier: 30,000 requests/month. Each step = 1 request. ~70 steps = ~70 req → trivial cost.
*
* Run: npm run ingest:here
* or: tsx src/ingest/here_discover.ts -- --limit-steps=5 (smoke test)
*/
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';
const HERE_KEY = process.env.HERE_API_KEY;
if (!HERE_KEY) {
console.error('[here] missing HERE_API_KEY in .env. Get one at https://platform.here.com or via /secrets.');
process.exit(1);
}
// ─── Ventura Blvd polyline anchor points (approx, west → east) ──────────
// Pulled from OpenStreetMap centerline, sampled every ~600m. Covers
// Calabasas (Mulholland) → Woodland Hills → Tarzana → Encino → Sherman Oaks
// → Studio City → North Hollywood (Cahuenga). Total ~17 mi.
const ANCHORS: Array<{ lat: number; lng: number; city: string }> = [
// Calabasas / west end
{ lat: 34.1466, lng: -118.6086, city: 'Calabasas' },
{ lat: 34.1505, lng: -118.6005, city: 'Calabasas' },
{ lat: 34.1530, lng: -118.5915, city: 'Woodland Hills' },
{ lat: 34.1545, lng: -118.5810, city: 'Woodland Hills' },
{ lat: 34.1556, lng: -118.5705, city: 'Woodland Hills' },
{ lat: 34.1563, lng: -118.5605, city: 'Woodland Hills' },
{ lat: 34.1568, lng: -118.5500, city: 'Woodland Hills' },
{ lat: 34.1572, lng: -118.5400, city: 'Woodland Hills' },
{ lat: 34.1575, lng: -118.5305, city: 'Tarzana' },
{ lat: 34.1577, lng: -118.5210, city: 'Tarzana' },
{ lat: 34.1580, lng: -118.5115, city: 'Tarzana' },
{ lat: 34.1583, lng: -118.5025, city: 'Encino' },
{ lat: 34.1586, lng: -118.4930, city: 'Encino' },
{ lat: 34.1590, lng: -118.4835, city: 'Encino' },
{ lat: 34.1593, lng: -118.4740, city: 'Encino' },
{ lat: 34.1597, lng: -118.4645, city: 'Encino' },
{ lat: 34.1600, lng: -118.4550, city: 'Sherman Oaks' },
{ lat: 34.1604, lng: -118.4455, city: 'Sherman Oaks' },
{ lat: 34.1606, lng: -118.4360, city: 'Sherman Oaks' },
{ lat: 34.1599, lng: -118.4270, city: 'Sherman Oaks' },
{ lat: 34.1565, lng: -118.4200, city: 'Sherman Oaks' },
{ lat: 34.1505, lng: -118.4140, city: 'Studio City' },
{ lat: 34.1450, lng: -118.4070, city: 'Studio City' },
{ lat: 34.1421, lng: -118.3985, city: 'Studio City' },
{ lat: 34.1402, lng: -118.3895, city: 'Studio City' },
{ lat: 34.1385, lng: -118.3820, city: 'Studio City' },
{ lat: 34.1370, lng: -118.3760, city: 'North Hollywood' }, // Cahuenga junction (east end)
];
const RADIUS_M = 350; // overlap so we don't miss between anchors
const PER_STEP_LIMIT = 100; // HERE max items per call
const SLEEP_MS = 250; // polite throttle
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface HereItem {
id: string;
title: string;
resultType?: string;
address?: {
label?: string;
countryCode?: string;
state?: string;
city?: string;
district?: string;
street?: string;
houseNumber?: string;
postalCode?: string;
};
position?: { lat: number; lng: number };
contacts?: Array<{ phone?: Array<{ value: string }>; www?: Array<{ value: string }> }>;
categories?: Array<{ id: string; name: string; primary?: boolean }>;
}
function topCategory(item: HereItem): { name: string | null; raw: string | null } {
const cats = item.categories || [];
const primary = cats.find((c) => c.primary) || cats[0];
return { name: primary?.name || null, raw: primary?.id || null };
}
function normalizeStreet(s?: string): string | null {
if (!s) return null;
return s.toUpperCase().replace(/\s+/g, ' ').trim();
}
function isVenturaBlvd(s?: string): boolean {
if (!s) return false;
return /\bVENTURA\s+B(L)?V?D\b/i.test(s) || /\bVENTURA\s+BOULEVARD\b/i.test(s);
}
async function fetchStep(anchor: { lat: number; lng: number }): Promise<HereItem[]> {
const url = new URL('https://discover.search.hereapi.com/v1/discover');
url.searchParams.set('apiKey', HERE_KEY!);
url.searchParams.set('at', `${anchor.lat},${anchor.lng}`);
url.searchParams.set('q', 'business');
url.searchParams.set('limit', String(PER_STEP_LIMIT));
url.searchParams.set('in', `circle:${anchor.lat},${anchor.lng};r=${RADIUS_M}`);
const r = await fetch(url, { headers: { 'User-Agent': 'ventura-corridor/0.1 (+local)' } });
if (!r.ok) {
const txt = await r.text().catch(() => '');
throw new Error(`HERE ${r.status} ${r.statusText}: ${txt.slice(0, 200)}`);
}
const j = await r.json() as { items?: HereItem[] };
return j.items || [];
}
async function upsertBusiness(item: HereItem, anchorCity: string): Promise<'new' | 'updated'> {
const cat = topCategory(item);
const street = normalizeStreet(item.address?.street);
const onCorridor = isVenturaBlvd(item.address?.street) ||
isVenturaBlvd(item.address?.label);
const block = item.address?.houseNumber
? Math.floor(parseInt(item.address.houseNumber, 10) / 100) * 100 || null
: null;
const phone = item.contacts?.[0]?.phone?.[0]?.value || null;
const website = item.contacts?.[0]?.www?.[0]?.value || null;
const r = await query<{ id: number; first_seen_at: string }>(`
INSERT INTO businesses (
source, source_id, name, category, category_raw,
address, street_number, street_name, city, zip, lat, lng,
phone, website, on_corridor, corridor_block, raw, last_seen_at
) VALUES (
'here', $1, $2, $3, $4,
$5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, NOW()
)
ON CONFLICT (source, source_id) DO UPDATE SET
name = EXCLUDED.name,
category = EXCLUDED.category,
category_raw = EXCLUDED.category_raw,
address = EXCLUDED.address,
street_number = EXCLUDED.street_number,
street_name = EXCLUDED.street_name,
city = COALESCE(EXCLUDED.city, businesses.city),
zip = COALESCE(EXCLUDED.zip, businesses.zip),
lat = EXCLUDED.lat,
lng = EXCLUDED.lng,
phone = COALESCE(EXCLUDED.phone, businesses.phone),
website = COALESCE(EXCLUDED.website, businesses.website),
on_corridor = EXCLUDED.on_corridor OR businesses.on_corridor,
corridor_block = COALESCE(EXCLUDED.corridor_block, businesses.corridor_block),
raw = EXCLUDED.raw,
last_seen_at = NOW()
RETURNING id, first_seen_at
`, [
item.id,
item.title,
cat.name,
cat.raw,
item.address?.label || null,
item.address?.houseNumber || null,
street,
item.address?.city || anchorCity,
item.address?.postalCode || null,
item.position?.lat ?? null,
item.position?.lng ?? null,
phone,
website,
onCorridor,
block,
JSON.stringify(item),
]);
// Same-second first_seen_at means this row was just inserted.
return Date.parse(r.rows[0].first_seen_at) > Date.now() - 5_000 ? 'new' : 'updated';
}
async function main() {
const argLimit = process.argv.find((a) => a.startsWith('--limit-steps='));
const stepLimit = argLimit ? parseInt(argLimit.split('=')[1], 10) : ANCHORS.length;
const run = await query<{ id: number }>(
`INSERT INTO ingest_runs (source, notes) VALUES ('here', $1) RETURNING id`,
[`Ventura Blvd corridor — ${stepLimit}/${ANCHORS.length} anchors`],
);
const runId = run.rows[0].id;
console.log(`[here] ingest_run #${runId} starting · ${stepLimit} anchors · ${RADIUS_M}m radius`);
let inTotal = 0, newTotal = 0, updTotal = 0, errMessage: string | null = null;
for (let i = 0; i < stepLimit; i++) {
const a = ANCHORS[i];
process.stdout.write(`[step ${i + 1}/${stepLimit}] ${a.city.padEnd(16)} ${a.lat.toFixed(4)},${a.lng.toFixed(4)} · `);
try {
const items = await fetchStep(a);
let stepNew = 0, stepUpd = 0;
for (const it of items) {
const v = await upsertBusiness(it, a.city);
if (v === 'new') stepNew++; else stepUpd++;
}
inTotal += items.length;
newTotal += stepNew;
updTotal += stepUpd;
console.log(`fetched ${items.length} · new ${stepNew} · upd ${stepUpd}`);
} catch (e: any) {
console.log(`ERROR: ${e.message}`);
errMessage = e.message;
break;
}
if (i < stepLimit - 1) await sleep(SLEEP_MS);
}
await query(
`UPDATE ingest_runs SET finished_at=NOW(), rows_in=$1, rows_new=$2, rows_updated=$3, error_message=$4 WHERE id=$5`,
[inTotal, newTotal, updTotal, errMessage, runId],
);
// Summary
const corridor = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor`,
);
console.log('');
console.log(`[here] done. fetched ${inTotal} · new ${newTotal} · updated ${updTotal}`);
console.log(`[here] businesses on Ventura Blvd: ${corridor.rows[0].n}`);
await pool.end();
}
main().catch((e) => {
console.error('[here]', e);
process.exit(1);
});