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