← 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
M src/pr/adapters/website.jsM src/pr/lib/normalize.jsA src/pr/tools/headless-team-extract.js
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 →