← back to Stars of Design

scrapers/seed-top-firms.js

88 lines

#!/usr/bin/env node
'use strict';
// Seed the top US architecture + interior design firms as sod_firms anchors
// for the StarsOfDesign directory. Deterministic — no scraping. Source data
// lives in data/top-us-firms.json (public facts: name, city, founded year,
// website, public LinkedIn company page slug). Logos stay made-with-ai
// placeholders until the firm claims its profile.
//
// Idempotent. Re-running updates existing rows but never duplicates.

require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
  max: 4,
});

function slugify(s) {
  return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100);
}

async function upsertFirm(f, kind) {
  const slug = slugify(f.name);
  const r = await pool.query(`
    INSERT INTO sod_firms
      (slug, name, city, state_or_region, founded_year, size_band, website, bio, styles, logo_source)
    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'made-with-ai')
    ON CONFLICT (slug) DO UPDATE SET
      name            = EXCLUDED.name,
      city            = COALESCE(sod_firms.city, EXCLUDED.city),
      state_or_region = COALESCE(sod_firms.state_or_region, EXCLUDED.state_or_region),
      founded_year    = COALESCE(sod_firms.founded_year, EXCLUDED.founded_year),
      size_band       = COALESCE(sod_firms.size_band, EXCLUDED.size_band),
      website         = COALESCE(sod_firms.website, EXCLUDED.website),
      styles          = COALESCE(sod_firms.styles, EXCLUDED.styles),
      updated_at      = NOW()
    RETURNING id, (xmax = 0) AS inserted`,
    [slug, f.name, f.city, f.state, f.founded, f.size_band, f.website,
     `Top US ${kind} firm. ${f.styles ? 'Known for ' + f.styles.join(' / ') + '.' : ''}`.trim(),
     f.styles || []]);
  const firmId = r.rows[0].id;
  const inserted = r.rows[0].inserted;
  if (f.linkedin_company) {
    await pool.query(`
      INSERT INTO sod_links (firm_id, url, kind, added_by)
      VALUES ($1, $2, 'linkedin', 'seed-top-firms')
      ON CONFLICT DO NOTHING`,
      [firmId, `https://www.linkedin.com/company/${f.linkedin_company}/`]);
  }
  if (f.website) {
    await pool.query(`
      INSERT INTO sod_links (firm_id, url, kind, added_by)
      VALUES ($1, $2, 'firm-site', 'seed-top-firms')
      ON CONFLICT DO NOTHING`,
      [firmId, f.website]);
  }
  return inserted;
}

async function main() {
  const ir = await pool.query(
    `INSERT INTO sod_ingest_runs (run_kind, source, status) VALUES ('seed-top-firms', 'data/top-us-firms.json', 'running') RETURNING id`
  );
  const runId = ir.rows[0].id;
  const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'top-us-firms.json'), 'utf8'));
  let archCreated = 0, archUpdated = 0;
  for (const f of data.architecture_firms) {
    if (await upsertFirm(f, 'architecture')) archCreated++; else archUpdated++;
  }
  let intCreated = 0, intUpdated = 0;
  for (const f of data.interior_design_firms) {
    if (await upsertFirm(f, 'interior design')) intCreated++; else intUpdated++;
  }
  await pool.query(
    `UPDATE sod_ingest_runs SET status='ok', finished_at=NOW(), records_in=$1, records_out=$2, notes=$3 WHERE id=$4`,
    [data.architecture_firms.length + data.interior_design_firms.length,
     archCreated + intCreated,
     `arch:+${archCreated}/~${archUpdated}  interior:+${intCreated}/~${intUpdated}`,
     runId]
  );
  console.log(`[seed-top-firms] arch:+${archCreated}/~${archUpdated}  interior:+${intCreated}/~${intUpdated}`);
  await pool.end();
}
main().catch(e => { console.error('fatal:', e); process.exit(1); });