[object Object]

← back to Rentv 2026

pr-intelligence: headless team-page extractor (local Playwright → audited prod API), ranked leadership-first link selection; name-gate stoplist extended with UI verb+noun leaks

d37f9138defad175189536b3967e2bef3269b9e5 · 2026-07-30 19:37:21 -0700 · Steve Abrams

Files touched

Diff

commit d37f9138defad175189536b3967e2bef3269b9e5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 19:37:21 2026 -0700

    pr-intelligence: headless team-page extractor (local Playwright → audited prod API), ranked leadership-first link selection; name-gate stoplist extended with UI verb+noun leaks
---
 src/pr/adapters/website.js            |   3 +
 src/pr/lib/normalize.js               |   2 +
 src/pr/tools/headless-team-extract.js | 102 ++++++++++++++++++++++++++++++++++
 3 files changed, 107 insertions(+)

diff --git a/src/pr/adapters/website.js b/src/pr/adapters/website.js
index 674b36a6..2ec2d692 100644
--- a/src/pr/adapters/website.js
+++ b/src/pr/adapters/website.js
@@ -255,3 +255,6 @@ const adapter = register({
 });
 
 module.exports = adapter;
+module.exports.extractPeople = extractPeople;
+module.exports.extractPressContacts = extractPressContacts;
+module.exports.PAGE_HINTS = PAGE_HINTS;
diff --git a/src/pr/lib/normalize.js b/src/pr/lib/normalize.js
index c447c89a..0811478c 100644
--- a/src/pr/lib/normalize.js
+++ b/src/pr/lib/normalize.js
@@ -81,6 +81,8 @@ const COMMON_NONNAME_WORDS = new Set([
   'website', 'privacy', 'policy', 'careers', 'news', 'events', 'services', 'search',
   'menu', 'group', 'company', 'officers', 'executive', 'senior', 'more', 'view', 'all',
   'of', 'for', 'with', 'from', 'to', 'in', 'at', 'on', 'read', 'learn', 'get', 'our',
+  'profile', 'profiles', 'employee', 'employees', 'account', 'portal', 'apply', 'join',
+  'see', 'click', 'bio', 'bios', 'info', 'details', 'page', 'load', 'show', 'full',
 ]);
 
 /**
diff --git a/src/pr/tools/headless-team-extract.js b/src/pr/tools/headless-team-extract.js
new file mode 100644
index 00000000..49847a8c
--- /dev/null
+++ b/src/pr/tools/headless-team-extract.js
@@ -0,0 +1,102 @@
+'use strict';
+// Headless team-page extractor — runs LOCALLY on the Mac (Playwright renders the
+// JS-hidden team pages that plain fetch can't see) and writes people to PROD through
+// the audited admin API, so the server-side person-name gate, dedupe, evidence, and
+// audit trail all apply exactly as if the worker had found them.
+//
+// Usage: NODE_PATH=$(npm root -g) node src/pr/tools/headless-team-extract.js [maxOrgs]
+// Env: PR_API (default https://rentv.agentabrams.com), PR_AUTH (user:pass)
+const { extractPeople, PAGE_HINTS } = require('../adapters/website');
+const { robotsAllows } = require('../adapters');
+
+let chromium;
+try { ({ chromium } = require('playwright')); }
+catch { ({ chromium } = require(require('path').join(process.env.HOME, '.npm-global/lib/node_modules/playwright'))); }
+
+const API = process.env.PR_API || 'https://rentv.agentabrams.com';
+const AUTH = 'Basic ' + Buffer.from(process.env.PR_AUTH || 'admin:DW2024!').toString('base64');
+const MAX_ORGS = Number(process.argv[2]) || 50;
+
+async function api(path, opts = {}) {
+  const r = await fetch(API + '/api/pr' + path, {
+    ...opts,
+    headers: { Authorization: AUTH, 'Content-Type': 'application/json', ...(opts.headers || {}) },
+    body: opts.body ? JSON.stringify(opts.body) : undefined,
+  });
+  if (!r.ok) throw new Error(path + ' → ' + r.status + ': ' + (await r.text()).slice(0, 120));
+  return r.json();
+}
+
+async function main() {
+  // Uncovered orgs, highest priority first (server computes the no-PR-contact filter).
+  const { rows } = await api(`/organizations?no_pr_contact=1&state=CA&sort=priority&dir=desc&limit=${MAX_ORGS}`);
+  const targets = rows.filter((o) => o.website_url);
+  console.log(`[headless] ${targets.length} uncovered CA orgs to render (of ${rows.length} fetched)`);
+  const browser = await chromium.launch();
+  const ctx = await browser.newContext({ userAgent: 'RENTV-PR-Research/1.0 (headless render; press-list research)' });
+  let totalCreated = 0, totalRejected = 0, orgsWithPeople = 0;
+  for (const org of targets) {
+    const t0 = Date.now();
+    let created = 0, rejected = 0, teamUrl = null;
+    try {
+      if (!(await robotsAllows(org.website_url))) { console.log(`  ${org.display_name}: robots disallow — skipped`); continue; }
+      const page = await ctx.newPage();
+      page.setDefaultTimeout(20000);
+      await page.goto(org.website_url, { waitUntil: 'domcontentloaded' });
+      await page.waitForTimeout(1200); // let client JS paint nav
+      // Collect ALL team-ish links from the rendered dom, then rank: static leadership/
+      // management pages first — broker "professionals" pages are search directories
+      // that list nobody until queried (verified on kidder.com), so they rank last.
+      const candidates = await page.evaluate((teamReSrc) => {
+        const re = new RegExp(teamReSrc, 'i');
+        const seen = new Set();
+        return [...document.querySelectorAll('a[href]')]
+          .filter((el) => re.test(el.getAttribute('href') || '') || re.test(el.textContent || ''))
+          .map((el) => el.href)
+          .filter((h) => { if (!h || seen.has(h)) return false; seen.add(h); return true; })
+          .slice(0, 10);
+      }, PAGE_HINTS.team.source);
+      const rank = (u) => /leadership|executive|management|officers|our-team|meet-the-team/i.test(u) ? 0
+        : /about/i.test(u) ? 1 : /professionals|people-search|find|directory|agents/i.test(u) ? 3 : 2;
+      candidates.sort((a, b) => rank(a) - rank(b));
+      let people = [];
+      for (const cand of candidates.slice(0, 3)) {
+        if (!(await robotsAllows(cand))) continue;
+        await page.goto(cand, { waitUntil: 'domcontentloaded' });
+        await page.waitForTimeout(2000); // rendered people grids are late paints
+        people = extractPeople(await page.content());
+        if (people.length) { teamUrl = cand; break; }
+      }
+      if (people.length) {
+        for (const cand of people.slice(0, 40)) {
+          try {
+            const r = await api('/people', { method: 'POST', body: {
+              full_name: cand.full_name, exact_title: cand.exact_title,
+              organization_id: org.id, state: 'CA', metro: (org.metros || [])[0] || null,
+              lifecycle_status: 'discovered',
+              evidence: {
+                source: {
+                  source_type: 'org_website', source_name: 'Team page (headless render): ' + org.display_name,
+                  url: teamUrl, is_primary_source: true, adapter: 'website-headless',
+                  license_or_usage_note: 'Public organization website rendered headlessly (JS content); robots.txt honored; throttled.',
+                },
+                fields: { organization: 85, exact_title: 85 },
+              },
+            } });
+            if (r.rejected) rejected++;
+            else if (r.created) created++;
+          } catch (e) { /* per-person API errors don't stop the org */ }
+        }
+      }
+      await page.close();
+    } catch (e) { console.log(`  ${org.display_name}: ${e.message.slice(0, 70)}`); }
+    if (created) orgsWithPeople++;
+    totalCreated += created; totalRejected += rejected;
+    console.log(`  ${org.display_name.slice(0, 36).padEnd(36)} team:${teamUrl ? 'y' : '-'} created:${created} gate-rejected:${rejected} (${Date.now() - t0}ms)`);
+    await new Promise((r) => setTimeout(r, 1500)); // polite pacing between orgs
+  }
+  await browser.close();
+  console.log(`[headless] DONE — people created: ${totalCreated} across ${orgsWithPeople} orgs · name-gate rejections: ${totalRejected} · $0 (local compute)`);
+}
+
+main().catch((e) => { console.error('fatal:', e.message); process.exit(1); });

← cf59aa53 yoloforever: cycle 2 ledger (SHIP 5/5); local-models directi  ·  back to Rentv 2026  ·  pr-intelligence: Cody c3 — two-tier name predicate (HARD tit b3dfe478 →