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