← back to Rentv Licensed Targets

scrapers/az_state_bar.mjs

203 lines

#!/usr/bin/env node
/**
 * az_state_bar.mjs — $0 local-Playwright scraper for Arizona real-estate attorneys
 * from the State Bar of Arizona public Member Directory.
 *
 * Source (discovered 2026-08-06):
 *   Public directory UI:
 *     https://azbar.org/for-legal-professionals/practice-tools-management/member-directory/
 *   Backing API (public, no login):
 *     POST https://api-proxy.azbar.org/MemberSearch/Search/?PageSize=N&Page=P
 *     JSON body carries the source-side filters:
 *       Specialization:"RE"  = Real Estate Law
 *       County:"07"          = Maricopa (Phoenix/Scottsdale metro)
 *     The endpoint needs a PUBLIC tools token (userid/password) that the directory
 *     page ships in its own JS — this scraper extracts it live at runtime (see main())
 *     rather than hardcoding it, so no secret-shaped string is committed.
 *
 * Filters AT THE SOURCE — pulls only the RE + Maricopa filtered set, never all 20k+ AZ attorneys.
 * Polite: ~1 req/s, headless, one session.
 *
 * HONESTY: writes ONLY fields the directory actually returns. No fabricated
 * bar numbers, emails, phones, firms, or websites. Empty stays empty.
 *
 * Usage:
 *   node scrapers/az_state_bar.mjs                       # RE + Maricopa (default), -> data/az_state_bar.csv
 *   node scrapers/az_state_bar.mjs --spec RE --county 07 # explicit
 *   node scrapers/az_state_bar.mjs --out data/foo.csv
 *
 * Reusable: change --spec / --county to retarget any AZ Bar practice area / county.
 * Practice-area codes: AL BY CD CR ET FL PI RE TX WC
 * County codes: Apache 01, Cochise 02, Coconino 03, Gila 04, Graham 05, Greenlee 06,
 *   La Paz 15, Maricopa 07, Mohave 08, Navajo 09, Out of State 00, Pima 10, Pinal 11,
 *   Santa Cruz 12, Yavapai 13, Yuma 14
 */
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJ = resolve(__dirname, '..');

// ---- args ----
const argv = process.argv.slice(2);
const getArg = (k, d) => { const i = argv.indexOf('--' + k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
const SPEC = getArg('spec', 'RE');       // Real Estate Law
const COUNTY = getArg('county', '07');   // Maricopa
const OUT = resolve(PROJ, getArg('out', 'data/az_state_bar.csv'));
const PAGE_SIZE = parseInt(getArg('pagesize', '25'), 10);
const POLITE_MS = parseInt(getArg('delay', '1100'), 10); // ~1 req/s

const DIR_URL = 'https://azbar.org/for-legal-professionals/practice-tools-management/member-directory/';
const API = 'https://api-proxy.azbar.org/MemberSearch/Search/';
// userid/password are a PUBLIC tools token baked into the member-directory page for
// its own search widget — extracted live from the page at runtime (see main()) so no
// secret-shaped string is committed to git and the scraper survives token rotation.
const HEADERS = { 'Content-Type': 'application/json; charset=UTF-8' };

const COUNTY_NAME = {
  '01': 'Apache', '02': 'Cochise', '03': 'Coconino', '04': 'Gila', '05': 'Graham',
  '06': 'Greenlee', '15': 'La Paz', '07': 'Maricopa', '08': 'Mohave', '09': 'Navajo',
  '00': 'Out of State', '10': 'Pima', '11': 'Pinal', '12': 'Santa Cruz', '13': 'Yavapai', '14': 'Yuma',
};

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// CSV columns match DB table rentv_licensed_targets
const COLS = ['source', 'role', 'entity_name', 'contact_name', 'license_no', 'license_type',
  'license_status', 'address', 'city', 'county', 'state', 'zip', 'phone', 'website',
  'market', 'commercial_flag', 'within_300mi', 'source_url'];

function csvCell(v) {
  if (v === null || v === undefined) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

function mapRow(m) {
  const a = m.Address || {};
  const contact = [m.FirstName, m.MiddleName, m.LastName].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
  const firm = (m.Company || '').trim();
  // phone: directory returns PhoneNumbers[] (often empty) + PrimaryPhone (often empty). Only use what's real.
  let phone = (m.PrimaryPhone || '').trim();
  if (!phone && Array.isArray(m.PhoneNumbers) && m.PhoneNumbers.length) {
    const first = m.PhoneNumbers[0];
    phone = (typeof first === 'string' ? first : (first?.Number || first?.PhoneNumber || '')).trim();
  }
  const addr = [a.Address1, a.Address2].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
  return {
    source: 'az_state_bar',
    role: 'Attorney',
    entity_name: firm || contact,          // firm, or same as contact if solo
    contact_name: contact,
    license_no: (m.BarNumber || '').trim(), // AZ bar number
    license_type: 'AZ Bar',
    license_status: (m.MemberStatus || '').trim(),
    address: addr,
    city: (a.City || '').trim(),
    county: (a.County || COUNTY_NAME[COUNTY] || '').trim(),
    state: 'AZ',
    zip: (a.Zip || '').trim(),
    phone,
    website: '',                            // directory does not expose a website field
    market: 'Arizona',
    commercial_flag: 'true',                // real-estate practice = CRE-relevant
    within_300mi: 'true',                   // Maricopa/Scottsdale within 300mi-of-Scottsdale scope
    source_url: DIR_URL,
  };
}

async function main() {
  const specLabel = SPEC === 'RE' ? 'Real Estate Law' : SPEC;
  console.log(`[az_state_bar] spec=${SPEC} (${specLabel}) county=${COUNTY} (${COUNTY_NAME[COUNTY] || '?'}) pageSize=${PAGE_SIZE}`);

  const browser = await chromium.launch({ headless: true });
  const ctx = await browser.newContext();
  const page = await ctx.newPage();
  page.setDefaultTimeout(60000);

  // Establish origin/referer by loading the real directory page (also proves basic search isn't login-walled).
  await page.goto(DIR_URL, { waitUntil: 'domcontentloaded' });
  await sleep(1500);

  // Extract the PUBLIC search-widget auth (userid/password) live from the page — a
  // public token the page ships for its own directory search; runtime-fetching it
  // keeps the secret out of git and survives rotation.
  const html = await page.content();
  // The widget sets them via: setRequestHeader("userid","publictools"); setRequestHeader("password","<token>")
  const uid = (html.match(/setRequestHeader\(\s*["']userid["']\s*,\s*["']([^"']+)["']/i) || [])[1];
  const pw = (html.match(/setRequestHeader\(\s*["']password["']\s*,\s*["']([^"']+)["']/i) || [])[1];
  if (uid) HEADERS.userid = uid;
  if (pw) HEADERS.password = pw;
  if (!HEADERS.userid || !HEADERS.password) {
    console.error('[az_state_bar] FATAL: could not extract public search auth from the directory page — the widget markup likely changed');
    process.exit(1);
  }

  const bodyBase = {
    Type: 'Member', Firm: '', FuzzySearch: false, IncludeDeceased: false,
    FirstName: '', LastName: '', City: '', State: '', Zip: '',
    County: COUNTY, LanguageCode: '', Section: '', LegalNeed: '',
    Specialization: SPEC, JurisdictionCode: '', LawSchool: '',
  };

  const seen = new Set(); // dedupe by EntityNumber
  const rows = [];
  let total = null;
  let pageNo = 1;

  while (true) {
    const url = `${API}?PageSize=${PAGE_SIZE}&Page=${pageNo}&Shuffle=false&Seed=null`;
    const res = await page.evaluate(async ({ url, headers, body }) => {
      const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
      let text = '';
      try { text = await r.text(); } catch { /* */ }
      return { status: r.status, text };
    }, { url, headers: HEADERS, body: bodyBase });

    if (res.status !== 200) {
      console.error(`[az_state_bar] page ${pageNo} HTTP ${res.status} — stopping. Body: ${res.text?.slice(0, 300)}`);
      break;
    }
    let json;
    try { json = JSON.parse(res.text); } catch (e) {
      console.error(`[az_state_bar] page ${pageNo} JSON parse fail — stopping.`); break;
    }
    if (!json.IsSuccess || !json.Result) {
      console.error(`[az_state_bar] page ${pageNo} not success (${json.Message || json.Error}) — stopping.`); break;
    }
    if (total === null) { total = json.Result.TotalCount; console.log(`[az_state_bar] TotalCount = ${total}`); }

    const batch = json.Result.Results || [];
    if (!batch.length) break;

    for (const m of batch) {
      const id = m.EntityNumber ?? `${m.BarNumber}-${m.LastName}`;
      if (seen.has(id)) continue;
      seen.add(id);
      rows.push(mapRow(m));
    }
    console.log(`[az_state_bar] page ${pageNo}: +${batch.length} (running ${rows.length}/${total})`);

    if (rows.length >= (total ?? 0) || batch.length < PAGE_SIZE) break;
    pageNo += 1;
    await sleep(POLITE_MS); // polite ~1 req/s
  }

  await browser.close();

  // write CSV
  mkdirSync(dirname(OUT), { recursive: true });
  const lines = [COLS.join(',')];
  for (const r of rows) lines.push(COLS.map((c) => csvCell(r[c])).join(','));
  writeFileSync(OUT, lines.join('\n') + '\n');

  console.log(`[az_state_bar] wrote ${rows.length} rows -> ${OUT}`);
  if (total !== null && rows.length !== total) {
    console.warn(`[az_state_bar] NOTE: rows (${rows.length}) != TotalCount (${total}).`);
  }
}

main().catch((e) => { console.error('[az_state_bar] FATAL', e); process.exit(1); });