1
2
3
4
5
6
7
8import http from 'node:http';
9import { Actor, log } from 'apify';
10
11import { createScorer, scoreToBand } from './scorer.js';
12
13
14
15
16
17
18function normalizeLeads(leads) {
19 return leads.flatMap((lead) => {
20 if (Array.isArray(lead)) {
21 log.warning(`Flattening nested array of ${lead.length} leads`);
22 return lead.filter((item) => item && typeof item === 'object' && !Array.isArray(item));
23 }
24 if (lead && typeof lead === 'object' && 'input' in lead && typeof lead.input === 'object') {
25 return lead.input;
26 }
27 if (lead && typeof lead === 'object') return lead;
28 return [];
29 }).filter(Boolean);
30}
31
32
33
34
35function generateLabel(lead) {
36 const name = lead.name || lead.fullName || [lead.firstName, lead.lastName].filter(Boolean).join(' ') || null;
37 const title = lead.title || lead.jobTitle || lead.position || null;
38 const company = lead.company || lead.companyName || lead.organization || null;
39
40 const parts = [];
41 if (name && title) {
42 parts.push(`${name}, ${title}`);
43 } else if (name || title) {
44 parts.push(name || title);
45 }
46 if (company) {
47 parts.push(parts.length > 0 ? `at ${company}` : company);
48 }
49 return parts.join(' ') || JSON.stringify(lead).slice(0, 80);
50}
51
52await Actor.init();
53
54if (Actor.config.get('metaOrigin') === 'STANDBY') {
55 startStandbyServer();
56} else {
57 await runBatchMode();
58 await Actor.exit();
59}
60
61
62
63
64
65function startStandbyServer() {
66 const port = Actor.config.get('containerPort');
67
68 const server = http.createServer(async (req, res) => {
69 if (req.headers['x-apify-container-server-readiness-probe']) {
70 res.writeHead(200, { 'Content-Type': 'application/json' });
71 res.end(JSON.stringify({ status: 'ready' }));
72 return;
73 }
74
75 if (req.method !== 'GET') {
76 res.writeHead(405, { 'Content-Type': 'application/json' });
77 res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
78 return;
79 }
80
81 const reqUrl = new URL(req.url, `http://${req.headers.host}`);
82 if (!reqUrl.searchParams.get('lead') && !reqUrl.searchParams.get('icp')) {
83 res.writeHead(200, { 'Content-Type': 'text/html' });
84 res.end(getLandingPageHtml());
85 return;
86 }
87
88 try {
89 const result = await handleStandbyRequest(reqUrl, req);
90 await Actor.pushData(result);
91
92 res.writeHead(200, { 'Content-Type': 'application/json' });
93 res.end(JSON.stringify(result));
94 } catch (err) {
95 const statusCode = err.statusCode || 500;
96 res.writeHead(statusCode, { 'Content-Type': 'application/json' });
97 res.end(JSON.stringify({ error: err.message }));
98 }
99 });
100
101 server.listen(port, () => {
102 console.log(`Superlead ICP Scorer Standby server listening on port ${port}`);
103 });
104}
105
106async function handleStandbyRequest(reqUrl, req) {
107 const leadParam = reqUrl.searchParams.get('lead');
108 const icp = reqUrl.searchParams.get('icp');
109
110 if (!leadParam) {
111 const err = new Error("Provide a 'lead' query parameter with the lead object as JSON.");
112 err.statusCode = 400;
113 throw err;
114 }
115
116 if (!icp) {
117 const err = new Error("Provide an 'icp' query parameter with your Ideal Customer Profile description.");
118 err.statusCode = 400;
119 throw err;
120 }
121
122 let lead;
123 try {
124 lead = JSON.parse(leadParam);
125 } catch {
126 const err = new Error("Invalid JSON in 'lead' parameter. Provide a valid JSON object.");
127 err.statusCode = 400;
128 throw err;
129 }
130
131 if (!lead || typeof lead !== 'object' || Array.isArray(lead)) {
132 const err = new Error("'lead' must be a JSON object (not an array or primitive).");
133 err.statusCode = 400;
134 throw err;
135 }
136
137 const model = reqUrl.searchParams.get('model') || 'openrouter/auto';
138 const openRouterApiKey = req.headers['x-openrouter-key'] || null;
139
140 const scorer = createScorer(model, icp, openRouterApiKey);
141 const results = await scorer.score([lead]);
142
143 if (results.length === 0) {
144 return { id: 1, label: generateLabel(lead), score: 50, band: 'fair', summary: 'Unable to score lead.', confidence: 0 };
145 }
146
147 const r = results[0];
148 return {
149 id: 1,
150 input: lead,
151 label: generateLabel(lead),
152 score: r.score,
153 band: scoreToBand(r.score),
154 summary: r.summary,
155 confidence: Math.round(r.confidence * 100) / 100,
156 };
157}
158
159
160
161
162
163async function runBatchMode() {
164 const input = await Actor.getInput();
165 let { leads: rawLeads, lead, icp } = input || {};
166
167
168 if (lead && typeof lead === 'object' && !Array.isArray(lead)) {
169 if (!Array.isArray(rawLeads)) rawLeads = [];
170 rawLeads = [lead, ...rawLeads];
171 }
172
173
174 if (!icp || typeof icp !== 'string' || !icp.trim()) {
175 await Actor.pushData({ error: 'missing_icp', message: 'Missing or invalid "icp" field. Provide a string describing your Ideal Customer Profile, e.g. "B2B SaaS companies with 50-500 employees".' });
176 await Actor.exit('Skipped: Missing or invalid "icp" field. Provide a string describing your Ideal Customer Profile, e.g. "B2B SaaS companies with 50-500 employees".');
177 return;
178 }
179
180
181 if (!rawLeads || !Array.isArray(rawLeads) || rawLeads.length === 0) {
182 await Actor.pushData({ error: 'missing_leads', message: 'Missing or invalid "leads" field. Provide an array of lead objects, e.g. [{"company": "Acme Corp", "title": "VP of Engineering"}]. You can also pass a single lead via the "lead" field.' });
183 await Actor.exit('Skipped: Missing or invalid "leads" field. Provide an array of lead objects, e.g. [{"company": "Acme Corp", "title": "VP of Engineering"}]. You can also pass a single lead via the "lead" field.');
184 return;
185 }
186
187 const model = input.model ?? 'openrouter/auto';
188 const openRouterApiKey = input.openRouterApiKey ?? null;
189
190
191 const leads = normalizeLeads(rawLeads);
192
193 if (leads.length === 0) {
194 await Actor.pushData({ error: 'no_valid_leads', message: 'No valid lead objects found after input normalization. Each item in "leads" must be a JSON object, e.g. [{"company": "Acme Corp", "title": "VP of Engineering"}].' });
195 await Actor.exit('Skipped: No valid lead objects found after input normalization. Each item in "leads" must be a JSON object, e.g. [{"company": "Acme Corp", "title": "VP of Engineering"}].');
196 return;
197 }
198
199 if (leads.length > 5000) {
200 const message = `Too many leads (${leads.length}). Maximum is 5,000 per run. Split your data into smaller batches.`;
201 await Actor.pushData({ error: 'too_many_leads', message });
202 await Actor.exit(`Skipped: ${message}`);
203 return;
204 }
205
206 if (leads.length > 500) {
207 log.warning(`Large batch of ${leads.length} leads — this may take several minutes.`);
208 }
209
210 log.info(`Starting ICP scoring of ${leads.length} leads`, { model });
211
212
213 const scorer = createScorer(model, icp.trim(), openRouterApiKey);
214 const scoreResults = await scorer.score(leads);
215
216
217 const results = leads.map((lead, i) => {
218 const r = scoreResults[i];
219 return {
220 id: i + 1,
221 input: lead,
222 label: generateLabel(lead),
223 score: r.score,
224 band: scoreToBand(r.score),
225 summary: r.summary,
226 confidence: Math.round(r.confidence * 100) / 100,
227 };
228 });
229
230
231 for (const result of results) {
232 await Actor.pushData(result);
233 }
234
235
236 const avgScore = Math.round(results.reduce((sum, r) => sum + r.score, 0) / results.length);
237 const bandCounts = results.reduce((acc, r) => { acc[r.band] = (acc[r.band] || 0) + 1; return acc; }, {});
238
239 log.info('Scoring complete', {
240 totalLeads: results.length,
241 averageScore: avgScore,
242 bands: bandCounts,
243 });
244}
245
246
247
248
249
250function getLandingPageHtml() {
251 return `<!DOCTYPE html>
252<html lang="en">
253<head>
254<meta charset="utf-8">
255<meta name="viewport" content="width=device-width, initial-scale=1">
256<title>Superlead ICP Scorer — Superlative</title>
257<link rel="preconnect" href="https://fonts.googleapis.com">
258<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
259<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
260<style>
261:root{--color-bg:#0a0e14;--color-surface:#12171f;--color-border:#1e2632;--color-text:#e8eaed;--color-text-muted:#8b95a5;--color-accent:#60a5fa;--color-accent-dim:rgba(96,165,250,0.15);--font-display:'Space Mono',monospace;--font-body:'DM Sans',sans-serif}
262*{margin:0;padding:0;box-sizing:border-box}
263body{font-family:var(--font-body);background:var(--color-bg);color:var(--color-text);line-height:1.6;min-height:100vh;overflow-x:hidden}
264a{color:var(--color-accent);text-decoration:none;transition:color .2s ease}
265a:hover{color:#93c5fd}
266.grid-bg{position:fixed;top:0;left:0;width:100%;height:100%;background-image:linear-gradient(var(--color-border) 1px,transparent 1px),linear-gradient(90deg,var(--color-border) 1px,transparent 1px);background-size:60px 60px;opacity:.3;pointer-events:none;z-index:0}
267.accent-line{position:fixed;top:0;left:0;width:100%;height:2px;background:linear-gradient(90deg,transparent 0%,var(--color-accent) 50%,transparent 100%);animation:shimmer 3s ease-in-out infinite;z-index:10}
268@keyframes shimmer{0%,100%{transform:translateX(-100%)}50%{transform:translateX(100%)}}
269@keyframes fadeUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
270.container{position:relative;z-index:1;max-width:900px;margin:0 auto;padding:0 24px}
271header{padding:32px 0}
272.logo{font-family:var(--font-display);font-size:14px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text);display:flex;align-items:center;gap:10px;text-decoration:none}
273.logo:hover{color:var(--color-text)}
274.logo-mark{width:28px;height:28px;background:var(--color-accent);display:flex;align-items:center;justify-content:center}
275.logo-mark svg{width:16px;height:16px}
276.hero{padding:80px 0 60px;animation:fadeUp .8s ease-out}
277.hero-label{font-family:var(--font-display);font-size:12px;font-weight:400;letter-spacing:.15em;text-transform:uppercase;color:var(--color-accent);margin-bottom:24px;display:flex;align-items:center;gap:12px}
278.hero-label::before{content:'';width:24px;height:1px;background:var(--color-accent)}
279h1{font-family:var(--font-display);font-size:clamp(28px,5vw,44px);font-weight:700;line-height:1.15;margin-bottom:20px;letter-spacing:-.02em}
280h1 .highlight{color:var(--color-accent)}
281.hero-desc{font-size:18px;color:var(--color-text-muted);max-width:540px;margin-bottom:28px;line-height:1.7}
282.hero-link{font-family:var(--font-display);font-size:13px;letter-spacing:.05em;color:var(--color-accent);display:inline-flex;align-items:center;gap:6px}
283.section{padding:60px 0;border-top:1px solid var(--color-border);animation:fadeUp .8s ease-out .2s backwards}
284.section-label{font-family:var(--font-display);font-size:11px;font-weight:400;letter-spacing:.2em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:24px}
285h2{font-family:var(--font-display);font-size:20px;font-weight:700;margin-bottom:16px;letter-spacing:-.01em}
286p,li{font-size:15px;color:var(--color-text-muted);line-height:1.7}
287li{margin-bottom:6px}
288ul{margin:.5rem 0 1rem 1.25rem}
289strong{color:var(--color-text);font-weight:600}
290pre{background:var(--color-surface);color:var(--color-text);padding:16px 20px;overflow-x:auto;margin:12px 0 16px;font-family:var(--font-display);font-size:13px;line-height:1.6;border:1px solid var(--color-border)}
291code{font-family:var(--font-display);font-size:13px}
292p code,li code,td code{background:var(--color-accent-dim);color:var(--color-accent);padding:2px 6px}
293table{width:100%;border-collapse:collapse;margin:12px 0 16px;font-size:14px}
294th,td{border:1px solid var(--color-border);padding:10px 14px;text-align:left}
295th{background:var(--color-surface);color:var(--color-text-muted);font-family:var(--font-display);font-size:12px;font-weight:400;letter-spacing:.05em;text-transform:uppercase}
296td{color:var(--color-text-muted)}
297.badge{display:inline-block;background:var(--color-accent-dim);color:var(--color-accent);padding:3px 8px;font-family:var(--font-display);font-size:11px;font-weight:400;letter-spacing:.05em}
298.note{font-size:13px;color:var(--color-text-muted);margin-top:8px}
299.product-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;margin-top:24px}
300.product-card{background:var(--color-surface);border:1px solid var(--color-border);padding:24px;transition:all .3s ease;text-decoration:none;color:inherit;display:block}
301.product-card:hover{border-color:var(--color-accent);transform:translateY(-2px)}
302.product-name{font-family:var(--font-display);font-size:14px;font-weight:700;margin-bottom:8px;color:var(--color-text);display:flex;align-items:center;gap:8px}
303.product-name .status{font-size:9px;font-weight:400;padding:3px 6px;background:var(--color-accent-dim);color:var(--color-accent);letter-spacing:.1em}
304.product-desc{font-size:14px;color:var(--color-text-muted);line-height:1.5}
305footer{padding:48px 0;border-top:1px solid var(--color-border)}
306.footer-content{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:24px}
307.footer-text{font-size:13px;color:var(--color-text-muted)}
308.footer-links{display:flex;gap:24px}
309.footer-links a{font-family:var(--font-display);font-size:12px;color:var(--color-text-muted);letter-spacing:.05em}
310.footer-links a:hover{color:var(--color-accent)}
311@media(max-width:600px){.hero{padding:60px 0 40px}.section{padding:40px 0}.footer-content{flex-direction:column;align-items:flex-start}pre{font-size:12px;padding:12px 14px}}
312</style>
313</head>
314<body>
315<div class="grid-bg"></div>
316<div class="accent-line"></div>
317
318<div class="container">
319 <header>
320 <a href="https://apify.com/superlativetech?fpr=8e9l1" class="logo">
321 <div class="logo-mark">
322 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
323 <polyline points="4 17 10 11 4 5"></polyline>
324 <line x1="12" y1="19" x2="20" y2="19"></line>
325 </svg>
326 </div>
327 Superlative
328 </a>
329 </header>
330
331 <section class="hero">
332 <div class="hero-label">Superlead</div>
333 <h1>ICP <span class="highlight">Scorer</span></h1>
334 <p class="hero-desc">AI-powered lead scoring against your Ideal Customer Profile. Get fit scores, band labels, and plain-English explanations for every lead. Sub-second responses via Standby API.</p>
335 <a href="https://apify.com/superlativetech/superlead-icp-scorer?fpr=8e9l1" class="hero-link">View on Apify Store →</a>
336 </section>
337
338 <section class="section">
339 <div class="section-label">Features</div>
340 <h2>What this does</h2>
341 <ul>
342 <li><strong>AI-powered scoring</strong> — LLM evaluates every lead against your ICP description</li>
343 <li><strong>Fit scores (0-100)</strong> — Excellent, good, fair, or poor band labels</li>
344 <li><strong>Plain-English summaries</strong> — Explains why each lead scored the way it did</li>
345 <li><strong>Any lead format</strong> — Works with any JSON fields (company, title, industry, etc.)</li>
346 <li><strong>BYOK support</strong> — Use your own OpenRouter API key via header</li>
347 <li><strong>Batch mode</strong> — Score hundreds of leads via the standard Actor run</li>
348 </ul>
349 </section>
350
351 <section class="section">
352 <div class="section-label">Getting Started</div>
353 <h2>Quick start</h2>
354 <p>Replace <code>YOUR_TOKEN</code> with your Apify API token.</p>
355
356 <p style="margin-top:16px"><strong>Score a single lead:</strong></p>
357 <pre>curl "https://superlativetech--superlead-icp-scorer.apify.actor?token=YOUR_TOKEN&lead=%7B%22company%22%3A%22Acme%22%2C%22title%22%3A%22VP+Engineering%22%7D&icp=B2B+SaaS+50-500+employees"</pre>
358
359 <p><strong>With your own OpenRouter key:</strong></p>
360 <pre>curl -H "X-OpenRouter-Key: sk-or-..." "https://superlativetech--superlead-icp-scorer.apify.actor?token=YOUR_TOKEN&lead=%7B%22company%22%3A%22Acme%22%7D&icp=B2B+SaaS"</pre>
361 </section>
362
363 <section class="section">
364 <div class="section-label">Reference</div>
365 <h2>Query parameters</h2>
366 <table>
367 <tr><th>Parameter</th><th>Required</th><th>Description</th></tr>
368 <tr><td><code>lead</code></td><td>Yes</td><td>Lead object as JSON string</td></tr>
369 <tr><td><code>icp</code></td><td>Yes</td><td>Ideal Customer Profile description (plain text)</td></tr>
370 <tr><td><code>model</code></td><td>No</td><td>LLM model (default: <code>openrouter/auto</code>)</td></tr>
371 <tr><td><code>token</code></td><td>Yes</td><td>Your Apify API token</td></tr>
372 </table>
373 <h2 style="margin-top:24px">Headers</h2>
374 <table>
375 <tr><th>Header</th><th>Required</th><th>Description</th></tr>
376 <tr><td><code>X-OpenRouter-Key</code></td><td>No</td><td>Your OpenRouter API key for BYOK (keeps secrets out of URL logs)</td></tr>
377 </table>
378 </section>
379
380 <section class="section">
381 <div class="section-label">Response</div>
382 <h2>Response format</h2>
383 <pre>{
384 "id": 1,
385 "input": {"company": "Acme Corp", "title": "VP of Engineering"},
386 "label": "VP of Engineering at Acme Corp",
387 "score": 85,
388 "band": "excellent",
389 "summary": "Strong fit: VP-level decision maker at mid-size SaaS company.",
390 "confidence": 0.9
391}</pre>
392 </section>
393
394 <section class="section">
395 <div class="section-label">Scoring</div>
396 <h2>Score bands</h2>
397 <table>
398 <tr><th>Band</th><th>Score Range</th><th>Meaning</th></tr>
399 <tr><td><span class="badge">Excellent</span></td><td>80-100</td><td>Strong ICP match — prioritize for outreach</td></tr>
400 <tr><td><span class="badge">Good</span></td><td>60-79</td><td>Solid fit with minor gaps</td></tr>
401 <tr><td><span class="badge">Fair</span></td><td>40-59</td><td>Partial fit — worth reviewing</td></tr>
402 <tr><td><span class="badge">Poor</span></td><td>0-39</td><td>Does not match ICP</td></tr>
403 </table>
404 </section>
405
406 <section class="section">
407 <div class="section-label">Cost</div>
408 <h2>Pricing</h2>
409 <p>Free to use — you only pay standard Apify platform usage.</p>
410 </section>
411
412 <section class="section">
413 <div class="section-label">Auth</div>
414 <h2>Authentication</h2>
415 <p>Authenticate using either method:</p>
416 <ul>
417 <li>Query parameter: <code>?token=YOUR_APIFY_TOKEN</code></li>
418 <li>Header: <code>Authorization: Bearer YOUR_APIFY_TOKEN</code></li>
419 </ul>
420 <p style="margin-top:8px">Get your token from <a href="https://console.apify.com/settings/integrations">Apify Console → Settings → Integrations</a>.</p>
421 </section>
422
423 <section class="section">
424 <div class="section-label">Superlative</div>
425 <h2>More from Superlative</h2>
426 <div class="product-grid">
427 <a href="https://apify.com/superlativetech/superclean-company-names?fpr=8e9l1" class="product-card">
428 <div class="product-name">Company Names <span class="status">Live</span></div>
429 <p class="product-desc">Clean company names for cold email and CRM.</p>
430 </a>
431 <a href="https://apify.com/superlativetech/superclean-job-titles?fpr=8e9l1" class="product-card">
432 <div class="product-name">Job Titles <span class="status">Live</span></div>
433 <p class="product-desc">Standardize job titles for outreach sequences.</p>
434 </a>
435 <a href="https://apify.com/superlativetech/superclean-person-names?fpr=8e9l1" class="product-card">
436 <div class="product-name">Person Names <span class="status">Live</span></div>
437 <p class="product-desc">Clean and format person names for personalization.</p>
438 </a>
439 <a href="https://apify.com/superlativetech/superclean-product-names?fpr=8e9l1" class="product-card">
440 <div class="product-name">Product Names <span class="status">Live</span></div>
441 <p class="product-desc">Normalize product and brand names from exports.</p>
442 </a>
443 <a href="https://apify.com/superlativetech/supernet-domain-health?fpr=8e9l1" class="product-card">
444 <div class="product-name">Domain Health <span class="status">Live</span></div>
445 <p class="product-desc">Check email deliverability and domain reputation.</p>
446 </a>
447 <a href="https://apify.com/superlativetech/dns-lookup?fpr=8e9l1" class="product-card">
448 <div class="product-name">DNS Lookup <span class="status">Live</span></div>
449 <p class="product-desc">Look up DNS records for any domain.</p>
450 </a>
451 </div>
452 </section>
453
454 <footer>
455 <div class="footer-content">
456 <p class="footer-text">© 2026 Superlative</p>
457 <div class="footer-links">
458 <a href="https://apify.com/superlativetech?fpr=8e9l1">Apify Store</a>
459 </div>
460 </div>
461 </footer>
462</div>
463
464</body>
465</html>`;
466}