← back to Ventura Claw Leads
scripts/seed-corridor-food.js
254 lines
#!/usr/bin/env node
/**
* seed-corridor-food.js
*
* Seed the food vertical on /find?vertical=food using public Ventura corridor
* data (OSM POIs + LA City Business Tax Registration Certificates) already
* normalized into the `ventura_corridor` PG database (~/Projects/ventura).
*
* - OSM source: 89 hand-tagged restaurants/cafes/bars on the Boulevard with
* names, websites, phones, cuisine — clean signal.
* - la_btrc source: ~575 NAICS-classified food businesses with tax-cert addr.
* We filter on `category_raw` (NAICS label), not the noisy fuzzy-matched
* `category`, so "Barber shops" don't sneak in as bars.
*
* Output: rows in ventura_claw_leads.businesses with vertical='food',
* tier='free', claim_status='unclaimed', source='public_records'. These are
* directory shells — businesses claim them via the standard /claim flow.
*/
require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
const slugify = require('slugify');
const { Pool } = require('pg');
// Source DB: corridor ingest (~/Projects/ventura)
const corridor = new Pool({
host: process.env.PGHOST || 'localhost',
port: parseInt(process.env.PGPORT || '5432', 10),
database: 'ventura_corridor',
user: process.env.PGUSER || process.env.USER,
max: 4
});
// Target DB: ventura-claw-leads
const target = require('../lib/db');
// LA City BTRC NAICS labels we accept as "food vertical".
// Excludes "Barber shops" (286 false positives), "Other direct selling"
// (multi-level marketing), and "Other food mfg." (manufacturers, not retail).
const BTRC_FOOD_LABELS = new Set([
'Full-service restaurants',
'Full-Service Restaurants',
'Limited-Service Restaurants',
'Special food services (including food service contractors & caterers)',
'Snack and Nonalcoholic Beverage Bars',
'Drinking places (alcoholic beverages)',
'Coffee and Tea Manufacturing',
'Caterers',
'Food Service Contractors',
'Mobile Food Services'
]);
// Map LA-area city values (corridor data is mixed-case + UPPERCASE) to the
// canonical Title Case used by ventura-claw-leads view templates.
const CITY_MAP = {
'sherman oaks': 'Sherman Oaks',
'studio city': 'Studio City',
'encino': 'Encino',
'tarzana': 'Tarzana',
'woodland hills': 'Woodland Hills',
'woodland hls': 'Woodland Hills'
};
// ZIP fallback when city is missing — Ventura Blvd ZIPs only.
const ZIP_TO_CITY = {
'91423': 'Sherman Oaks',
'91403': 'Sherman Oaks',
'91604': 'Studio City',
'91436': 'Encino',
'91316': 'Encino',
'91356': 'Tarzana',
'91364': 'Woodland Hills'
};
const TARGET_CITIES = new Set(Object.values(CITY_MAP));
function normalizeCity(raw, zip) {
if (raw) {
const key = String(raw).toLowerCase().split(';')[0].trim();
if (CITY_MAP[key]) return CITY_MAP[key];
}
if (zip) {
const z = String(zip).slice(0, 5);
if (ZIP_TO_CITY[z]) return ZIP_TO_CITY[z];
}
return null;
}
// "JAMBA JUICE #8" -> "Jamba Juice #8". Keep numbers/punct, just titlecase
// each whitespace-separated word. la_btrc rows arrive ALL CAPS.
function titleCaseName(name) {
if (!name) return name;
if (name === name.toLowerCase() || name !== name.toUpperCase()) return name;
return name.toLowerCase().replace(/\b([a-z])/g, c => c.toUpperCase());
}
// Same idea for street addresses.
function titleCaseStreet(s) {
if (!s) return s;
if (s !== s.toUpperCase()) return s;
return s.toLowerCase().replace(/\b([a-z])/g, c => c.toUpperCase());
}
// Build a per-row headline. For OSM rows we have cuisine + amenity tags; for
// BTRC rows we have NAICS label. Either gives us a one-line description that
// reads better than blank.
function buildHeadline({ name, category, raw, category_raw }) {
const tags = (raw && raw.tags) || {};
const cuisine = tags.cuisine ? tags.cuisine.replace(/[_;]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) : null;
const isCafe = category === 'amenity: cafe';
const isBar = category === 'amenity: bar';
const isFast = category === 'amenity: fast food';
const isRest = category === 'amenity: restaurant';
if (cuisine && isRest) return `${cuisine} restaurant on Ventura Blvd.`;
if (cuisine && isFast) return `${cuisine} — quick-serve on the Boulevard.`;
if (isCafe) return `Coffee + light fare on Ventura Blvd.`;
if (isBar) return `Neighborhood bar on the Boulevard.`;
if (isRest) return `Sit-down restaurant on Ventura Blvd.`;
if (isFast) return `Quick-serve on the Boulevard.`;
if (category_raw) {
if (/Coffee|Tea/i.test(category_raw)) return `Coffee + tea on Ventura Blvd.`;
if (/Caterer|Food Service Contractor/i.test(category_raw))
return `Caterer + food-service on the Boulevard.`;
if (/Drinking places/i.test(category_raw)) return `Bar on Ventura Blvd.`;
if (/Limited-Service/i.test(category_raw)) return `Quick-serve on Ventura Blvd.`;
if (/Snack|Beverage Bars/i.test(category_raw))
return `Snacks + drinks on the Boulevard.`;
if (/Mobile Food/i.test(category_raw)) return `Mobile food vendor on Ventura Blvd.`;
}
return `Local food + drink on Ventura Blvd.`;
}
// Skip rows whose name is obviously a person (la_btrc has a chunk of
// owner-name registrations like "DIANA ROBINSON" mis-tagged as bars). Also
// skip pure numerical / placeholder names.
function isPersonNameOrJunk(name) {
if (!name) return true;
if (name.length < 3) return true;
// "FIRSTNAME LASTNAME" all-caps with no digits and no business-y suffix
const looksLikePerson = /^[A-Z]+ [A-Z]+(?:[ ,]+[A-Z]+)?$/.test(name) &&
!/(LLC|INC|CORP|CO|RESTAURANT|CAFE|GRILL|KITCHEN|BAR|FOOD|BAKERY|PIZZA|BURGER|TACO|TAQUERIA|BISTRO|BBQ|JUICE|SUSHI|RAMEN|DELI|CATERING|COFFEE|TEA|PANINI|SANDWICH)/i.test(name);
if (looksLikePerson) return true;
// Generic strip-mall placeholders sometimes appear
if (/^(SUITE|UNIT|TBD|N\/A|UNKNOWN)/i.test(name)) return true;
return false;
}
(async () => {
console.log('[seed-corridor-food] pulling food rows from ventura_corridor…');
// OSM-source rows (clean POIs)
const osmSql = `
SELECT name, category, category_raw, address, street_number, street_name, city, zip,
lat, lng, phone, website, raw
FROM businesses
WHERE on_corridor = true
AND merged_into IS NULL
AND source = 'osm'
AND category IN ('amenity: restaurant','amenity: cafe','amenity: bar','amenity: fast food')
`;
// la_btrc rows filtered on NAICS labels (avoids the "Barber shops"->bar bug)
const btrcSql = `
SELECT name, category, category_raw, address, street_number, street_name, city, zip,
lat, lng, phone, website, raw
FROM businesses
WHERE on_corridor = true
AND merged_into IS NULL
AND source = 'la_btrc'
AND category_raw = ANY($1::text[])
`;
const [{ rows: osmRows }, { rows: btrcRows }] = await Promise.all([
corridor.query(osmSql),
corridor.query(btrcSql, [Array.from(BTRC_FOOD_LABELS)])
]);
console.log(`[seed-corridor-food] pulled ${osmRows.length} osm rows + ${btrcRows.length} btrc rows`);
// OSM is higher-trust signal — ingest first so subsequent BTRC dups are skipped.
const all = [...osmRows, ...btrcRows];
const seenSlug = new Set();
const seenNameAddr = new Set(); // dedupe key: lower(name)|street_number+street_name|zip
let inserted = 0, skipped = 0, conflicted = 0, dropped_junk = 0, dropped_city = 0;
for (const r of all) {
const cleanName = titleCaseName(r.name);
if (isPersonNameOrJunk(r.name)) { dropped_junk++; continue; }
const city = normalizeCity(r.city, r.zip);
if (!city || !TARGET_CITIES.has(city)) { dropped_city++; continue; }
const street = titleCaseStreet(r.address || [r.street_number, r.street_name].filter(Boolean).join(' '));
if (!street) { dropped_junk++; continue; }
// Strip city/zip suffix from address ("14704 Ventura Boulevard Sherman Oaks 91403")
let cleanStreet = street;
cleanStreet = cleanStreet.replace(new RegExp(`\\s+${city}\\s+\\d{5}(-\\d{4})?$`, 'i'), '');
cleanStreet = cleanStreet.replace(/\s+\d{5}(-\d{4})?$/, '');
const dedupeKey = `${cleanName.toLowerCase()}|${cleanStreet.toLowerCase()}|${(r.zip||'').slice(0,5)}`;
if (seenNameAddr.has(dedupeKey)) { skipped++; continue; }
seenNameAddr.add(dedupeKey);
// Slug must be globally unique. Append city for collision safety
// (Chipotle on Ventura appears in 3 cities).
let slug = slugify(`${cleanName} ${city}`, { lower: true, strict: true });
if (seenSlug.has(slug)) {
// Disambiguate with last 4 of zip + first 3 of street number
const sn = (r.street_number || '').replace(/\D/g, '').slice(0, 4) || '0';
slug = `${slug}-${sn}`;
}
seenSlug.add(slug);
const headline = buildHeadline(r);
const description = `${cleanName} is a food & drink business on the Ventura Blvd corridor in ${city}. ${headline}`;
const phone = r.phone ? r.phone.replace(/^\+1[-\s]?/, '').trim() : null;
const website = r.website || null;
const zip = (r.zip || '').slice(0, 10) || null;
try {
const ins = await target.query(
`INSERT INTO businesses
(slug, business_name, vertical, headline, description, street, city, state, zip, neighborhood,
latitude, longitude, phone, website,
tier, claim_status, verified, status, source)
VALUES ($1,$2,'food',$3,$4,$5,$6,'CA',$7,$8,$9,$10,$11,$12,
'free','unclaimed',false,'active','public_records')
ON CONFLICT (slug) DO NOTHING
RETURNING id`,
[slug, cleanName, headline, description, cleanStreet, city, zip, city,
r.lat, r.lng, phone, website]
);
if (ins.rows.length) inserted++;
else conflicted++;
} catch (err) {
console.error(`[seed-corridor-food] insert failed for "${cleanName}": ${err.message}`);
skipped++;
}
}
console.log(`[seed-corridor-food] inserted=${inserted} skipped(dup)=${skipped} conflicted(slug-exists)=${conflicted} dropped_junk=${dropped_junk} dropped_city=${dropped_city}`);
await corridor.end();
await target.pool.end();
process.exit(0);
})().catch(err => {
console.error('[seed-corridor-food] fatal', err);
process.exit(1);
});