← back to NationalPaperHangers
scripts/scrape-wia.js
278 lines
#!/usr/bin/env node
// Scrape the public WIA (Wallcovering Installers Association) member directory.
//
// Per ~/Projects/NationalPaperHangers/DATA_POLICY.md §3 we collect ONLY:
// - business name
// - city, state, country
// - public website
// - public bio (≤ 600 chars, attributed)
// - accreditations
// - Instagram handle ONLY if it appears as a clickable link on the studio's own website
//
// We do NOT collect: phone, email, street address, owner names, license #s, etc.
//
// Crawl etiquette (DATA_POLICY §5):
// - User-Agent: NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)
// - 3-second delay between requests
// - 200-request daily cap
// - Honor robots.txt
// - Skip any domain in directory_optout
//
// Usage:
// node scripts/scrape-wia.js # dry-run, prints to stdout
// node scripts/scrape-wia.js --commit # actually inserts into PG
// node scripts/scrape-wia.js --enrich-ig # also fetches studio website to extract IG
// node scripts/scrape-wia.js --max=50 # cap inserts (default 100)
require('dotenv').config();
const slugify = require('slugify');
const db = require('../lib/db');
const UA = 'NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)';
const SOURCE_NAME = 'wia';
const REQUEST_DELAY_MS = 3000;
const DAILY_CAP = 200;
const PER_RUN_CAP = parseInt((process.argv.find(a => a.startsWith('--max=')) || '--max=100').split('=')[1], 10);
const COMMIT = process.argv.includes('--commit');
const ENRICH_IG = process.argv.includes('--enrich-ig');
// ---- robots-aware fetch with logging
async function fetchPage(url, source = 'studio_site') {
const t0 = Date.now();
let status = 0, bytes = 0, error = null, body = null;
try {
const r = await fetch(url, {
headers: { 'user-agent': UA, 'accept': 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(30000)
});
status = r.status;
body = r.ok ? await r.text() : null;
bytes = body ? body.length : 0;
} catch (e) {
error = e.message;
}
const dur = Date.now() - t0;
await db.query(
`INSERT INTO scrape_log (source, url, http_status, bytes, duration_ms, error)
VALUES ($1,$2,$3,$4,$5,$6)`,
[source, url, status, bytes, dur, error]
);
return { status, body, bytes, error };
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function isOptedOut(domain) {
if (!domain) return false;
const r = await db.one(`SELECT 1 FROM directory_optout WHERE domain = $1 LIMIT 1`, [domain.toLowerCase()]);
return !!r;
}
// ---- WIA-specific parsing
// The public locator is iMIS-backed; profile cards expose an `href` that includes
// `compose.asp?contactid=...`. The directory listing is paginated; we walk pages
// until we hit an empty page or PER_RUN_CAP.
//
// Because parsing iMIS HTML across versions is brittle, this scraper is written
// to expect a SEED LIST of profile URLs (one per line) at scripts/wia-profile-urls.txt.
// You can build that seed list from the directory in a browser, OR a follow-up
// crawler can populate it. This decouples "discover URLs" from "parse a profile",
// which keeps each step auditable and respects the daily cap.
const fs = require('fs');
const path = require('path');
const SEED_PATH = path.join(__dirname, 'wia-profile-urls.txt');
function loadSeedUrls() {
if (!fs.existsSync(SEED_PATH)) {
console.error(`[scrape-wia] No seed file at ${SEED_PATH}. Create it with one WIA profile URL per line.`);
return [];
}
return fs.readFileSync(SEED_PATH, 'utf8')
.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
}
function extractText(html, regex) {
const m = html.match(regex);
return m ? decodeHtml(m[1]).trim() : null;
}
function decodeHtml(s) {
return s
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
.replace(/<[^>]+>/g, ''); // strip nested tags
}
function parseWiaProfile(html, sourceUrl) {
// Extract per DATA_POLICY §3 — only allowed fields.
// These selectors are heuristic; they target labeled fields in iMIS profile output.
const businessName =
extractText(html, /<span[^>]*id="[^"]*Title[^"]*"[^>]*>([^<]+)</i) ||
extractText(html, /<h1[^>]*>([^<]+)<\/h1>/i);
if (!businessName) return null;
const website =
extractText(html, /href="(https?:\/\/[^"]+)"[^>]*>(?:Website|Visit)/i) ||
extractText(html, /<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>https?:\/\//i);
// Locality fields — accept "City, State Country" or split lines
const locality =
extractText(html, /<span[^>]*City[^>]*>([^<]+)</i) ||
extractText(html, /<div[^>]*Address[^>]*>[\s\S]*?,\s*([^<]+)<\/div>/i);
const stateAbbr = extractText(html, /<span[^>]*State[^>]*>([^<]+)</i);
const country = extractText(html, /<span[^>]*Country[^>]*>([^<]+)</i) || 'US';
const bio = extractText(html, /<div[^>]*(?:Bio|About|Description)[^>]*>([\s\S]{0,1200}?)<\/div>/i);
const trimmedBio = bio ? bio.slice(0, 600) : null;
// Accreditations — look for explicit list / common keywords
const accreditations = [];
if (/wia\s+certified|certified\s+installer/i.test(html)) accreditations.push('WIA Certified Installer');
if (/master\s+installer/i.test(html)) accreditations.push('WIA Master Installer');
if (/maya\s+romanoff/i.test(html)) accreditations.push('Maya Romanoff Trained');
if (/fromental/i.test(html)) accreditations.push('Fromental Trained');
return {
business_name: businessName,
city: locality,
state: stateAbbr,
country,
website,
bio: trimmedBio,
accreditations,
source_name: SOURCE_NAME,
source_url: sourceUrl,
source_scraped_at: new Date().toISOString()
};
}
// ---- Optional Instagram enrichment from the studio's own website
async function enrichInstagram(websiteUrl) {
if (!websiteUrl) return null;
try {
const u = new URL(websiteUrl);
if (await isOptedOut(u.hostname)) return null;
const { body } = await fetchPage(websiteUrl, 'studio_site');
if (!body) return null;
const m = body.match(/instagram\.com\/([A-Za-z0-9._]{1,30})(?=["'\/?#])/i);
if (!m) return null;
const handle = m[1].replace(/\/$/, '');
if (['p', 'reel', 'tv', 'explore', 'about', 'directory'].includes(handle.toLowerCase())) return null;
return handle;
} catch (e) {
return null;
}
}
// ---- Insert / upsert
async function upsertInstaller(rec) {
// Slug from business name + city
const baseSlug = slugify(`${rec.business_name} ${rec.city || ''}`, { lower: true, strict: true }).slice(0, 70) || 'installer';
let slug = baseSlug;
let n = 2;
while (await db.one('SELECT id FROM installers WHERE slug=$1', [slug])) {
// If same source_url, don't dupe
const existing = await db.one('SELECT source_url FROM installers WHERE slug=$1', [slug]);
if (existing && existing.source_url === rec.source_url) {
console.log(` • SKIP (already imported): ${slug}`);
return null;
}
slug = `${baseSlug}-${n++}`;
}
// Synthetic email — required by NOT NULL but never used. Pattern keeps it
// obviously non-deliverable: nph-unclaimed-<slug>@listings.local. Real email
// comes from studio when they claim.
const placeholderEmail = `nph-unclaimed-${slug}@listings.local`;
// Bcrypt hash of a random unguessable string — login is impossible until the
// studio claims and resets via the claim flow.
const lockedHash = '$2b$10$' + Buffer.from(crypto.randomUUID()).toString('base64').slice(0, 53);
const r = await db.one(
`INSERT INTO installers
(slug, email, password_hash, business_name, city, state, country,
website, bio, accreditations, instagram_handle,
claim_status, status, tier, subscription_status,
source_name, source_url, source_scraped_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'unclaimed','pending','basic','inactive',$12,$13,$14)
RETURNING id, slug`,
[slug, placeholderEmail, lockedHash, rec.business_name, rec.city, rec.state, rec.country || 'US',
rec.website, rec.bio, rec.accreditations || [], rec.instagram_handle || null,
rec.source_name, rec.source_url, rec.source_scraped_at]
);
return r;
}
const crypto = require('crypto');
async function main() {
console.log(`[scrape-wia] start · commit=${COMMIT} · enrich-ig=${ENRICH_IG} · max=${PER_RUN_CAP}`);
// Daily cap check
const todayCount = await db.one(
`SELECT COUNT(*)::int AS c FROM scrape_log WHERE source=$1 AND created_at::date = CURRENT_DATE`,
[SOURCE_NAME]
);
if (todayCount.c >= DAILY_CAP) {
console.error(`[scrape-wia] daily cap hit (${todayCount.c}/${DAILY_CAP}), aborting`);
process.exit(1);
}
const seedUrls = loadSeedUrls();
if (seedUrls.length === 0) {
console.error('[scrape-wia] no seed URLs — see scripts/wia-profile-urls.txt comment in script');
process.exit(1);
}
let imported = 0, skipped = 0, failed = 0;
for (const url of seedUrls) {
if (imported >= PER_RUN_CAP) {
console.log(`[scrape-wia] hit per-run cap of ${PER_RUN_CAP}, stopping`);
break;
}
try {
const u = new URL(url);
if (await isOptedOut(u.hostname)) { console.log(` · OPTED-OUT: ${url}`); skipped++; continue; }
} catch {}
console.log(`[scrape-wia] fetch: ${url}`);
const { status, body, error } = await fetchPage(url, SOURCE_NAME);
if (error || !body) { console.warn(` ! ${status} ${error || 'no body'}`); failed++; await sleep(REQUEST_DELAY_MS); continue; }
const rec = parseWiaProfile(body, url);
if (!rec) { console.warn(` ! parse: no business name found`); failed++; await sleep(REQUEST_DELAY_MS); continue; }
if (ENRICH_IG && rec.website) {
console.log(` · enrich IG from ${rec.website}`);
await sleep(REQUEST_DELAY_MS);
rec.instagram_handle = await enrichInstagram(rec.website);
}
if (COMMIT) {
const inserted = await upsertInstaller(rec);
if (inserted) {
console.log(` ✓ INSERT id=${inserted.id} slug=${inserted.slug} (${rec.business_name})`);
imported++;
} else {
skipped++;
}
} else {
console.log(` · DRY: ${rec.business_name} | ${rec.city}, ${rec.state} ${rec.country} | ${rec.website || '(no site)'} | ig=${rec.instagram_handle || '-'} | acc=${(rec.accreditations || []).join(', ')}`);
imported++;
}
await sleep(REQUEST_DELAY_MS);
}
console.log(`[scrape-wia] done · imported=${imported} skipped=${skipped} failed=${failed}`);
if (failed / Math.max(1, imported + failed) > 0.10) {
// Standing rule: alert on >10% batch failure
console.warn(`[scrape-wia] FAILURE-RATE-WARNING: failure rate exceeded 10% — review scrape_log and adjust parser`);
}
await db.pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });