1import http from 'node:http';
2import { Actor, log } from 'apify';
3import { deduplicateRecords, comparePair, autoDetectFields } from './deduper.js';
4
5const PPE_EVENT = 'record-processed';
6
7
8
9function normalizeRecords(records) {
10 return records.flatMap((item) => {
11 if (Array.isArray(item)) {
12 log.warning(`Flattening nested array of ${item.length} records`);
13 return item.filter((r) => r && typeof r === 'object' && !Array.isArray(r));
14 }
15 if (item && typeof item === 'object' && 'input' in item && typeof item.input === 'object') {
16 return item.input;
17 }
18 if (item && typeof item === 'object' && !Array.isArray(item)) return item;
19 return [];
20 }).filter(Boolean);
21}
22
23
24
25function startStandbyServer() {
26 const port = Actor.config.get('containerPort');
27
28 const server = http.createServer(async (req, res) => {
29
30 if (req.headers['x-apify-container-server-readiness-probe']) {
31 res.writeHead(200, { 'Content-Type': 'application/json' });
32 res.end(JSON.stringify({ status: 'ready' }));
33 return;
34 }
35
36 if (req.method !== 'GET') {
37 res.writeHead(405, { 'Content-Type': 'application/json' });
38 res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
39 return;
40 }
41
42 const reqUrl = new URL(req.url, `http://${req.headers.host}`);
43 const paramA = reqUrl.searchParams.get('a');
44 const paramB = reqUrl.searchParams.get('b');
45
46
47 if (!paramA && !paramB) {
48 res.writeHead(200, { 'Content-Type': 'text/html' });
49 res.end(getLandingPageHtml());
50 return;
51 }
52
53
54 if (!paramA || !paramB) {
55 res.writeHead(400, { 'Content-Type': 'application/json' });
56 res.end(JSON.stringify({ error: 'Both "a" and "b" query parameters required for pair comparison.' }));
57 return;
58 }
59
60 try {
61 const recordA = JSON.parse(paramA);
62 const recordB = JSON.parse(paramB);
63 const threshold = parseFloat(reqUrl.searchParams.get('threshold') || '0.85');
64
65 const matchFields = autoDetectFields([recordA, recordB]);
66 if (!matchFields.length) {
67 res.writeHead(400, { 'Content-Type': 'application/json' });
68 res.end(JSON.stringify({ error: 'No matchable fields detected. Records must have fields like email, name, company, phone.' }));
69 return;
70 }
71
72 const result = comparePair(recordA, recordB, matchFields);
73 const response = {
74 isDuplicate: result.score >= threshold,
75 score: result.confidence,
76 reasons: result.reasons,
77 confidence: result.confidence,
78 fieldsUsed: matchFields.map((f) => f.field),
79 };
80
81
82 await Actor.pushData(response);
83
84 try {
85 await Actor.charge({ eventName: PPE_EVENT, count: 1 });
86 } catch (chargeErr) {
87 console.warn(`PPE charge failed: ${chargeErr.message}`);
88 }
89
90 res.writeHead(200, { 'Content-Type': 'application/json' });
91 res.end(JSON.stringify(response));
92 } catch (err) {
93 const statusCode = err instanceof SyntaxError ? 400 : 500;
94 const message = err instanceof SyntaxError
95 ? 'Invalid JSON in "a" or "b" parameter.'
96 : err.message;
97 res.writeHead(statusCode, { 'Content-Type': 'application/json' });
98 res.end(JSON.stringify({ error: message }));
99 }
100 });
101
102 server.listen(port, () => {
103 log.info(`Superclean Dedupe Standby server listening on port ${port}`);
104 });
105}
106
107
108
109async function runBatchMode() {
110 const input = await Actor.getInput();
111 let { records: rawRecords, record, matchFields, threshold } = input || {};
112
113
114 if (record && typeof record === 'object' && !Array.isArray(record)) {
115 if (!Array.isArray(rawRecords)) rawRecords = [];
116 rawRecords = [record, ...rawRecords];
117 }
118
119 if (!Array.isArray(rawRecords) || rawRecords.length === 0) {
120 await Actor.fail('Input required: provide "records" (array of objects) or "record" (single object). Example: {"records": [{"name": "John", "email": "john@example.com"}]}');
121 return;
122 }
123
124 const records = normalizeRecords(rawRecords);
125
126 log.info('Dedupe input:', {
127 recordCount: records.length,
128 matchFields: matchFields || 'auto-detect',
129 threshold: threshold ?? 0.85,
130 });
131
132 if (records.length < 2) {
133 log.info('Only 1 record provided — nothing to deduplicate.');
134 const result = {
135 id: 1,
136 record: records[0],
137 clusterId: 'c_1',
138 clusterSize: 1,
139 isCanonical: true,
140 duplicateOf: null,
141 matchScore: null,
142 matchReasons: [],
143 confidence: 1,
144 };
145 await Actor.pushData(result);
146 return;
147 }
148
149
150 const results = deduplicateRecords(records, {
151 matchFields,
152 threshold: threshold ?? 0.85,
153 });
154
155
156 const clusters = new Set(results.map((r) => r.clusterId));
157 const duplicates = results.filter((r) => !r.isCanonical).length;
158
159 log.info(`Dedup complete: ${records.length} records → ${clusters.size} unique (${duplicates} duplicates found)`);
160
161
162 let chargedCount = 0;
163 let chargeLimitReached = false;
164
165 for (const result of results) {
166 if (!chargeLimitReached) {
167 try {
168 const chargeResult = await Actor.charge({ eventName: PPE_EVENT, count: 1 });
169 chargedCount++;
170 if (chargeResult?.eventChargeLimitReached) {
171 chargeLimitReached = true;
172 log.info(`User spending limit reached after charging ${chargedCount}/${results.length} items`);
173 }
174 } catch (chargeErr) {
175 log.warning(`PPE charge failed: ${chargeErr.message}`);
176 }
177 }
178 }
179
180 await Actor.pushData(results);
181 log.info(`Pushed ${results.length} results to dataset`, { charged: chargedCount });
182
183 if (chargeLimitReached) {
184 log.info('Note: spending limit was reached but all records were still processed and pushed.');
185 }
186}
187
188
189
190await Actor.init();
191
192if (Actor.config.get('metaOrigin') === 'STANDBY') {
193 startStandbyServer();
194} else {
195 await runBatchMode();
196 await Actor.exit();
197}
198
199
200
201function getLandingPageHtml() {
202 return `<!DOCTYPE html>
203<html lang="en">
204<head>
205<meta charset="UTF-8">
206<meta name="viewport" content="width=device-width, initial-scale=1.0">
207<title>Superclean Dedupe — API</title>
208<style>
209:root{--color-bg:#0a0e14;--color-surface:#12171f;--color-border:#1e2632;--color-text:#e8eaed;--color-text-muted:#8b95a5;--color-accent:#8b5cf6;--color-accent-dim:rgba(139,92,246,0.15);--font-display:'Space Mono',monospace;--font-body:'DM Sans',sans-serif}
210*{margin:0;padding:0;box-sizing:border-box}
211body{font-family:var(--font-body);background:var(--color-bg);color:var(--color-text);line-height:1.6;min-height:100vh}
212.container{max-width:720px;margin:0 auto;padding:48px 24px}
213.logo{font-family:var(--font-display);font-size:13px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:48px}
214h1{font-family:var(--font-display);font-size:28px;font-weight:700;margin-bottom:12px;letter-spacing:-0.02em}
215h1 .hl{color:var(--color-accent)}
216.subtitle{color:var(--color-text-muted);font-size:16px;max-width:520px;margin-bottom:32px;line-height:1.7}
217.link{color:var(--color-accent);text-decoration:none}
218.link:hover{text-decoration:underline}
219h2{font-family:var(--font-display);font-size:16px;font-weight:700;margin:48px 0 16px;padding-top:24px;border-top:1px solid var(--color-border)}
220p{color:var(--color-text-muted);margin-bottom:16px;font-size:14px;line-height:1.7}
221pre{background:var(--color-surface);border:1px solid var(--color-border);padding:16px;overflow-x:auto;font-family:var(--font-display);font-size:12px;line-height:1.6;margin:12px 0;color:var(--color-text)}
222code{font-family:var(--font-display);font-size:12px}
223.comment{color:var(--color-text-muted)}
224table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
225th{font-family:var(--font-display);font-size:10px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--color-text-muted);text-align:left;padding:10px 12px;border-bottom:1px solid var(--color-border)}
226td{padding:8px 12px;border-bottom:1px solid var(--color-border);color:var(--color-text-muted)}
227td:first-child{color:var(--color-text);font-family:var(--font-display);font-size:12px}
228.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:16px}
229.card{background:var(--color-surface);border:1px solid var(--color-border);padding:16px;text-decoration:none;color:inherit;display:block;transition:border-color 0.2s}
230.card:hover{border-color:var(--color-accent)}
231.card-name{font-family:var(--font-display);font-size:12px;font-weight:700;margin-bottom:6px}
232.card-desc{font-size:12px;color:var(--color-text-muted);line-height:1.5}
233footer{margin-top:64px;padding-top:24px;border-top:1px solid var(--color-border);font-size:12px;color:var(--color-text-muted)}
234</style>
235<link rel="preconnect" href="https://fonts.googleapis.com">
236<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
237</head>
238<body>
239<div class="container">
240<div class="logo">Superlative</div>
241<h1>Superclean <span class="hl">Dedupe</span></h1>
242<p class="subtitle">Deduplicate lead records using fuzzy matching. Compare two records instantly via this API, or run batch dedup on thousands of records via <a class="link" href="https://apify.com/superlativetech/superclean-dedupe?fpr=8e9l1">the Apify actor</a>.</p>
243
244<h2>Pair Comparison</h2>
245<p>Compare two records to check if they're duplicates:</p>
246<pre><span class="comment"># Are these the same person?</span>
247curl "https://superlativetech--superclean-dedupe.apify.actor?token=TOKEN\\
248&a={\\"name\\":\\"Jim Smith\\",\\"email\\":\\"jim@acme.com\\"}\\
249&b={\\"name\\":\\"James Smith\\",\\"email\\":\\"jim@acme.com\\"}"
250
251<span class="comment"># Response</span>
252{
253 "isDuplicate": true,
254 "score": 0.92,
255 "reasons": ["email_exact", "name_jaro-winkler:0.87"],
256 "confidence": 0.92,
257 "fieldsUsed": ["email", "name"]
258}</pre>
259
260<h2>Query Parameters</h2>
261<table>
262<tr><th>Parameter</th><th>Required</th><th>Description</th></tr>
263<tr><td>a</td><td>Yes</td><td>First record as JSON object</td></tr>
264<tr><td>b</td><td>Yes</td><td>Second record as JSON object</td></tr>
265<tr><td>threshold</td><td>No</td><td>Similarity threshold (default 0.85)</td></tr>
266</table>
267
268<h2>Supported Fields</h2>
269<p>Fields are auto-detected from your records:</p>
270<table>
271<tr><th>Field</th><th>Method</th><th>Weight</th></tr>
272<tr><td>email</td><td>Exact (normalized)</td><td>1.0</td></tr>
273<tr><td>phone</td><td>Exact (digits)</td><td>0.9</td></tr>
274<tr><td>name / fullName</td><td>Jaro-Winkler</td><td>0.8</td></tr>
275<tr><td>company</td><td>Token Set Ratio</td><td>0.7</td></tr>
276<tr><td>domain / website</td><td>Exact (extracted)</td><td>0.7</td></tr>
277<tr><td>title / jobTitle</td><td>Token Set Ratio</td><td>0.3</td></tr>
278</table>
279
280<h2>Batch Dedup</h2>
281<p>For deduplicating hundreds or thousands of records, use the <a class="link" href="https://apify.com/superlativetech/superclean-dedupe?fpr=8e9l1">full actor</a> with batch input.</p>
282
283<h2>Pricing</h2>
284<table>
285<tr><th>Records</th><th>Cost</th></tr>
286<tr><td>1,000</td><td>$0.50</td></tr>
287<tr><td>10,000</td><td>$5.00</td></tr>
288<tr><td>100,000</td><td>$50.00</td></tr>
289</table>
290<p>Volume discounts: Bronze (100+) $0.45/1K, Silver (1K+) $0.40/1K, Gold (10K+) $0.35/1K</p>
291
292<h2>More from Superlative</h2>
293<div class="cards">
294<a class="card" href="https://apify.com/superlativetech/superclean-company-names?fpr=8e9l1"><div class="card-name">Company Names</div><div class="card-desc">Normalize company names from CRM exports</div></a>
295<a class="card" href="https://apify.com/superlativetech/superclean-emails?fpr=8e9l1"><div class="card-name">Emails</div><div class="card-desc">Validate emails, fix typos, check MX records</div></a>
296<a class="card" href="https://apify.com/superlativetech/superclean-person-names?fpr=8e9l1"><div class="card-name">Person Names</div><div class="card-desc">Clean person names for cold email</div></a>
297<a class="card" href="https://apify.com/superlativetech/superlead-icp-scorer?fpr=8e9l1"><div class="card-name">ICP Scorer</div><div class="card-desc">Score leads against your Ideal Customer Profile</div></a>
298<a class="card" href="https://apify.com/superlativetech/supernet-domain-health?fpr=8e9l1"><div class="card-name">Domain Health</div><div class="card-desc">Audit email sending domains (SPF, DKIM, DMARC)</div></a>
299<a class="card" href="https://apify.com/superlativetech/superclean-phone-numbers?fpr=8e9l1"><div class="card-name">Phone Numbers</div><div class="card-desc">Format and validate phone numbers</div></a>
300</div>
301
302<footer>© 2026 Superlative · <a class="link" href="https://superlative.tech">superlative.tech</a></footer>
303</div>
304</body>
305</html>`;
306}