1
2
3
4
5
6
7
8import http from 'node:http';
9import { Actor, log } from 'apify';
10
11import { createNormalizer } from './normalizer.js';
12import { preprocess } from './preprocessor.js';
13
14const PPE_EVENT = 'person-name-clean';
15
16
17
18
19
20
21function normalizeItems(items) {
22 return items.map((item) => {
23 if (typeof item === 'string') {
24 return item;
25 }
26 if (item && typeof item === 'object' && 'input' in item) {
27 return String(item.input ?? '');
28 }
29 return String(item ?? '');
30 });
31}
32
33await Actor.init();
34
35if (Actor.config.get('metaOrigin') === 'STANDBY') {
36 startStandbyServer();
37} else {
38 await runBatchMode();
39 await Actor.exit();
40}
41
42
43
44
45
46function startStandbyServer() {
47 const port = Actor.config.get('containerPort');
48
49 const server = http.createServer(async (req, res) => {
50
51 if (req.headers['x-apify-container-server-readiness-probe']) {
52 res.writeHead(200, { 'Content-Type': 'application/json' });
53 res.end(JSON.stringify({ status: 'ready' }));
54 return;
55 }
56
57
58 if (req.method !== 'GET') {
59 res.writeHead(405, { 'Content-Type': 'application/json' });
60 res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
61 return;
62 }
63
64
65 const reqUrl = new URL(req.url, `http://${req.headers.host}`);
66 if (!reqUrl.searchParams.get('input')) {
67 res.writeHead(200, { 'Content-Type': 'text/html' });
68 res.end(getLandingPageHtml());
69 return;
70 }
71
72 try {
73 const result = await handleStandbyRequest(reqUrl, req);
74 await Actor.pushData(result);
75
76
77 try {
78 await Actor.charge({ eventName: PPE_EVENT, count: 1 });
79 } catch (chargeErr) {
80 console.warn(`PPE charge failed: ${chargeErr.message}`);
81 }
82
83 res.writeHead(200, { 'Content-Type': 'application/json' });
84 res.end(JSON.stringify(result));
85 } catch (err) {
86 const statusCode = err.statusCode || 500;
87 res.writeHead(statusCode, { 'Content-Type': 'application/json' });
88 res.end(JSON.stringify({ error: err.message }));
89 }
90 });
91
92 server.listen(port, () => {
93 console.log(`Superclean Person Names Standby server listening on port ${port}`);
94 });
95}
96
97async function handleStandbyRequest(reqUrl, req) {
98 const input = reqUrl.searchParams.get('input');
99
100 if (!input) {
101 const err = new Error("Provide an 'input' query parameter with the person name to clean.");
102 err.statusCode = 400;
103 throw err;
104 }
105
106 const style = reqUrl.searchParams.get('style') || 'casual';
107 const model = reqUrl.searchParams.get('model') || 'openrouter/auto';
108 const openRouterApiKey = req.headers['x-openrouter-key'] || null;
109
110 const validStyles = ['first', 'casual', 'formal'];
111 if (!validStyles.includes(style)) {
112 const err = new Error(`Invalid style "${style}". Must be one of: ${validStyles.join(', ')}`);
113 err.statusCode = 400;
114 throw err;
115 }
116
117 const trimmed = input.trim();
118 if (!trimmed) {
119 return { id: 1, input, output: '', confidence: 0 };
120 }
121
122
123 const preprocessResult = preprocess(trimmed);
124
125
126 if (preprocessResult.isNonEnglish) {
127 return {
128 id: 1,
129 input: trimmed,
130 output: preprocessResult.extractedEnglish || trimmed,
131 confidence: 0,
132 };
133 }
134
135
136 const item = {
137 id: 1,
138 input: trimmed,
139 preProcessed: preprocessResult.value,
140 isNonEnglish: false,
141 extractedEnglish: preprocessResult.extractedEnglish,
142 detectedComponents: preprocessResult.detectedComponents,
143 };
144
145 const normalizer = createNormalizer(model, style, openRouterApiKey);
146 const normalizedItems = await normalizer.normalize([item]);
147
148 if (normalizedItems.length === 0) {
149 return { id: 1, input: trimmed, output: trimmed, confidence: 0 };
150 }
151
152 return {
153 id: 1,
154 input: trimmed,
155 output: normalizedItems[0].normalized,
156 confidence: Math.round(normalizedItems[0].confidence * 100) / 100,
157 };
158}
159
160
161
162
163
164async function runBatchMode() {
165
166 const input = await Actor.getInput();
167 let { items: rawItems, item } = input || {};
168
169
170 if (typeof item === 'string' && item.trim()) {
171 if (!Array.isArray(rawItems)) rawItems = [];
172 rawItems = [item.trim(), ...rawItems];
173 }
174
175
176 if (typeof rawItems === 'string') rawItems = [rawItems];
177
178
179 if (!rawItems || !Array.isArray(rawItems) || rawItems.length === 0) {
180 await Actor.fail('Input required: provide "items" (array) or "item" (single string). Example: {"items": ["John Smith"]}');
181 return;
182 }
183
184
185 const style = input.style ?? 'casual';
186 const model = input.model ?? 'openrouter/auto';
187 const openRouterApiKey = input.openRouterApiKey ?? null;
188
189
190 const items = normalizeItems(rawItems);
191
192 log.info(`Starting normalization of ${items.length} person names`, {
193 style,
194 model,
195 });
196
197
198 const processedItems = items.map((item, index) => {
199
200 const value = typeof item === 'string' ? item : String(item || '');
201 const id = index + 1;
202
203
204 if (!value.trim()) {
205 log.warning(`Skipping invalid item at index ${index}`, { item });
206 return null;
207 }
208
209
210 const preprocessResult = preprocess(value);
211
212 return {
213 id,
214 input: value,
215 preProcessed: preprocessResult.value,
216 isNonEnglish: preprocessResult.isNonEnglish,
217 extractedEnglish: preprocessResult.extractedEnglish,
218 detectedComponents: preprocessResult.detectedComponents,
219 };
220 });
221
222
223 const validItems = processedItems.filter((item) => item !== null);
224
225 if (validItems.length === 0) {
226 throw new Error('No valid items to process after input validation');
227 }
228
229
230 const englishItems = validItems.filter((item) => !item.isNonEnglish);
231 const nonEnglishItems = validItems.filter((item) => item.isNonEnglish);
232
233 if (nonEnglishItems.length > 0) {
234 log.info(`Flagging ${nonEnglishItems.length} non-English names for review`);
235 }
236
237 log.info(`Pre-processed ${validItems.length} items (${englishItems.length} English, ${nonEnglishItems.length} non-English)`);
238
239
240 let normalizedItems = [];
241 if (englishItems.length > 0) {
242 const normalizer = createNormalizer(model, style, openRouterApiKey);
243 normalizedItems = await normalizer.normalize(englishItems);
244 }
245
246
247 const englishResults = normalizedItems.map((item) => ({
248 id: item.id,
249 input: item.input,
250 output: item.normalized,
251 confidence: Math.round(item.confidence * 100) / 100,
252 }));
253
254 const nonEnglishResults = nonEnglishItems.map((item) => ({
255 id: item.id,
256 input: item.input,
257 output: item.extractedEnglish || item.input,
258 confidence: 0,
259 }));
260
261 const results = [...englishResults, ...nonEnglishResults];
262
263
264 let chargedCount = 0;
265 let chargeLimitReached = false;
266 for (const result of results) {
267 if (!chargeLimitReached) {
268 try {
269 const chargeResult = await Actor.charge({ eventName: PPE_EVENT, count: 1 });
270 chargedCount++;
271 if (chargeResult?.eventChargeLimitReached) {
272 chargeLimitReached = true;
273 console.log(`User spending limit reached after charging ${chargedCount}/${results.length} items`);
274 }
275 } catch (chargeErr) {
276 console.warn(`PPE charge failed: ${chargeErr.message}`);
277 }
278 }
279 }
280
281 await Actor.pushData(results);
282
283
284 const avgConfidence = results.reduce((sum, r) => sum + r.confidence, 0) / results.length;
285
286 log.info(`Normalization complete`, {
287 totalItems: results.length,
288 charged: chargedCount,
289 averageConfidence: Math.round(avgConfidence * 100) / 100,
290 });
291}
292
293
294
295
296
297function getLandingPageHtml() {
298 return `<!DOCTYPE html>
299<html lang="en">
300<head>
301<meta charset="utf-8">
302<meta name="viewport" content="width=device-width, initial-scale=1">
303<title>Superclean Person Names — Superlative</title>
304<link rel="preconnect" href="https://fonts.googleapis.com">
305<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
306<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
307<style>
308:root{--color-bg:#0a0e14;--color-surface:#12171f;--color-border:#1e2632;--color-text:#e8eaed;--color-text-muted:#8b95a5;--color-accent:#4ade80;--color-accent-dim:rgba(74,222,128,0.15);--font-display:'Space Mono',monospace;--font-body:'DM Sans',sans-serif}
309*{margin:0;padding:0;box-sizing:border-box}
310body{font-family:var(--font-body);background:var(--color-bg);color:var(--color-text);line-height:1.6;min-height:100vh;overflow-x:hidden}
311a{color:var(--color-accent);text-decoration:none;transition:color .2s ease}a:hover{color:#6ee7a0}
312.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}
313.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}
314@keyframes shimmer{0%,100%{transform:translateX(-100%)}50%{transform:translateX(100%)}}
315@keyframes fadeUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
316.container{position:relative;z-index:1;max-width:900px;margin:0 auto;padding:0 24px}
317header{padding:32px 0}.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}.logo:hover{color:var(--color-text)}.logo-mark{width:28px;height:28px;background:var(--color-accent);display:flex;align-items:center;justify-content:center}.logo-mark svg{width:16px;height:16px}
318.hero{padding:80px 0 60px;animation:fadeUp .8s ease-out}.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}.hero-label::before{content:'';width:24px;height:1px;background:var(--color-accent)}
319h1{font-family:var(--font-display);font-size:clamp(28px,5vw,44px);font-weight:700;line-height:1.15;margin-bottom:20px;letter-spacing:-.02em}h1 .highlight{color:var(--color-accent)}
320.hero-desc{font-size:18px;color:var(--color-text-muted);max-width:540px;margin-bottom:28px;line-height:1.7}.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}
321.section{padding:60px 0;border-top:1px solid var(--color-border);animation:fadeUp .8s ease-out .2s backwards}.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}
322h2{font-family:var(--font-display);font-size:20px;font-weight:700;margin-bottom:16px;letter-spacing:-.01em}p,li{font-size:15px;color:var(--color-text-muted);line-height:1.7}li{margin-bottom:6px}ul{margin:.5rem 0 1rem 1.25rem}strong{color:var(--color-text);font-weight:600}
323pre{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)}code{font-family:var(--font-display);font-size:13px}p code,li code,td code{background:var(--color-accent-dim);color:var(--color-accent);padding:2px 6px}
324table{width:100%;border-collapse:collapse;margin:12px 0 16px;font-size:14px}th,td{border:1px solid var(--color-border);padding:10px 14px;text-align:left}th{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}td{color:var(--color-text-muted)}
325.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}
326.product-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;margin-top:24px}.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}.product-card:hover{border-color:var(--color-accent);transform:translateY(-2px)}.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}.product-name .status{font-size:9px;font-weight:400;padding:3px 6px;background:var(--color-accent-dim);color:var(--color-accent);letter-spacing:.1em}.product-desc{font-size:14px;color:var(--color-text-muted);line-height:1.5}
327footer{padding:48px 0;border-top:1px solid var(--color-border)}.footer-content{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:24px}.footer-text{font-size:13px;color:var(--color-text-muted)}.footer-links{display:flex;gap:24px}.footer-links a{font-family:var(--font-display);font-size:12px;color:var(--color-text-muted);letter-spacing:.05em}.footer-links a:hover{color:var(--color-accent)}
328@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}}
329</style>
330</head>
331<body>
332<div class="grid-bg"></div>
333<div class="accent-line"></div>
334<div class="container">
335 <header><a href="https://apify.com/superlativetech?fpr=8e9l1" class="logo"><div class="logo-mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg></div>Superlative</a></header>
336
337 <section class="hero">
338 <div class="hero-label">Superclean</div>
339 <h1>Person <span class="highlight">Names</span></h1>
340 <p class="hero-desc">Instant person name cleaning. AI-powered normalization for cold email personalization and CRM. Sub-second responses via Standby API.</p>
341 <a href="https://apify.com/superlativetech/superclean-person-names?fpr=8e9l1" class="hero-link">View on Apify Store →</a>
342 </section>
343
344 <section class="section">
345 <div class="section-label">Features</div>
346 <h2>What this does</h2>
347 <ul>
348 <li><strong>AI-powered cleaning</strong> — LLM normalizes person names with context awareness</li>
349 <li><strong>Three styles</strong> — First name only, Casual, Formal</li>
350 <li><strong>Handles titles</strong> — Strips Dr., Mr., Mrs., PhD, MD, etc.</li>
351 <li><strong>BYOK support</strong> — Use your own OpenRouter API key via header</li>
352 <li><strong>Batch mode</strong> — Process hundreds of names via the standard Actor run</li>
353 </ul>
354 </section>
355
356 <section class="section">
357 <div class="section-label">Getting Started</div>
358 <h2>Quick start</h2>
359 <p>Replace <code>YOUR_TOKEN</code> with your Apify API token.</p>
360 <p style="margin-top:16px"><strong>Casual style (default):</strong></p>
361 <pre>curl "https://superlativetech--superclean-person-names.apify.actor?token=YOUR_TOKEN&input=DR.+JOHN+SMITH+III"</pre>
362 <p><strong>First name only:</strong></p>
363 <pre>curl "https://superlativetech--superclean-person-names.apify.actor?token=YOUR_TOKEN&input=DR.+JOHN+SMITH+III&style=first"</pre>
364 <p><strong>With your own OpenRouter key:</strong></p>
365 <pre>curl -H "X-OpenRouter-Key: sk-or-..." "https://superlativetech--superclean-person-names.apify.actor?token=YOUR_TOKEN&input=mcdonald,+sarah+jane"</pre>
366 </section>
367
368 <section class="section">
369 <div class="section-label">Reference</div>
370 <h2>Query parameters</h2>
371 <table>
372 <tr><th>Parameter</th><th>Required</th><th>Description</th></tr>
373 <tr><td><code>input</code></td><td>Yes</td><td>Person name to clean</td></tr>
374 <tr><td><code>style</code></td><td>No</td><td>Output style: <code>first</code>, <code>casual</code> (default), or <code>formal</code></td></tr>
375 <tr><td><code>model</code></td><td>No</td><td>LLM model (default: <code>openrouter/auto</code>)</td></tr>
376 <tr><td><code>token</code></td><td>Yes</td><td>Your Apify API token</td></tr>
377 </table>
378 <h2 style="margin-top:24px">Headers</h2>
379 <table>
380 <tr><th>Header</th><th>Required</th><th>Description</th></tr>
381 <tr><td><code>X-OpenRouter-Key</code></td><td>No</td><td>Your OpenRouter API key for BYOK</td></tr>
382 </table>
383 </section>
384
385 <section class="section">
386 <div class="section-label">Response</div>
387 <h2>Response format</h2>
388 <pre>{
389 "id": 1,
390 "input": "DR. JOHN SMITH III",
391 "output": "John Smith",
392 "confidence": 0.95
393}</pre>
394 </section>
395
396 <section class="section">
397 <div class="section-label">Cost</div>
398 <h2>Pricing</h2>
399 <p>Pay-per-event pricing on the Apify platform:</p>
400 <table>
401 <tr><th>Tier</th><th>Names</th><th>Per 1,000</th></tr>
402 <tr><td>Base</td><td>Any</td><td>$1.00</td></tr>
403 <tr><td><span class="badge">Bronze</span></td><td>100+</td><td>$0.90</td></tr>
404 <tr><td><span class="badge">Silver</span></td><td>1,000+</td><td>$0.80</td></tr>
405 <tr><td><span class="badge">Gold</span></td><td>10,000+</td><td>$0.70</td></tr>
406 </table>
407 </section>
408
409 <section class="section">
410 <div class="section-label">Auth</div>
411 <h2>Authentication</h2>
412 <p>Authenticate using either method:</p>
413 <ul>
414 <li>Query parameter: <code>?token=YOUR_APIFY_TOKEN</code></li>
415 <li>Header: <code>Authorization: Bearer YOUR_APIFY_TOKEN</code></li>
416 </ul>
417 <p style="margin-top:8px">Get your token from <a href="https://console.apify.com/settings/integrations">Apify Console → Settings → Integrations</a>.</p>
418 </section>
419
420 <section class="section">
421 <div class="section-label">Superlative</div>
422 <h2>More from Superlative</h2>
423 <div class="product-grid">
424 <a href="https://apify.com/superlativetech/superclean-company-names?fpr=8e9l1" class="product-card"><div class="product-name">Company Names <span class="status">Live</span></div><p class="product-desc">Normalize company names for CRM and cold email outreach.</p></a>
425 <a href="https://apify.com/superlativetech/superclean-job-titles?fpr=8e9l1" class="product-card"><div class="product-name">Job Titles <span class="status">Live</span></div><p class="product-desc">Standardize job titles for outreach sequences.</p></a>
426 <a href="https://apify.com/superlativetech/superclean-product-names?fpr=8e9l1" class="product-card"><div class="product-name">Product Names <span class="status">Live</span></div><p class="product-desc">Normalize product and brand names from exports.</p></a>
427 <a href="https://apify.com/superlativetech/superclean-places?fpr=8e9l1" class="product-card"><div class="product-name">Places <span class="status">Live</span></div><p class="product-desc">Parse and standardize location strings.</p></a>
428 <a href="https://apify.com/superlativetech/superclean-urls?fpr=8e9l1" class="product-card"><div class="product-name">URLs <span class="status">Live</span></div><p class="product-desc">Clean and normalize URLs from lead data.</p></a>
429 <a href="https://apify.com/superlativetech/superclean-phone-numbers?fpr=8e9l1" class="product-card"><div class="product-name">Phone Numbers <span class="status">Live</span></div><p class="product-desc">Format and validate phone numbers.</p></a>
430 <a href="https://apify.com/superlativetech/dns-lookup?fpr=8e9l1" class="product-card"><div class="product-name">Supernet DNS Lookup <span class="status">Live</span></div><p class="product-desc">Look up DNS records for any domain.</p></a>
431 <a href="https://apify.com/superlativetech/http-api?fpr=8e9l1" class="product-card"><div class="product-name">HTTP API <span class="status">Live</span></div><p class="product-desc">General-purpose HTTP request utility.</p></a>
432 </div>
433 </section>
434
435</div>
436</body>
437</html>`;
438}