1import http from 'node:http';
2import { Actor } from 'apify';
3import { cleanPhoneNumber, cleanPhoneNumbers } from './cleaner.js';
4
5
6
7
8
9
10function normalizeItems(items) {
11 return items.map((item) => {
12 if (typeof item === 'string') {
13 return item;
14 }
15 if (item && typeof item === 'object' && 'input' in item) {
16 return String(item.input ?? '');
17 }
18 return String(item ?? '');
19 });
20}
21
22await Actor.init();
23
24if (Actor.config.get('metaOrigin') === 'STANDBY') {
25 startStandbyServer();
26} else {
27 await runBatchMode();
28 await Actor.exit();
29}
30
31
32
33
34
35function startStandbyServer() {
36 const port = Actor.config.get('containerPort');
37
38 const server = http.createServer(async (req, res) => {
39
40 if (req.headers['x-apify-container-server-readiness-probe']) {
41 res.writeHead(200, { 'Content-Type': 'application/json' });
42 res.end(JSON.stringify({ status: 'ready' }));
43 return;
44 }
45
46
47 if (req.method !== 'GET') {
48 res.writeHead(405, { 'Content-Type': 'application/json' });
49 res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
50 return;
51 }
52
53
54 const reqUrl = new URL(req.url, `http://${req.headers.host}`);
55 if (!reqUrl.searchParams.get('input')) {
56 res.writeHead(200, { 'Content-Type': 'text/html' });
57 res.end(getLandingPageHtml());
58 return;
59 }
60
61 try {
62 const result = handleStandbyRequest(reqUrl);
63 await Actor.pushData(result);
64
65 res.writeHead(200, { 'Content-Type': 'application/json' });
66 res.end(JSON.stringify(result));
67 } catch (err) {
68 const statusCode = err.statusCode || 500;
69 res.writeHead(statusCode, { 'Content-Type': 'application/json' });
70 res.end(JSON.stringify({ error: err.message }));
71 }
72 });
73
74 server.listen(port, () => {
75 console.log(`Superclean Phone Numbers Standby server listening on port ${port}`);
76 });
77}
78
79function handleStandbyRequest(reqUrl) {
80 const input = reqUrl.searchParams.get('input');
81
82 if (!input) {
83 const err = new Error("Provide an 'input' query parameter with the phone number to clean.");
84 err.statusCode = 400;
85 throw err;
86 }
87
88 const defaultCountry = (reqUrl.searchParams.get('defaultCountry') || 'US').toUpperCase();
89 const outputFormat = reqUrl.searchParams.get('outputFormat') || 'e164';
90
91
92 if (defaultCountry.length !== 2) {
93 const err = new Error('defaultCountry must be a 2-letter ISO country code (e.g., US, GB, DE)');
94 err.statusCode = 400;
95 throw err;
96 }
97
98
99 const validFormats = ['e164', 'international', 'national'];
100 if (!validFormats.includes(outputFormat)) {
101 const err = new Error(`Invalid outputFormat "${outputFormat}". Must be one of: ${validFormats.join(', ')}`);
102 err.statusCode = 400;
103 throw err;
104 }
105
106 const cleaned = cleanPhoneNumber(input, { defaultCountry, outputFormat });
107
108 return {
109 id: 1,
110 input: cleaned.input,
111 output: cleaned.output,
112 e164: cleaned.e164,
113 isValid: cleaned.isValid,
114 type: cleaned.type,
115 countryCode: cleaned.countryCode,
116 extension: cleaned.extension,
117 };
118}
119
120
121
122
123
124async function runBatchMode() {
125 const input = await Actor.getInput();
126
127 let {
128 items: rawItems = [],
129 item,
130 defaultCountry = 'US',
131 outputFormat = 'e164'
132 } = input || {};
133
134
135 if (typeof item === 'string' && item.trim()) {
136 if (!Array.isArray(rawItems)) rawItems = [];
137 rawItems = [item.trim(), ...rawItems];
138 }
139
140
141 if (typeof rawItems === 'string') rawItems = [rawItems];
142
143
144 if (!Array.isArray(rawItems) || rawItems.length === 0) {
145 await Actor.pushData({ error: 'missing_items', message: 'Input required: provide "items" (array) or "item" (single string). Example: {"items": ["+1 (555) 123-4567"]}' });
146 await Actor.exit('Skipped: Input required: provide "items" (array) or "item" (single string). Example: {"items": ["+1 (555) 123-4567"]}');
147 return;
148 }
149
150
151 const items = normalizeItems(rawItems);
152
153
154 if (typeof defaultCountry !== 'string' || defaultCountry.length !== 2) {
155 await Actor.pushData({ error: 'invalid_country', message: 'defaultCountry must be a 2-letter ISO country code (e.g., US, GB, DE)' });
156 await Actor.exit('Skipped: defaultCountry must be a 2-letter ISO country code (e.g., US, GB, DE)');
157 return;
158 }
159
160
161 const validFormats = ['e164', 'international', 'national'];
162 if (!validFormats.includes(outputFormat)) {
163 const message = `Invalid outputFormat "${outputFormat}". Must be one of: ${validFormats.join(', ')}`;
164 await Actor.pushData({ error: 'invalid_format', message });
165 await Actor.exit(`Skipped: ${message}`);
166 return;
167 }
168
169 console.log(`Processing ${items.length} phone number(s)`);
170 console.log(`Options: defaultCountry=${defaultCountry}, outputFormat=${outputFormat}`);
171
172
173 const results = cleanPhoneNumbers(items, {
174 defaultCountry: defaultCountry.toUpperCase(),
175 outputFormat
176 });
177
178
179
180 await Actor.pushData(results);
181
182
183 const validCount = results.filter(r => r.isValid).length;
184 const typeBreakdown = results.reduce((acc, r) => {
185 if (r.type) {
186 acc[r.type] = (acc[r.type] || 0) + 1;
187 }
188 return acc;
189 }, {});
190
191 console.log(`Processed ${results.length} phone numbers`);
192 console.log(`Valid: ${validCount}, Invalid: ${results.length - validCount}`);
193 if (Object.keys(typeBreakdown).length > 0) {
194 console.log(`Types: ${JSON.stringify(typeBreakdown)}`);
195 }
196}
197
198
199
200
201
202function getLandingPageHtml() {
203 return `<!DOCTYPE html>
204<html lang="en">
205<head>
206<meta charset="utf-8">
207<meta name="viewport" content="width=device-width, initial-scale=1">
208<title>Superclean Phone Numbers — Superlative</title>
209<link rel="preconnect" href="https://fonts.googleapis.com">
210<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
211<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
212<style>
213: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}
214*{margin:0;padding:0;box-sizing:border-box}
215body{font-family:var(--font-body);background:var(--color-bg);color:var(--color-text);line-height:1.6;min-height:100vh;overflow-x:hidden}
216a{color:var(--color-accent);text-decoration:none;transition:color .2s ease}
217a:hover{color:#6ee7a0}
218.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}
219.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}
220@keyframes shimmer{0%,100%{transform:translateX(-100%)}50%{transform:translateX(100%)}}
221@keyframes fadeUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
222.container{position:relative;z-index:1;max-width:900px;margin:0 auto;padding:0 24px}
223header{padding:32px 0}
224.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}
225.logo:hover{color:var(--color-text)}
226.logo-mark{width:28px;height:28px;background:var(--color-accent);display:flex;align-items:center;justify-content:center}
227.logo-mark svg{width:16px;height:16px}
228.hero{padding:80px 0 60px;animation:fadeUp .8s ease-out}
229.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}
230.hero-label::before{content:'';width:24px;height:1px;background:var(--color-accent)}
231h1{font-family:var(--font-display);font-size:clamp(28px,5vw,44px);font-weight:700;line-height:1.15;margin-bottom:20px;letter-spacing:-.02em}
232h1 .highlight{color:var(--color-accent)}
233.hero-desc{font-size:18px;color:var(--color-text-muted);max-width:540px;margin-bottom:28px;line-height:1.7}
234.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}
235.section{padding:60px 0;border-top:1px solid var(--color-border);animation:fadeUp .8s ease-out .2s backwards}
236.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}
237h2{font-family:var(--font-display);font-size:20px;font-weight:700;margin-bottom:16px;letter-spacing:-.01em}
238p,li{font-size:15px;color:var(--color-text-muted);line-height:1.7}
239li{margin-bottom:6px}
240ul{margin:.5rem 0 1rem 1.25rem}
241strong{color:var(--color-text);font-weight:600}
242pre{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)}
243code{font-family:var(--font-display);font-size:13px}
244p code,li code,td code{background:var(--color-accent-dim);color:var(--color-accent);padding:2px 6px}
245table{width:100%;border-collapse:collapse;margin:12px 0 16px;font-size:14px}
246th,td{border:1px solid var(--color-border);padding:10px 14px;text-align:left}
247th{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}
248td{color:var(--color-text-muted)}
249.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}
250.note{font-size:13px;color:var(--color-text-muted);margin-top:8px}
251.product-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;margin-top:24px}
252.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}
253.product-card:hover{border-color:var(--color-accent);transform:translateY(-2px)}
254.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}
255.product-name .status{font-size:9px;font-weight:400;padding:3px 6px;background:var(--color-accent-dim);color:var(--color-accent);letter-spacing:.1em}
256.product-desc{font-size:14px;color:var(--color-text-muted);line-height:1.5}
257footer{padding:48px 0;border-top:1px solid var(--color-border)}
258.footer-content{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:24px}
259.footer-text{font-size:13px;color:var(--color-text-muted)}
260.footer-links{display:flex;gap:24px}
261.footer-links a{font-family:var(--font-display);font-size:12px;color:var(--color-text-muted);letter-spacing:.05em}
262.footer-links a:hover{color:var(--color-accent)}
263@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}}
264</style>
265</head>
266<body>
267<div class="grid-bg"></div>
268<div class="accent-line"></div>
269
270<div class="container">
271 <header>
272 <a href="https://apify.com/superlativetech?fpr=8e9l1" class="logo">
273 <div class="logo-mark">
274 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
275 <polyline points="4 17 10 11 4 5"></polyline>
276 <line x1="12" y1="19" x2="20" y2="19"></line>
277 </svg>
278 </div>
279 Superlative
280 </a>
281 </header>
282
283 <section class="hero">
284 <div class="hero-label">Superclean</div>
285 <h1>Superclean Phone <span class="highlight">Numbers</span></h1>
286 <p class="hero-desc">Instant phone number cleaning and validation. Format to E.164, detect type, handle vanity numbers. Sub-second responses via Standby API.</p>
287 <a href="https://apify.com/superlativetech/superclean-phone-numbers?fpr=8e9l1" class="hero-link">View on Apify Store →</a>
288 </section>
289
290 <section class="section">
291 <div class="section-label">Features</div>
292 <h2>What this does</h2>
293 <ul>
294 <li><strong>Formats numbers</strong> — E.164, international, or national output formats</li>
295 <li><strong>Validates numbers</strong> — Check if phone numbers are valid with type detection</li>
296 <li><strong>Handles vanity</strong> — Convert 1-800-FLOWERS to digits automatically</li>
297 <li><strong>Detects type</strong> — Mobile, landline, toll-free, VOIP classification</li>
298 <li><strong>Batch mode</strong> — Process hundreds of numbers via the standard Actor run</li>
299 </ul>
300 </section>
301
302 <section class="section">
303 <div class="section-label">Getting Started</div>
304 <h2>Quick start</h2>
305 <p>Replace <code>YOUR_TOKEN</code> with your Apify API token.</p>
306
307 <p style="margin-top:16px"><strong>Clean a phone number (E.164):</strong></p>
308 <pre>curl "https://superlativetech--superclean-phone-numbers.apify.actor?token=YOUR_TOKEN&input=(555)+123-4567"</pre>
309
310 <p><strong>International format:</strong></p>
311 <pre>curl "https://superlativetech--superclean-phone-numbers.apify.actor?token=YOUR_TOKEN&input=(555)+123-4567&outputFormat=international"</pre>
312
313 <p><strong>Non-US number:</strong></p>
314 <pre>curl "https://superlativetech--superclean-phone-numbers.apify.actor?token=YOUR_TOKEN&input=020+7946+0958&defaultCountry=GB"</pre>
315 </section>
316
317 <section class="section">
318 <div class="section-label">Reference</div>
319 <h2>Query parameters</h2>
320 <table>
321 <tr><th>Parameter</th><th>Required</th><th>Description</th></tr>
322 <tr><td><code>input</code></td><td>Yes</td><td>Phone number to clean</td></tr>
323 <tr><td><code>defaultCountry</code></td><td>No</td><td>2-letter ISO country code (default: <code>US</code>)</td></tr>
324 <tr><td><code>outputFormat</code></td><td>No</td><td>Format: <code>e164</code> (default), <code>international</code>, or <code>national</code></td></tr>
325 <tr><td><code>token</code></td><td>Yes</td><td>Your Apify API token</td></tr>
326 </table>
327 </section>
328
329 <section class="section">
330 <div class="section-label">Response</div>
331 <h2>Response format</h2>
332 <pre>{
333 "id": 1,
334 "input": "(555) 123-4567",
335 "output": "+15551234567",
336 "e164": "+15551234567",
337 "isValid": true,
338 "type": "fixed_line_or_mobile",
339 "countryCode": "US",
340 "extension": null
341}</pre>
342 </section>
343
344 <section class="section">
345 <div class="section-label">Cost</div>
346 <h2>Pricing</h2>
347 <p>Free to use — you only pay standard Apify platform usage.</p>
348 </section>
349
350 <section class="section">
351 <div class="section-label">Auth</div>
352 <h2>Authentication</h2>
353 <p>Authenticate using either method:</p>
354 <ul>
355 <li>Query parameter: <code>?token=YOUR_APIFY_TOKEN</code></li>
356 <li>Header: <code>Authorization: Bearer YOUR_APIFY_TOKEN</code></li>
357 </ul>
358 <p style="margin-top:8px">Get your token from <a href="https://console.apify.com/settings/integrations">Apify Console → Settings → Integrations</a>.</p>
359 </section>
360
361 <section class="section">
362 <div class="section-label">Superlative</div>
363 <h2>More from Superlative</h2>
364 <div class="product-grid">
365 <a href="https://apify.com/superlativetech/superclean-company-names?fpr=8e9l1" class="product-card">
366 <div class="product-name">Company Names <span class="status">Live</span></div>
367 <p class="product-desc">Normalize company names for CRM and cold email outreach.</p>
368 </a>
369 <a href="https://apify.com/superlativetech/superclean-job-titles?fpr=8e9l1" class="product-card">
370 <div class="product-name">Job Titles <span class="status">Live</span></div>
371 <p class="product-desc">Standardize job titles for outreach sequences.</p>
372 </a>
373 <a href="https://apify.com/superlativetech/superclean-person-names?fpr=8e9l1" class="product-card">
374 <div class="product-name">Person Names <span class="status">Live</span></div>
375 <p class="product-desc">Clean and format person names for personalization.</p>
376 </a>
377 <a href="https://apify.com/superlativetech/superclean-product-names?fpr=8e9l1" class="product-card">
378 <div class="product-name">Product Names <span class="status">Live</span></div>
379 <p class="product-desc">Normalize product and brand names from exports.</p>
380 </a>
381 <a href="https://apify.com/superlativetech/superclean-places?fpr=8e9l1" class="product-card">
382 <div class="product-name">Places <span class="status">Live</span></div>
383 <p class="product-desc">Parse and standardize location strings.</p>
384 </a>
385 <a href="https://apify.com/superlativetech/superclean-urls?fpr=8e9l1" class="product-card">
386 <div class="product-name">URLs <span class="status">Live</span></div>
387 <p class="product-desc">Clean and normalize URLs from lead data.</p>
388 </a>
389 <a href="https://apify.com/superlativetech/dns-lookup?fpr=8e9l1" class="product-card">
390 <div class="product-name">Supernet DNS Lookup <span class="status">Live</span></div>
391 <p class="product-desc">Look up DNS records for any domain.</p>
392 </a>
393 <a href="https://apify.com/superlativetech/http-api?fpr=8e9l1" class="product-card">
394 <div class="product-name">HTTP API <span class="status">Live</span></div>
395 <p class="product-desc">General-purpose HTTP request utility.</p>
396 </a>
397 </div>
398 </section>
399
400 <footer>
401 <div class="footer-content">
402 <p class="footer-text">© 2026 Superlative</p>
403 <div class="footer-links">
404 <a href="https://apify.com/superlativetech?fpr=8e9l1">Apify Store</a>
405 </div>
406 </div>
407 </footer>
408</div>
409
410</body>
411</html>`;
412}