fix: search race condition + brand detection + contacts + reassess

- loadDomains(): add generation counter so stale auto-advance fetches
  cannot overwrite a newer user-triggered search result; snapshot filter
  state before the first await so URL reflects what was requested; add
  HTTP status check so backend errors surface as toasts rather than
  silent empty results; auto-advance now calls loadDomains() without
  await so the counter increments correctly per page advance

- beauty_ai: word-boundary regex for short brands (≤5 chars) to stop
  'ref' matching 'reference'/'refresh'/'prefer' etc.; merge phones,
  whatsapp and social_links from site_analyzer directly into result
  (more reliable than AI extraction); add contact_whatsapp and
  contact_social fields to AI JSON schema

- db: add requeue_beauty() for re-assessing already-assessed domains

- beauty_main: /api/beauty/reassess/batch endpoint using requeue_beauty

- index.html: Re-assess Selected bulk button, per-row ↺ button in
  Browse and Pipeline, WhatsApp + social links in Pipeline contact panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 11:06:58 +02:00
parent be0fbb502c
commit d9ece58e12
4 changed files with 141 additions and 27 deletions

View File

@@ -180,6 +180,10 @@ textarea{width:100%;resize:vertical;font-family:monospace;font-size:12px}
<span x-show="prescreening">Screening…</span>
</button>
<button class="btn-primary btn-sm" @click="assessSelected()">B2B Assess Selected</button>
<button class="btn-secondary btn-sm" @click="reassessSelected()" :disabled="reassessing">
<span x-show="!reassessing">Re-assess Selected</span>
<span x-show="reassessing">Re-queuing…</span>
</button>
<button class="btn-secondary btn-sm" @click="selected=[]">Clear</button>
</div>
@@ -227,6 +231,7 @@ textarea{width:100%;resize:vertical;font-family:monospace;font-size:12px}
<td style="white-space:nowrap;display:flex;gap:4px">
<button class="btn-secondary btn-sm" @click="prescreenOne(row.domain)" title="HTTP check + niche classify">Screen</button>
<button class="btn-primary btn-sm" @click="assessOne(row.domain)" title="Beauty B2B AI assessment">Assess</button>
<button x-show="row.beauty_lead_quality" class="btn-secondary btn-sm" @click="reassessOne(row.domain)" title="Re-run B2B assessment"></button>
</td>
</tr>
</template>
@@ -338,6 +343,7 @@ textarea{width:100%;resize:vertical;font-family:monospace;font-size:12px}
x-text="(row._beauty||{}).contact_email||row.emails||'—'"></td>
<td @click.stop style="white-space:nowrap;display:flex;gap:4px">
<button class="btn-secondary btn-sm" @click="copyOutreach(row)">Copy email</button>
<button class="btn-secondary btn-sm" @click="reassessOne(row.domain)" title="Re-run B2B assessment"></button>
</td>
</tr>
<!-- Expanded detail -->
@@ -371,14 +377,20 @@ textarea{width:100%;resize:vertical;font-family:monospace;font-size:12px}
</div>
<div class="detail-box">
<h4>Contact Details</h4>
<p style="font-size:12px;line-height:1.7">
<p style="font-size:12px;line-height:1.8">
<template x-if="(row._beauty||{}).contact_email">
<span>Email: <a :href="'mailto:'+(row._beauty||{}).contact_email" x-text="(row._beauty||{}).contact_email"></a><br></span>
</template>
<template x-if="(row._beauty||{}).contact_phone">
<span>Phone: <span x-text="(row._beauty||{}).contact_phone"></span><br></span>
</template>
<template x-if="row.emails">
<template x-if="(row._beauty||{}).contact_whatsapp">
<span>WhatsApp: <a :href="(row._beauty||{}).contact_whatsapp" target="_blank" x-text="(row._beauty||{}).contact_whatsapp"></a><br></span>
</template>
<template x-if="(row._beauty||{}).contact_social">
<span style="color:var(--muted)">Social: <span x-text="(row._beauty||{}).contact_social"></span><br></span>
</template>
<template x-if="row.emails && !(row._beauty||{}).contact_email">
<span style="color:var(--muted);font-size:11px">On-site: <span x-text="row.emails"></span></span>
</template>
</p>
@@ -446,7 +458,8 @@ function app() {
valSt: {running:false,processed:0,live:0,dead:0,error:0,parked:0,redirect:0,skipped:0,offset:0,rate:0},
valTld: '', valRescan: false,
toasts: [],
prescreening: false, validating: false,
prescreening: false, validating: false, reassessing: false,
_loadGen: 0, // incremented on every loadDomains() call; stale responses are discarded
exportQuality: '', exportCountry: '',
f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
site_type:'ecommerce', country:'', assessed:'', alpha_only:false, no_sld:false, limit:'100', page:1},
@@ -491,52 +504,72 @@ function app() {
goSearch() { this.f.page=1; this.loadDomains(); },
async loadDomains() {
// Generation counter: if a newer call starts while this one is awaiting the
// network, this call's result is stale and must be discarded. This prevents
// auto-advance's background fetches from overwriting a fresh user-triggered
// search that completes after the stale fetch returns.
const gen = ++this._loadGen;
this.loading = true;
try {
const p = new URLSearchParams({page: this.f.page, limit: this.f.limit});
if (this.f.keyword) p.set('keyword', this.f.keyword.trim());
if (this.f.tld) p.set('tld', this.f.tld.trim());
if (this.f.alpha_only) p.set('alpha_only', 'true');
if (this.f.no_sld) p.set('no_sld', 'true');
// Snapshot filter state NOW (before any await), so the URL we build
// matches the filters the user actually requested.
const snap = {...this.f};
const p = new URLSearchParams({page: snap.page, limit: snap.limit});
if (snap.keyword) p.set('keyword', snap.keyword.trim());
if (snap.tld) p.set('tld', snap.tld.trim());
if (snap.alpha_only) p.set('alpha_only', 'true');
if (snap.no_sld) p.set('no_sld', 'true');
// 'none' (Not checked) = domains never in the pipeline → DuckDB search.
// Any real status (live/dead/…), niche, site_type, country, or assessed
// requires the SQLite enriched_domains table (all server-side).
const hasEnrichFilter = (this.f.prescreen_status && this.f.prescreen_status !== 'none')
|| this.f.niche || this.f.site_type || this.f.country || this.f.assessed;
const hasEnrichFilter = (snap.prescreen_status && snap.prescreen_status !== 'none')
|| snap.niche || snap.site_type || snap.country || snap.assessed;
let endpoint;
if (hasEnrichFilter) {
if (this.f.prescreen_status) p.set('prescreen_status', this.f.prescreen_status);
if (this.f.niche) p.set('niche', this.f.niche);
if (this.f.site_type) p.set('site_type', this.f.site_type);
if (this.f.country) p.set('country', this.f.country.trim().toUpperCase());
if (this.f.assessed) p.set('assessed', this.f.assessed);
if (snap.prescreen_status) p.set('prescreen_status', snap.prescreen_status);
if (snap.niche) p.set('niche', snap.niche);
if (snap.site_type) p.set('site_type', snap.site_type);
if (snap.country) p.set('country', snap.country.trim().toUpperCase());
if (snap.assessed) p.set('assessed', snap.assessed);
endpoint = '/api/enriched';
} else {
endpoint = '/api/domains';
}
const d = await fetch(endpoint + '?' + p).then(r=>r.json());
const d = await fetch(endpoint + '?' + p).then(r => {
if (!r.ok) throw new Error(`Server error ${r.status}`);
return r.json();
});
// Discard if a newer search was started while this one was in-flight
if (gen !== this._loadGen) return;
this.domainsTotal = d.total || 0;
let rows = d.results || [];
// 'Not checked': DuckDB returns all domains joined with enriched data;
// keep only those with no prescreen_status yet (truly unprocessed).
if (this.f.prescreen_status === 'none') rows = rows.filter(r => !r.prescreen_status);
if (snap.prescreen_status === 'none') rows = rows.filter(r => !r.prescreen_status);
// Auto-advance: current DuckDB page was fully processed → try next page
// (prevents "0 results" after bulk-validating a page of Not checked domains)
if (rows.length === 0 && this.f.prescreen_status === 'none'
&& (d.results||[]).length > 0 && this.f.page < 500) {
this.f.page++;
this.loading = false;
await this.loadDomains();
if (rows.length === 0 && snap.prescreen_status === 'none'
&& (d.results||[]).length > 0 && snap.page < 500) {
this.f.page = snap.page + 1;
// Do NOT await — start the next page search as a fresh call so the
// generation counter works correctly; this call ends immediately.
this.loadDomains();
return;
}
this.domains = rows;
} catch(e) { this.notify('Failed to load: '+e.message, 'error'); }
finally { this.loading = false; }
} catch(e) {
if (gen !== this._loadGen) return;
this.notify('Failed to load: '+e.message, 'error');
} finally {
if (gen === this._loadGen) this.loading = false;
}
},
async loadLeads() {
@@ -644,6 +677,30 @@ function app() {
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
},
async reassessSelected() {
if (!this.selected.length || this.reassessing) return;
this.reassessing = true;
try {
const d = await fetch('/api/beauty/reassess/batch', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({domains: this.selected}),
}).then(r=>r.json());
this.notify(`Re-queued ${d.requeued} domains for fresh B2B assessment`, 'success');
this.selected = [];
} catch(e) { this.notify('Re-assess failed: '+e.message, 'error'); }
finally { this.reassessing = false; }
},
async reassessOne(domain) {
try {
await fetch('/api/beauty/reassess/batch', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({domains:[domain]}),
});
this.notify(`${domain} re-queued for fresh assessment`, 'success');
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
},
toggleLead(domain) {
this.expandedLead = this.expandedLead===domain ? null : domain;
},