← back to Rentv
people UI: stacked Public info column (email/LinkedIn/phone/address/notes each on its own line) + editable notes in drawer; list API exposes office_location+notes
67f5fba7fc8152dd21f16ace01f15c674940e872 · 2026-07-30 17:58:09 -0700 · Steve Abrams
Files touched
M public/admin/pr-intelligence/people.htmlM src/pr/adapters/website.jsM src/pr/jobs/index.jsM src/pr/services/people.js
Diff
commit 67f5fba7fc8152dd21f16ace01f15c674940e872
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 30 17:58:09 2026 -0700
people UI: stacked Public info column (email/LinkedIn/phone/address/notes each on its own line) + editable notes in drawer; list API exposes office_location+notes
---
public/admin/pr-intelligence/people.html | 12 ++++-
src/pr/adapters/website.js | 93 ++++++++++++++++++++++++++++++++
src/pr/jobs/index.js | 54 +++++++++++++++++++
src/pr/services/people.js | 3 +-
4 files changed, 159 insertions(+), 3 deletions(-)
diff --git a/public/admin/pr-intelligence/people.html b/public/admin/pr-intelligence/people.html
index 72d766ea..ee5e85ea 100644
--- a/public/admin/pr-intelligence/people.html
+++ b/public/admin/pr-intelligence/people.html
@@ -41,8 +41,14 @@
{ key: 'name', label: 'Person', render: (r) => `<b>${PR.esc(r.full_name)}</b><br><span style="color:var(--muted);font-size:12px">${PR.esc(r.exact_title || '')}</span>` },
{ key: 'org', label: 'Organization', render: (r) => r.organization_id ? `<a href="/admin/pr-intelligence/organizations?id=${r.organization_id}">${PR.esc(r.organization_name || '')}</a>` : '—' },
{ key: 'role', label: 'Role', render: (r) => PR.badge(r.normalized_role || 'other', 'blue') + ' ' + PR.badge(r.department || '', 'gray') },
- { key: '_email', label: 'Email', render: (r) => r.public_work_email ? `${PR.esc(r.public_work_email)}<br>${PR.verifBadge(r.email_verification_status)}` : '—' },
- { key: '_li', label: 'LinkedIn', render: (r) => r.linkedin_url ? `<a href="${PR.esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">profile</a> ${PR.verifBadge(r.linkedin_status)}` : '—' },
+ { key: '_public', label: 'Public info', render: (r) => `
+ <div style="line-height:1.9;font-size:12.5px;min-width:230px">
+ <div>📧 ${r.public_work_email ? PR.esc(r.public_work_email) + ' ' + PR.verifBadge(r.email_verification_status) : '—'}</div>
+ <div>🔗 ${r.linkedin_url ? `<a href="${PR.esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(r.linkedin_url.replace('https://www.', ''))}</a> ${PR.verifBadge(r.linkedin_status)}` : '—'}</div>
+ <div>☎️ ${r.public_business_phone ? PR.esc(String(r.public_business_phone).replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3')) : '—'}</div>
+ <div>📍 ${PR.esc([r.office_location, r.metro, r.state].filter(Boolean).join(' · ')) || '—'}</div>
+ <div>📝 ${r.notes ? PR.esc(String(r.notes).slice(0, 120)) : '—'}</div>
+ </div>` },
{ key: 'confidence', label: 'Conf', render: (r) => PR.score(r.confidence_score) },
{ key: 'priority', label: 'Priority', render: (r) => PR.score(r.contact_priority_score) },
{ key: 'status', label: 'Status', render: (r) => PR.statusBadge(r.lifecycle_status) },
@@ -84,6 +90,7 @@
<dt>LinkedIn</dt><dd>${p.linkedin_url ? `<a href="${PR.esc(p.linkedin_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(p.linkedin_url)}</a>` : '—'} ${PR.verifBadge(p.linkedin_status)}
${p.linkedin_indexed_title ? `<div style="font-size:11px;color:var(--muted)">indexed title (uncorroborated): “${PR.esc(p.linkedin_indexed_title)}”</div>` : ''}</dd>
<dt>Location</dt><dd>${PR.esc([p.office_location, p.metro, p.state].filter(Boolean).join(' · ') || '—')}</dd>
+ <dt>Notes</dt><dd><textarea id="e-notes" style="width:100%;min-height:56px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:12.5px inherit" placeholder="Internal note…">${PR.esc(p.notes || '')}</textarea></dd>
<dt>Scores</dt><dd>Confidence ${PR.score(p.confidence_score)} · Priority ${PR.score(p.contact_priority_score)}
<details style="margin-top:4px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">components</summary>
<pre style="font-size:11px;background:var(--bg);padding:8px;border-radius:6px;overflow:auto">${PR.esc(JSON.stringify(p.score_components || {}, null, 1))}</pre></details></dd>
@@ -119,6 +126,7 @@
exact_title: body.querySelector('#e-title').value,
public_work_email: body.querySelector('#e-email').value || null,
email_verification_status: body.querySelector('#e-emailstatus').value,
+ notes: body.querySelector('#e-notes').value || null,
} }), 'Saved (original values preserved)');
openPerson(id); load();
});
diff --git a/src/pr/adapters/website.js b/src/pr/adapters/website.js
index aa62bb79..adfcca95 100644
--- a/src/pr/adapters/website.js
+++ b/src/pr/adapters/website.js
@@ -64,6 +64,53 @@ function extractPeople(html) {
return people.filter((p) => { const k = p.full_name.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
}
+/**
+ * Extract named MEDIA/PRESS CONTACT blocks from a press/newsroom/release page.
+ * Pattern: a "Media Contact(s) / Press Contact / For media inquiries" marker followed
+ * within a short window by a person name, an email, and often a title/phone.
+ * These are the org's own published press contacts — press_release_verified class.
+ */
+function extractPressContacts(html, pageUrl) {
+ const text = String(html)
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
+ .replace(/<(h[1-6]|p|div|li|td|br|strong|b)[^>]*>/gi, '\n').replace(/<[^>]+>/g, ' ')
+ .replace(/ /g, ' ').replace(/&/g, '&').replace(/�?39;|’|’/g, "'")
+ .replace(/–|–/g, '–').replace(/—|—/g, '—');
+ const lines = text.split('\n').map((l) => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
+ const MARKER = /^(media|press)\s+contacts?\b|^for (media|press) (inquiries|enquiries|information)|^media relations contact/i;
+ const NAME_RE = /^([A-Z][a-z]+(?:\s+[A-Z]\.?)?(?:\s+[A-Z][a-zA-Z'’-]+){1,2})\b[,:]?\s*(.*)$/;
+ const out = [];
+ for (let i = 0; i < lines.length; i++) {
+ if (!MARKER.test(lines[i])) continue;
+ // scan a short window after the marker for name + email
+ let full_name = null, exact_title = null, email = null, phone = null;
+ for (let j = 1; j <= 8 && i + j < lines.length; j++) {
+ const l = lines[i + j];
+ if (MARKER.test(l)) break;
+ const em = l.match(EMAIL_RE);
+ if (em && !email) email = normalizeEmail(em[0]);
+ const ph = l.match(PHONE_RE);
+ if (ph && !phone) phone = ph[0];
+ if (!full_name) {
+ const nm = l.match(NAME_RE);
+ if (nm && !/@/.test(nm[1]) && nm[1].split(' ').length >= 2) {
+ full_name = nm[1];
+ const rest = (nm[2] || '').replace(/[|•·]+/g, ',').trim();
+ if (rest && rest.length < 90 && !/@/.test(rest)) exact_title = rest.replace(/^[,\s]+|[,\s]+$/g, '') || null;
+ }
+ } else if (!exact_title && l.length < 90 && !/@|\d{3}[.-]\d{3}/.test(l) && /[a-z]/.test(l)) {
+ exact_title = l;
+ }
+ if (full_name && email) break;
+ }
+ if (full_name && email) out.push({ full_name, exact_title, email, phone, source_url: pageUrl });
+ if (out.length >= 5) break;
+ }
+ // dedupe by email
+ const seen = new Set();
+ return out.filter((c) => { if (seen.has(c.email)) return false; seen.add(c.email); return true; });
+}
+
const adapter = register({
name: 'website',
kind: 'enrichment',
@@ -74,6 +121,52 @@ const adapter = register({
license_or_usage_note: 'Public organization website; facts extracted, limited excerpts stored. robots.txt honored; throttled.',
}),
+ /**
+ * Harvest named press contacts from an org's press/newsroom page + up to 2 recent
+ * release pages linked from it. Returns items [{full_name, exact_title, email,
+ * phone, source_url}] — each found in the org's OWN published press material.
+ */
+ async pressContacts(pressUrl) {
+ const errors = []; let items = [];
+ try {
+ const r = await politeFetch(pressUrl, { provider: 'website' });
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ const base = r.finalUrl || pressUrl;
+ items = extractPressContacts(r.body, base);
+ if (items.length < 2) {
+ // Contact blocks usually live on individual RELEASE pages, not the index.
+ // Follow same-domain links that look like articles: year/release URL patterns
+ // OR headline-length anchor text on a deeper path than the index itself.
+ const dom = normalizeDomain(base);
+ const basePath = (() => { try { return new URL(base).pathname.replace(/\/$/, ''); } catch { return ''; } })();
+ const all = extractLinks(r.body, base).filter((l) => normalizeDomain(l.url) === dom);
+ const byPattern = all.filter((l) => /press-release|news\/20|\/20[0-9]{2}\/|announc|release/i.test(l.url));
+ const byHeadline = all.filter((l) => {
+ try {
+ const p = new URL(l.url).pathname.replace(/\/$/, '');
+ return p.startsWith(basePath) && p.length > basePath.length + 8 && (l.text || '').length >= 30;
+ } catch { return false; }
+ });
+ const seenU = new Set();
+ const relLinks = [...byPattern, ...byHeadline]
+ .filter((l) => { if (seenU.has(l.url)) return false; seenU.add(l.url); return true; })
+ .slice(0, 3);
+ for (const l of relLinks) {
+ try {
+ const rr = await politeFetch(l.url, { provider: 'website' });
+ if (rr.ok) items.push(...extractPressContacts(rr.body, l.url));
+ } catch (e) { errors.push({ url: l.url, error: e.message }); }
+ }
+ }
+ const seen = new Set();
+ items = items.filter((c) => { if (seen.has(c.email)) return false; seen.add(c.email); return true; }).slice(0, 5);
+ } catch (e) { errors.push({ url: pressUrl, error: e.message }); }
+ return report({
+ provider: 'website', query: pressUrl, errors, primary_source: true,
+ confidence_recommendation: 85, items, terms_note: adapter.sourceMeta().license_or_usage_note,
+ });
+ },
+
/**
* Verify + profile an organization website.
* Returns AdapterReport with one item: { website_ok, press_page_url, newsroom_url,
diff --git a/src/pr/jobs/index.js b/src/pr/jobs/index.js
index 4370f74d..501b48ae 100644
--- a/src/pr/jobs/index.js
+++ b/src/pr/jobs/index.js
@@ -144,6 +144,9 @@ const handlers = {
for (const item of rep.items) {
const r = await upsertOrgFromItem({ ...item, organization_type: 'bank' }, { state, metro: p.metro, sourceMeta: reg.sourceMeta(), adapter: 'registry:fdic', query: rep.query });
await runs.progress(run.id, { candidates: 1, created: r.created ? 1 : 0, updated: r.created ? 0 : 1, duplicates: r.matched === 'exact' ? 1 : 0 });
+ // registry orgs need website profiling too (cycle-2 gap fix: banks were
+ // never verified, so no press/team data ever landed for them)
+ if (r.org && r.org.website_url) await enqueue('verify-organization', { organization_id: r.org.id }, { priority: 6 });
}
for (const e of rep.errors) await runs.progress(run.id, { error: e.error });
cursor = rep.cursor;
@@ -422,6 +425,57 @@ const handlers = {
return { ...ck, queried, created };
},
+ /**
+ * discover-press-contacts: for orgs with a press/newsroom page but no comms contact,
+ * harvest the org's OWN published media-contact blocks (name + email + title) —
+ * the press_release_verified contact class, outreach-ready by definition. $0.
+ * Payload: { state='CA', limit=25 }. Checkpoint: { offset }.
+ */
+ async 'discover-press-contacts'(job) {
+ const web = adapters.getAdapter('website');
+ const state = job.payload.state || 'CA';
+ const limit = Math.min(Number(job.payload.limit) || 25, 60);
+ const offset = (job.checkpoint || {}).offset || 0;
+ const orgs = await db.rows(
+ `SELECT id, display_name, press_page_url, newsroom_url, metros, state_presence FROM pr_organizations o
+ WHERE $1 = ANY(state_presence) AND lifecycle_status NOT IN ('duplicate','archived')
+ AND coalesce(press_page_url, newsroom_url) IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM pr_people p WHERE p.organization_id = o.id
+ AND p.department IN ('communications','marketing','agency','bd')
+ AND p.lifecycle_status NOT IN ('duplicate','suppressed','wrong_person','left_company'))
+ ORDER BY priority_score DESC, id LIMIT $2 OFFSET $3`, [state, limit, offset]);
+ let created = 0, scanned = 0;
+ const ck = { offset };
+ for (const org of orgs) {
+ const rep = await web.pressContacts(org.press_page_url || org.newsroom_url);
+ scanned++;
+ for (const c of rep.items) {
+ const r = await people.create({
+ full_name: c.full_name, exact_title: c.exact_title,
+ organization_id: org.id, state, metro: (org.metros || [])[0] || null,
+ public_work_email: c.email, email_verification_status: 'press_release_verified',
+ public_business_phone: c.phone,
+ verification_status: 'press_release_verified', lifecycle_status: 'needs_verification',
+ }, {
+ evidence: {
+ source: {
+ source_type: 'press_release', source_name: 'Published press contact: ' + org.display_name,
+ url: c.source_url, short_excerpt: `${c.full_name}${c.exact_title ? ', ' + c.exact_title : ''} — ${c.email}`,
+ is_primary_source: true, license_or_usage_note: web.sourceMeta().license_or_usage_note, adapter: 'website',
+ },
+ fields: { organization: 85, exact_title: c.exact_title ? 80 : 0, public_work_email: 90 },
+ },
+ actor: 'system:discover-press-contacts',
+ });
+ if (r.created) created++;
+ }
+ ck.offset = offset + scanned;
+ await db.query('UPDATE pr_jobs SET checkpoint=$2 WHERE id=$1', [job.id, JSON.stringify(ck)]);
+ }
+ if (orgs.length === limit) await enqueue('discover-press-contacts', { state, limit, page: ck.offset }, { priority: 6 });
+ return { ...ck, scanned, created };
+ },
+
/**
* convergence-controller: durable in-code driver for the contact-coverage push
* (replaces the session-local shell loop the Cody gate correctly flagged).
diff --git a/src/pr/services/people.js b/src/pr/services/people.js
index a221ea3a..eb9d61aa 100644
--- a/src/pr/services/people.js
+++ b/src/pr/services/people.js
@@ -143,7 +143,8 @@ async function list(f = {}) {
const rows = await db.rows(
`SELECT p.id, p.full_name, p.exact_title, p.normalized_role, p.department, p.seniority,
p.state, p.metro, p.public_work_email, p.email_verification_status, p.public_business_phone,
- p.linkedin_url, p.linkedin_status, p.contact_priority_score, p.confidence_score, p.score_components,
+ p.linkedin_url, p.linkedin_status, p.office_location, p.notes,
+ p.contact_priority_score, p.confidence_score, p.score_components,
p.verification_status, p.lifecycle_status, p.last_verified_at, p.outreach_eligible,
p.organization_id, o.display_name AS organization_name, p.created_at, p.updated_at
${base} ORDER BY ${sort} ${dir} NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length}`, params);
← 3976b916 yoloforever: cycle 1 ledger (SHIP 5/5)
·
back to Rentv
·
pr-intelligence: press-contact extractor — RSS-first release 284c46a9 →