add public contact extraction
This commit is contained in:
+15
-2
@@ -74,7 +74,20 @@
|
||||
return `<div class="website-scan-meta"><span><b>Scan status</b><span class="website-status ${String(status).toLowerCase().replaceAll(' ','-')}">${esc(status)}</span></span><span>Checked ${esc(websiteScanValue(scan,['checked_at','last_checked_at','checked_time']))}</span><span>Cache: ${esc(websiteStateLabel(websiteScanValue(scan,['cache_state','cache_status','cache'], 'Unknown')))}</span></div>${error?`<div class="website-scan-error" role="alert"><strong>${esc(websiteStateLabel(scan.error_code||'Error'))}</strong><p>${esc(error)}</p></div>`:''}<div class="website-scan-grid"><div><dt>HTTP status</dt><dd>${esc(http)}</dd></div><div><dt>Final URL</dt><dd class="breakable">${esc(finalUrl)}</dd></div><div><dt>Redirect chain</dt><dd class="breakable">${esc(value(chain)||'None')}</dd></div><div><dt>Classification</dt><dd>${esc(classification)}</dd></div><div><dt>Title</dt><dd>${esc(websiteScanValue(scan,['title']))}</dd></div><div><dt>Meta description</dt><dd>${esc(websiteScanValue(scan,['meta_description','description']))}</dd></div><div><dt>Viewport</dt><dd>${esc(scan?.responsive_signal===true?'Responsive signal present':scan?.viewport||'Unknown')}</dd></div><div><dt>HTTPS / TLS</dt><dd>${esc(tls)}${scan?.certificate_status?` · ${esc(websiteStateLabel(scan.certificate_status))}`:''}</dd></div><div><dt>Load time</dt><dd>${esc(websiteScanValue(scan,['elapsed_ms','load_time_ms','duration_ms'],'Unknown'))}${scan?.elapsed_ms!=null?' ms':''}</dd></div><div><dt>Response size</dt><dd>${esc(websiteScanValue(scan,['response_size','size_bytes','body_size'],'Unknown'))}${scan?.size_bytes!=null?' bytes':''}</dd></div></div><div class="website-contact-signals"><h5>Contact signals</h5><div class="signal-list">${contact.map(([label,val])=>`<span class="signal ${val===true?'present':val===false?'absent':'unknown'}"><b>${esc(label)}</b>${esc(signalLabel(val))}</span>`).join('')}</div></div>`;
|
||||
}
|
||||
async function loadWebsiteScan(id, {scan=false}={}){const state=$('websiteScanState'), scanButton=$('scanWebsiteBtn'), refreshButton=$('refreshWebsiteScanBtn');if(!state||!id)return;[scanButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='<div class="detail-loading" aria-live="polite">Loading website scan…</div>';try{const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/websites/scan`,scan?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:websiteUrlFor(selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{})})}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,website_scan:result};state.innerHTML=renderWebsiteScan(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>${scan?'Website scan failed':'Unable to load website scan'}</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryWebsiteScanBtn" type="button">Try again</button></div>`;}finally{[scanButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}}
|
||||
function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain||'no detected website')}</p></div><span class="status ${st}">${esc(review)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><div class="detail-block"><h4>Pipeline stage</h4><form id="pipelineForm" class="inline-form"><select name="stage" aria-label="Pipeline stage"><option value="new" ${stage==='new'?'selected':''}>New</option><option value="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="review" ${stage==='review'?'selected':''}>Review</option><option value="suppressed" ${stage==='suppressed'?'selected':''}>Suppressed</option></select><button class="button ghost compact" type="submit">Save stage</button></form><p id="pipelineMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Contacts <span class="count">${contacts.length}</span></h4>${listItems(contacts,'No contacts added.','email')}<form id="contactForm" class="compact-form"><input name="name" placeholder="Contact name" aria-label="Contact name"><input name="email" type="email" placeholder="Email" aria-label="Contact email" required><button class="button ghost compact" type="submit">Add contact</button></form><p id="contactMessage" class="form-message" role="status"></p></div><section class="detail-block website-scan-panel" id="websiteScanPanel" data-smoke="website-scan"><div class="detail-loading">Loading website scan…</div></section><section class="detail-block domain-intelligence" id="domainIntelligencePanel" data-smoke="domain-intelligence"><div class="detail-loading">Loading domain intelligence…</div></section><div class="detail-block"><h4>Domains & websites</h4>${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}</div><div class="detail-block"><h4>Evidence timeline</h4>${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence||'Medium')}</span></p>`).join(''):''}</div><div class="detail-block"><h4>Notes <span class="count">${notes.length}</span></h4>${listItems(notes,'No notes added.','body')}<form id="noteForm" class="compact-form"><textarea name="body" rows="2" placeholder="Add a review note…" required></textarea><button class="button ghost compact" type="submit">Add note</button></form><p id="noteMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Review status</h4><p class="review-status">${esc(review)}</p>${st!=='suppressed'?'<button class="button primary compact" id="verifyBtn" type="button">Mark verified</button>':''}<p id="verifyMessage" class="form-message" role="status"></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();}
|
||||
const extractedContactItems = payload => { for (const key of ['contacts','extracted_contacts','items','results']) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; };
|
||||
const extractedContactValue = (item, keys, fallback='Unknown') => { for (const key of keys) if (item?.[key] !== undefined && item[key] !== null && item[key] !== '') return item[key]; return fallback; };
|
||||
const extractedContactStatus = item => { const status=String(extractedContactValue(item,['suppression_status','suppression','status'],'Unknown')).toLowerCase().replaceAll('_','-'); return status==='suppressed'||item?.suppressed===true ? 'Suppressed' : status==='not-suppressed'||status==='allowed'||item?.suppressed===false ? 'Not suppressed' : status.replaceAll('-',' ').replace(/\\b\\w/g,c=>c.toUpperCase()); };
|
||||
function renderExtractedContacts(payload){
|
||||
const state=$('contactExtractionState'); if(!state)return;
|
||||
if(!payload){state.innerHTML='<p class="muted">No extracted contacts returned. Extract from approved public business pages to review contact evidence.</p>';return;}
|
||||
const contacts=extractedContactItems(payload);
|
||||
if(!contacts.length){state.innerHTML='<div class="contact-extraction-empty"><strong>No public contacts found</strong><p class="muted">No contact details were found on the approved public business pages checked.</p></div>';return;}
|
||||
state.innerHTML=`<div class="extracted-contact-list">${contacts.map(item=>{const type=extractedContactValue(item,['type','contact_type','kind'],'Other'),email=extractedContactValue(item,['email','value','address']),emailClass=extractedContactValue(item,['email_class','classification','class'],'Unknown'),validation=extractedContactValue(item,['validation','validation_status','verified_status'],'Unknown'),confidence=extractedContactValue(item,['confidence','confidence_score','score']),source=extractedContactValue(item,['source_url','url','source'],'Unknown source URL'),suppression=extractedContactStatus(item);return `<article class="extracted-contact" data-contact-type="${esc(type)}"><div class="extracted-contact-head"><strong>${esc(email)}</strong><span class="contact-confidence">${esc(confidence)} confidence</span></div><dl class="extracted-contact-facts"><div><dt>Type</dt><dd>${esc(type)}</dd></div><div><dt>Email class</dt><dd>${esc(emailClass)}</dd></div><div><dt>Validation</dt><dd>${esc(validation)}</dd></div><div><dt>Suppression</dt><dd class="suppression-status">${esc(suppression)}</dd></div><div class="contact-source"><dt>Source URL</dt><dd class="breakable"><a href="${esc(source)}" target="_blank" rel="noreferrer">${esc(source)}</a></dd></div></dl></article>`;}).join('')}</div>`;
|
||||
}
|
||||
function renderContactExtractionPanel(p){const panel=$('contactExtractionPanel');if(!panel)return;panel.innerHTML=`<div class="contact-extraction-heading"><div><p class="eyebrow">PUBLIC CONTACT EXTRACTION</p><h4>Extracted contacts <span class="count">${extractedContactItems(p.extracted_contacts||p.contact_extraction).length}</span></h4></div><div class="contact-extraction-actions"><button class="button ghost compact" id="refreshContactExtractionBtn" type="button">↻ Refresh</button><button class="button primary compact" id="extractContactsBtn" type="button">Extract public contacts</button></div></div><p class="contact-extraction-safety">Extraction uses approved public business pages only. It does not probe SMTP, verify mailbox access, or send outreach.</p><div id="contactExtractionState" aria-live="polite">${p.extracted_contacts||p.contact_extraction?renderExtractedContactsMarkup(p.extracted_contacts||p.contact_extraction):'<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>'}</div>`;}
|
||||
function renderExtractedContactsMarkup(payload){const contacts=extractedContactItems(payload);if(!contacts.length)return '<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>';return '<p class="muted">Previously extracted contacts are available. Refresh to check approved public business pages again.</p>';}
|
||||
async function loadContactExtraction(id,{extract=false}={}){const state=$('contactExtractionState'),extractButton=$('extractContactsBtn'),refreshButton=$('refreshContactExtractionBtn');if(!state||!id)return;[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='<div class="detail-loading" aria-live="polite">Loading public contacts…</div>';try{const scan=selectedDetail?.website_scan||selectedDetail?.websiteScan||{};const payload={approved_public_pages_only:true,smtp_probing:false,outreach:false,website_scan_id:scan.id,source_url:scan.final_url||scan.input_url,html:scan.html};const result=await jsonRequest(extract?`/api/v1/businesses/${encodeURIComponent(id)}/contacts/extract`:`/api/v1/contact-extractions?business_id=${encodeURIComponent(id)}`,extract?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,extracted_contacts:result.contacts||result.extracted_contacts||result.items||result};renderExtractedContacts(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>${extract?'Contact extraction failed':'Unable to load extracted contacts'}</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryContactExtractionBtn" type="button">Try again</button></div>`;}finally{[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}}
|
||||
function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain||'no detected website')}</p></div><span class="status ${st}">${esc(review)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><div class="detail-block"><h4>Pipeline stage</h4><form id="pipelineForm" class="inline-form"><select name="stage" aria-label="Pipeline stage"><option value="new" ${stage==='new'?'selected':''}>New</option><option value="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="review" ${stage==='review'?'selected':''}>Review</option><option value="suppressed" ${stage==='suppressed'?'selected':''}>Suppressed</option></select><button class="button ghost compact" type="submit">Save stage</button></form><p id="pipelineMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Contacts <span class="count">${contacts.length}</span></h4>${listItems(contacts,'No contacts added.','email')}<form id="contactForm" class="compact-form"><input name="name" placeholder="Contact name" aria-label="Contact name"><input name="email" type="email" placeholder="Email" aria-label="Contact email" required><button class="button ghost compact" type="submit">Add contact</button></form><p id="contactMessage" class="form-message" role="status"></p></div><section class="detail-block contact-extraction-panel" id="contactExtractionPanel" data-smoke="contact-extraction"><div class="detail-loading">Loading public contacts…</div></section><section class="detail-block website-scan-panel" id="websiteScanPanel" data-smoke="website-scan"><div class="detail-loading">Loading website scan…</div></section><section class="detail-block domain-intelligence" id="domainIntelligencePanel" data-smoke="domain-intelligence"><div class="detail-loading">Loading domain intelligence…</div></section><div class="detail-block"><h4>Domains & websites</h4>${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}</div><div class="detail-block"><h4>Evidence timeline</h4>${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence||'Medium')}</span></p>`).join(''):''}</div><div class="detail-block"><h4>Notes <span class="count">${notes.length}</span></h4>${listItems(notes,'No notes added.','body')}<form id="noteForm" class="compact-form"><textarea name="body" rows="2" placeholder="Add a review note…" required></textarea><button class="button ghost compact" type="submit">Add note</button></form><p id="noteMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Review status</h4><p class="review-status">${esc(review)}</p>${st!=='suppressed'?'<button class="button primary compact" id="verifyBtn" type="button">Mark verified</button>':''}<p id="verifyMessage" class="form-message" role="status"></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();}
|
||||
let mergeSource = null, mergeTarget = null, mergeBusy = false;
|
||||
const payloadItems = (payload, keys) => { for (const key of keys) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; };
|
||||
const suggestionId = item => item.target_id ?? item.business_id ?? item.prospect_id ?? item.id;
|
||||
@@ -161,7 +174,7 @@
|
||||
async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
|
||||
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||
document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);});
|
||||
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
|
||||
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
|
||||
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
|
||||
bootstrap();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user