[object Object]

← 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

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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&#0?39;|&rsquo;|&#8217;/g, "'")
+    .replace(/&ndash;|&#8211;/g, '–').replace(/&mdash;|&#8212;/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 →