[object Object]

← back to Rentv 2026

pr-intelligence: cycle-4 extractor upgrades — single-line Name,Title parsing; common bank-path probes + two-hop about crawl in headless tool

47c8ac1404626f8567796570775904aa622cc6d3 · 2026-07-30 20:15:57 -0700 · Steve Abrams

Files touched

Diff

commit 47c8ac1404626f8567796570775904aa622cc6d3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 20:15:57 2026 -0700

    pr-intelligence: cycle-4 extractor upgrades — single-line Name,Title parsing; common bank-path probes + two-hop about crawl in headless tool
---
 public/social.html                    | 174 ++++++++++++++++++++++++++++++++++
 src/pr/adapters/website.js            |  10 ++
 src/pr/tools/headless-team-extract.js |  37 +++++++-
 3 files changed, 216 insertions(+), 5 deletions(-)

diff --git a/public/social.html b/public/social.html
index 3ab10dcf..6346d4b3 100644
--- a/public/social.html
+++ b/public/social.html
@@ -43,6 +43,49 @@
   .badge { font-size:10.5px; background:#fff3cd; color:#8a6d1a; padding:2px 7px; border-radius:99px; white-space:nowrap; }
   .times span { display:inline-block; background:#eef2f7; border-radius:99px; padding:4px 10px; margin:3px 4px 0 0; font-size:12.5px; }
   .plt { font-weight:600; font-size:12.5px; margin-top:10px; }
+  /* ── Growth · LinkedIn target rail (CRE conference attendees) ── */
+  .shell { display:flex; align-items:flex-start; }
+  main { flex:1 1 auto; min-width:0; }
+  .grail { position:sticky; top:0; flex:0 0 auto; width:44px; background:#fff; border-right:1px solid var(--line); transition:width .18s ease; z-index:6; }
+  .grail.open { width:340px; box-shadow:2px 0 14px rgba(13,27,42,.05); }
+  .grail-toggle { display:flex; align-items:center; justify-content:center; width:44px; height:230px; margin-top:16px; border:0; border-right:2px solid var(--li); background:#eef5fc; color:var(--li); cursor:pointer; border-radius:0 10px 10px 0; }
+  .grail.open .grail-toggle { display:none; }
+  .grail-vert { writing-mode:vertical-rl; transform:rotate(180deg); font-weight:700; font-size:12.5px; letter-spacing:.06em; text-transform:uppercase; }
+  .grail-body { display:none; padding:16px 15px 24px; max-height:100vh; overflow:auto; }
+  .grail.open .grail-body { display:block; }
+  .grail-top { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
+  .grail-top strong { font-size:14px; }
+  .grail-close { margin-left:auto; border:0; background:#eef2f7; color:var(--ink); width:26px; height:26px; border-radius:7px; cursor:pointer; font-size:16px; line-height:1; }
+  .grail-note { margin:0 0 12px; font-size:11.5px; color:var(--muted); line-height:1.45; }
+  .grail-actions { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:10px; }
+  .grail-actions button { padding:7px 10px; font-size:12px; }
+  .grail-actions button.ghost { background:#eef2f7; color:var(--ink); }
+  #grailFilter, #grailNew { width:100%; }
+  .grail-meta { display:flex; align-items:center; gap:8px; font-size:11.5px; color:var(--muted); margin:10px 0 4px; }
+  .grail-meta select { width:auto; flex:1; padding:5px 7px; font-size:12px; }
+  .gcat { margin-top:12px; }
+  .gitem { border:1px solid var(--line); border-radius:10px; padding:9px 11px; margin-top:7px; }
+  .gitem.cat { background:#f7fafd; border-color:#dbe7f4; }
+  .gitem .gi-top { display:flex; align-items:center; gap:8px; }
+  .gitem .gi-name { font-weight:600; font-size:13px; }
+  .gitem .gi-count { font-size:10.5px; color:var(--muted); }
+  .gitem .gi-sub { font-size:11.5px; color:var(--muted); margin-top:2px; }
+  .gitem a.gi-li { margin-left:auto; color:#fff; background:var(--li); border-radius:6px; padding:3px 8px; font-size:11px; font-weight:600; text-decoration:none; white-space:nowrap; }
+  .gitem .gi-x { border:0; background:none; color:#c0392b; cursor:pointer; font-size:12px; padding:2px; }
+  .gstatus { display:inline-block; margin-top:7px; font-size:10.5px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; border-radius:99px; padding:3px 9px; cursor:pointer; user-select:none; }
+  .gstatus.s0 { background:#eef2f7; color:#5b6b7b; }
+  .gstatus.s1 { background:#fff3cd; color:#8a6d1a; }
+  .gstatus.s2 { background:#e6f0ff; color:#0a4bb3; }
+  .gstatus.s3 { background:#e8f7ec; color:#1a7f37; }
+  .gwhen { font-size:10.5px; color:#9aa7b4; margin-left:8px; }
+  .gitem.sel { border-color:var(--li); box-shadow:0 0 0 2px #cfe3f8 inset; }
+  .gi-letter { display:inline-block; margin:8px 0 0 8px; border:1px solid var(--li); background:#fff; color:var(--li); border-radius:7px; padding:3px 9px; font-size:11px; font-weight:600; cursor:pointer; }
+  .gi-letter:hover { background:var(--li); color:#fff; }
+  .grail-add { display:flex; gap:6px; margin-top:14px; }
+  .grail-add input { flex:1; }
+  .grail-add button { padding:9px 12px; font-size:12.5px; }
+  .grail-msgline { font-size:11.5px; color:var(--muted); margin-top:8px; min-height:14px; }
+  @media (max-width:900px){ .grail.open { position:fixed; top:0; bottom:0; height:100vh; width:86vw; max-width:340px; } .grail.open .grail-body { max-height:100vh; } }
 </style>
 </head>
 <body>
@@ -50,6 +93,31 @@
   <h1>RENTV Social</h1><span class="tag">Admin</span>
   <a class="back" href="/backend">← Backend</a>
 </header>
+<div class="shell">
+<aside class="grail" id="grail">
+  <button class="grail-toggle" id="grailToggle" title="Growth · LinkedIn Targets"><span class="grail-vert">+ Growth · LinkedIn</span></button>
+  <div class="grail-body">
+    <div class="grail-top"><strong>Growth · LinkedIn Targets</strong><button class="grail-close" id="grailClose" title="Collapse">×</button></div>
+    <p class="grail-note">CRE conference attendees to grow on LinkedIn — owners &amp; investors, developers, brokers, lenders &amp; capital sources, vendors, legal, construction, title &amp; escrow.</p>
+    <div class="grail-actions">
+      <button id="grailAddAll">+ Add all categories</button>
+      <button class="ghost" id="grailPull">Pull registry contacts</button>
+      <button class="ghost" id="grailClear">Clear</button>
+    </div>
+    <input id="grailFilter" placeholder="Filter targets…">
+    <div class="grail-meta"><span id="grailCount">0</span> targets ·
+      <select id="grailSort">
+        <option value="added">Newest added</option>
+        <option value="cat">Category order</option>
+        <option value="name">Name A–Z</option>
+        <option value="status">Status</option>
+      </select>
+    </div>
+    <div id="grailList"></div>
+    <div class="grail-add"><input id="grailNew" placeholder="Add a custom target…"><button id="grailNewBtn">Add</button></div>
+    <div id="grailMsg" class="grail-msgline"></div>
+  </div>
+</aside>
 <main>
   <div class="col">
     <div class="panel">
@@ -86,6 +154,7 @@
     </div>
   </div>
 </main>
+</div><!-- /shell -->
 <script>
   var PLAT = [
     ['linkedin','LinkedIn','in'], ['x','X (Twitter)','𝕏'], ['instagram','Instagram','◎'],
@@ -141,6 +210,111 @@
         else msg.textContent = res.error||'Error';
       });
   };
+
+  // ── Growth · LinkedIn target list — CRE conference attendees, by role ──────
+  var GKEY='rentv.growthLI.v1', GOPEN='rentv.growthLI.open', GSORT='rentv.growthLI.sort';
+  var GSTL=['Target','Outreaching','Connected','Done'];
+  // [category, descriptor, LinkedIn people-search terms]
+  var GCATS=[
+    ['Owners / Investors','Property owners & CRE investors','commercial real estate owner OR investor'],
+    ['Developers','Real estate developers','commercial real estate developer'],
+    ['Brokers','CRE sales & leasing brokers','commercial real estate broker'],
+    ['Lenders & Capital Sources','Debt, equity & capital markets','commercial real estate lender OR "capital markets" OR debt OR equity'],
+    ['Vendors / Service Providers','CRE service & product vendors','commercial real estate services vendor'],
+    ['Legal','Real estate attorneys & counsel','commercial real estate attorney'],
+    ['Construction / GCs','General contractors & builders','commercial construction general contractor'],
+    ['Title & Escrow','Title, escrow & closing','commercial real estate title escrow officer']
+  ];
+  function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
+  function cap(s){ return String(s||'').toLowerCase().replace(/\b\w/g,function(m){return m.toUpperCase();}); }
+  function liUrl(t){ return 'https://www.linkedin.com/search/results/people/?keywords='+encodeURIComponent(t); }
+  function gid(){ return 'g'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36); }
+  function gload(){ try { return JSON.parse(localStorage.getItem(GKEY))||[]; } catch(e){ return []; } }
+  function gsave(l){ localStorage.setItem(GKEY, JSON.stringify(l)); }
+  function gmsg(m){ var el=document.getElementById('grailMsg'); el.textContent=m; if(m) setTimeout(function(){ if(el.textContent===m) el.textContent=''; },3000); }
+
+  function gAddAllCats(){
+    var l=gload(), have={}; l.forEach(function(x){ if(x.kind==='category') have[x.name]=1; });
+    var added=0, now=Date.now();
+    GCATS.forEach(function(c){ if(!have[c[0]]){ l.push({id:gid(),kind:'category',cat:c[0],name:c[0],sub:c[1],terms:c[2],status:0,added:now}); added++; } });
+    gsave(l); grender(); gmsg(added ? ('Added '+added+' categories ✓') : 'All categories already present.');
+  }
+  function gPull(){
+    gmsg('Pulling registry…');
+    Promise.all([
+      fetch('/api/summit/leads?type=firms&limit=200',{credentials:'same-origin'}).then(function(r){return r.json();}).catch(function(){return {rows:[]};}),
+      fetch('/api/summit/leads?type=brokers&limit=200',{credentials:'same-origin'}).then(function(r){return r.json();}).catch(function(){return {rows:[]};})
+    ]).then(function(res){
+      var l=gload(), seen={}; l.forEach(function(x){ if(x.rid) seen[x.rid]=1; });
+      var now=Date.now(), added=0;
+      (res[0].rows||[]).forEach(function(r){ var rid='f'+r.id; if(seen[rid])return; seen[rid]=1;
+        l.push({id:gid(),rid:rid,kind:'contact',cat:'Owners / Investors',name:cap(r.name),sub:(r.hq_city?cap(r.hq_city):'')+(r.agent_count?(' · '+r.agent_count+' agents'):''),terms:r.name+' commercial real estate',status:0,added:now}); added++; });
+      (res[1].rows||[]).forEach(function(r){ var rid='b'+r.id; if(seen[rid])return; seen[rid]=1;
+        l.push({id:gid(),rid:rid,kind:'contact',cat:'Brokers',name:cap(r.name),sub:cap(r.firm_name||'')+(r.city?(' · '+cap(r.city)):''),terms:r.name+' '+(r.firm_name||'')+' commercial real estate',status:0,added:now}); added++; });
+      gsave(l); grender(); gmsg(added ? ('Added '+added+' registry contacts ✓') : 'No new registry contacts.');
+    });
+  }
+  function gAddCustom(){
+    var el=document.getElementById('grailNew'), v=el.value.trim(); if(!v)return;
+    var l=gload(); l.push({id:gid(),kind:'contact',cat:'Other',name:v,sub:'Custom target',terms:v+' commercial real estate',status:0,added:Date.now()});
+    gsave(l); el.value=''; grender(); gmsg('Added ✓');
+  }
+  function gItemHtml(x, count){
+    var when=new Date(x.added).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+    var isCat=x.kind==='category';
+    var sel=(x.id===GSEL)?' sel':'';
+    var lLabel=(x.lv?'✎ Regenerate':'✎ Generate letter');
+    return '<div class="gitem'+(isCat?' cat':'')+sel+'">'
+      +'<div class="gi-top"><span class="gi-name">'+esc(x.name)+'</span>'
+      +(isCat&&count?'<span class="gi-count">'+count+' contacts</span>':'')
+      +'<a class="gi-li" href="'+liUrl(x.terms||x.name)+'" target="_blank" rel="noopener">in ↗</a>'
+      +'<button class="gi-x" data-del="'+x.id+'" title="Remove">✕</button></div>'
+      +(x.sub?'<div class="gi-sub">'+esc(x.sub)+'</div>':'')
+      +'<span class="gstatus s'+(x.status||0)+'" data-st="'+x.id+'">'+GSTL[x.status||0]+'</span>'
+      +'<span class="gwhen">🕓 '+when+'</span>'
+      +'<button class="gi-letter" data-letter="'+x.id+'">'+lLabel+'</button></div>';
+  }
+  function grender(){
+    var wrap=document.getElementById('grailList'); if(!wrap)return;
+    var l=gload(), f=(document.getElementById('grailFilter').value||'').toLowerCase();
+    var sort=document.getElementById('grailSort').value||'added';
+    document.getElementById('grailCount').textContent=l.length;
+    var items=l.filter(function(x){ return !f || (x.name+' '+(x.sub||'')+' '+x.cat).toLowerCase().indexOf(f)>=0; });
+    var order={}; GCATS.forEach(function(c,i){ order[c[0]]=i; });
+    var cats={}, extras=[];
+    items.forEach(function(x){ var c=x.cat||'Other'; if(!cats[c]){ cats[c]=[]; if(!(c in order)) extras.push(c); } cats[c].push(x); });
+    var names=Object.keys(cats).sort(function(a,b){ var oa=(a in order)?order[a]:100+extras.indexOf(a), ob=(b in order)?order[b]:100+extras.indexOf(b); return oa-ob; });
+    var html=names.map(function(cn){
+      var arr=cats[cn], head=arr.filter(function(x){return x.kind==='category';})[0];
+      var contacts=arr.filter(function(x){return x.kind==='contact';});
+      contacts.sort(function(a,b){ if(sort==='name')return a.name.localeCompare(b.name); if(sort==='status')return (b.status||0)-(a.status||0); return b.added-a.added; });
+      var out='<div class="gcat">';
+      if(head) out+=gItemHtml(head, contacts.length);
+      else out+='<div class="gitem cat"><div class="gi-top"><span class="gi-name">'+esc(cn)+'</span><span class="gi-count">'+contacts.length+' contacts</span><a class="gi-li" href="'+liUrl(cn+' commercial real estate')+'" target="_blank" rel="noopener">in ↗</a></div></div>';
+      out+=contacts.map(function(x){ return gItemHtml(x,0); }).join('');
+      return out+'</div>';
+    }).join('');
+    wrap.innerHTML = l.length ? html : '<p class="grail-note" style="margin-top:14px">No targets yet. Click <b>Add all categories</b> to seed the CRE conference-attendee list, then <b>Pull registry contacts</b> for named brokers &amp; owners.</p>';
+  }
+
+  var grail=document.getElementById('grail');
+  function gsetOpen(o){ grail.classList.toggle('open',o); localStorage.setItem(GOPEN,o?'1':'0'); }
+  document.getElementById('grailToggle').onclick=function(){ gsetOpen(true); };
+  document.getElementById('grailClose').onclick=function(){ gsetOpen(false); };
+  document.getElementById('grailAddAll').onclick=gAddAllCats;
+  document.getElementById('grailPull').onclick=gPull;
+  document.getElementById('grailClear').onclick=function(){ if(confirm('Clear the growth LinkedIn list?')){ gsave([]); grender(); gmsg('Cleared.'); } };
+  document.getElementById('grailFilter').oninput=grender;
+  var gsortEl=document.getElementById('grailSort'); gsortEl.value=localStorage.getItem(GSORT)||'added';
+  gsortEl.onchange=function(){ localStorage.setItem(GSORT,gsortEl.value); grender(); };
+  document.getElementById('grailNewBtn').onclick=gAddCustom;
+  document.getElementById('grailNew').addEventListener('keydown',function(e){ if(e.key==='Enter') gAddCustom(); });
+  document.getElementById('grailList').addEventListener('click',function(e){
+    var st=e.target.closest('[data-st]'); if(st){ var id=st.getAttribute('data-st'); var l=gload(); l.forEach(function(x){ if(x.id===id) x.status=((x.status||0)+1)%GSTL.length; }); gsave(l); grender(); return; }
+    var del=e.target.closest('[data-del]'); if(del){ var did=del.getAttribute('data-del'); gsave(gload().filter(function(x){return x.id!==did;})); grender(); }
+  });
+  gsetOpen(localStorage.getItem(GOPEN)==='1'); // collapsed by default
+  grender();
 </script>
 </body>
 </html>
diff --git a/src/pr/adapters/website.js b/src/pr/adapters/website.js
index 2ec2d692..8c860ddb 100644
--- a/src/pr/adapters/website.js
+++ b/src/pr/adapters/website.js
@@ -48,7 +48,17 @@ function extractPeople(html) {
   // word from this list disqualifies the candidate. Real ambiguity still lands in
   // needs_verification for human review; this just cuts the obvious junk.
   const NOT_NAME = /\b(job|jobs|career|careers|communication|communications|media|crisis|digital|social|marketing|advisor|advisors|strategy|strategies|services|solutions|relations|estate|group|team|about|contact|office|offices|leadership|management|partners|clients|projects|news|insights|events|home|search|menu|privacy|policy|our|the|and)\b/i;
+  // single-line variant: "Jane Smith, Senior Vice President" / "Jane Smith – CFO" /
+  // "Jane Smith | Chief Credit Officer" — common on small-bank officer pages (cycle 4:
+  // 36 team pages yielded 0 because name+title share one line).
+  const INLINE_RE = /^([A-Z][a-z]+(?:\s+[A-Z]\.?)?(?:\s+[A-Z][a-z'’-]+){1,2})\s*[,|–—-]\s+(.{3,90})$/;
   for (let i = 0; i < lines.length; i++) {
+    const inline = lines[i].match(INLINE_RE);
+    if (inline && !NOT_NAME.test(inline[1]) && TITLE_HINT.test(inline[2])) {
+      people.push({ full_name: inline[1], exact_title: inline[2].trim() });
+      if (people.length >= 60) break;
+      continue;
+    }
     const nm = lines[i].match(NAME_RE);
     if (!nm || NOT_NAME.test(nm[1])) continue;
     // look ahead up to 2 lines for a title
diff --git a/src/pr/tools/headless-team-extract.js b/src/pr/tools/headless-team-extract.js
index 49847a8c..9e2bffbc 100644
--- a/src/pr/tools/headless-team-extract.js
+++ b/src/pr/tools/headless-team-extract.js
@@ -59,13 +59,40 @@ async function main() {
       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));
+      // Cycle 4: 59 sites had NO team link in the rendered home nav (bank submenus /
+      // unlinked pages). Fall back to probing the common paths small-bank sites use,
+      // and to a two-hop crawl through the About page.
+      if (!candidates.length || rank(candidates[0]) >= 2) {
+        const origin = new URL(page.url()).origin;
+        for (const p of ['/about-us/management', '/about-us/leadership', '/about/leadership',
+          '/about-us/our-team', '/our-team', '/leadership', '/management', '/officers',
+          '/about-us/officers', '/team', '/meet-the-team', '/about-us/board-of-directors']) {
+          candidates.push(origin + p);
+        }
+        const aboutLink = candidates.find((u) => /about/i.test(u));
+        if (aboutLink && (await robotsAllows(aboutLink))) {
+          try { // two-hop: the About page's own nav usually links the team page
+            await page.goto(aboutLink, { waitUntil: 'domcontentloaded' });
+            await page.waitForTimeout(1000);
+            const hop = await page.evaluate((teamReSrc) => {
+              const re = new RegExp(teamReSrc, 'i');
+              const a = [...document.querySelectorAll('a[href]')].find((el) => re.test(el.getAttribute('href') || '') || re.test(el.textContent || ''));
+              return a ? a.href : null;
+            }, PAGE_HINTS.team.source);
+            if (hop) candidates.unshift(hop);
+          } catch { /* about page unreachable */ }
+        }
+      }
       let people = [];
-      for (const cand of candidates.slice(0, 3)) {
+      for (const cand of [...new Set(candidates)].slice(0, 6)) {
         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; }
+        try {
+          const resp = await page.goto(cand, { waitUntil: 'domcontentloaded' });
+          if (resp && resp.status() >= 400) continue; // probed path doesn't exist
+          await page.waitForTimeout(2000); // rendered people grids are late paints
+          people = extractPeople(await page.content());
+          if (people.length) { teamUrl = cand; break; }
+        } catch { /* dead candidate — try next */ }
       }
       if (people.length) {
         for (const cand of people.slice(0, 40)) {

← 6ffb2d7c yoloforever: cycle 3 ledger (SHIP 3/3 local)  ·  back to Rentv 2026  ·  social: collapsible left Growth·LinkedIn target rail — CRE c 835146a0 →