1import http from 'node:http';
2import { Actor, log } from 'apify';
3import { deduplicateRecords, comparePair, autoDetectFields } from './deduper.js';
4
5
6
7function normalizeRecords(records) {
8 return records.flatMap((item) => {
9 if (Array.isArray(item)) {
10 log.warning(`Flattening nested array of ${item.length} records`);
11 return item.filter((r) => r && typeof r === 'object' && !Array.isArray(r));
12 }
13 if (item && typeof item === 'object' && 'input' in item && typeof item.input === 'object') {
14 return item.input;
15 }
16 if (item && typeof item === 'object' && !Array.isArray(item)) return item;
17 return [];
18 }).filter(Boolean);
19}
20
21
22
23function startStandbyServer() {
24 const port = Actor.config.get('containerPort');
25
26 const server = http.createServer(async (req, res) => {
27
28 if (req.headers['x-apify-container-server-readiness-probe']) {
29 res.writeHead(200, { 'Content-Type': 'application/json' });
30 res.end(JSON.stringify({ status: 'ready' }));
31 return;
32 }
33
34 if (req.method !== 'GET') {
35 res.writeHead(405, { 'Content-Type': 'application/json' });
36 res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
37 return;
38 }
39
40 const reqUrl = new URL(req.url, `http://${req.headers.host}`);
41 const paramA = reqUrl.searchParams.get('a');
42 const paramB = reqUrl.searchParams.get('b');
43
44
45 if (!paramA && !paramB) {
46 res.writeHead(200, { 'Content-Type': 'text/html' });
47 res.end(getLandingPageHtml());
48 return;
49 }
50
51
52 if (!paramA || !paramB) {
53 res.writeHead(400, { 'Content-Type': 'application/json' });
54 res.end(JSON.stringify({ error: 'Both "a" and "b" query parameters required for pair comparison.' }));
55 return;
56 }
57
58 try {
59 const recordA = JSON.parse(paramA);
60 const recordB = JSON.parse(paramB);
61 const threshold = parseFloat(reqUrl.searchParams.get('threshold') || '0.85');
62
63 const matchFields = autoDetectFields([recordA, recordB]);
64 if (!matchFields.length) {
65 res.writeHead(400, { 'Content-Type': 'application/json' });
66 res.end(JSON.stringify({ error: 'No matchable fields detected. Records must have fields like email, name, company, phone.' }));
67 return;
68 }
69
70 const result = comparePair(recordA, recordB, matchFields);
71 const response = {
72 isDuplicate: result.score >= threshold,
73 score: result.confidence,
74 reasons: result.reasons,
75 confidence: result.confidence,
76 fieldsUsed: matchFields.map((f) => f.field),
77 };
78
79
80 await Actor.pushData(response);
81
82 res.writeHead(200, { 'Content-Type': 'application/json' });
83 res.end(JSON.stringify(response));
84 } catch (err) {
85 const statusCode = err instanceof SyntaxError ? 400 : 500;
86 const message = err instanceof SyntaxError
87 ? 'Invalid JSON in "a" or "b" parameter.'
88 : err.message;
89 res.writeHead(statusCode, { 'Content-Type': 'application/json' });
90 res.end(JSON.stringify({ error: message }));
91 }
92 });
93
94 server.listen(port, () => {
95 log.info(`Superclean Dedupe Standby server listening on port ${port}`);
96 });
97}
98
99
100
101async function runBatchMode() {
102 const input = await Actor.getInput();
103 let { records: rawRecords, record, matchFields, threshold } = input || {};
104
105
106 if (record && typeof record === 'object' && !Array.isArray(record)) {
107 if (!Array.isArray(rawRecords)) rawRecords = [];
108 rawRecords = [record, ...rawRecords];
109 }
110
111 if (!Array.isArray(rawRecords) || rawRecords.length === 0) {
112 await Actor.pushData({ error: 'missing_records', message: 'Input required: provide "records" (array of objects) or "record" (single object). Example: {"records": [{"name": "John", "email": "john@example.com"}]}' });
113 await Actor.exit('Skipped: Input required: provide "records" (array of objects) or "record" (single object). Example: {"records": [{"name": "John", "email": "john@example.com"}]}');
114 return;
115 }
116
117 const records = normalizeRecords(rawRecords);
118
119 log.info('Dedupe input:', {
120 recordCount: records.length,
121 matchFields: matchFields || 'auto-detect',
122 threshold: threshold ?? 0.85,
123 });
124
125 if (records.length < 2) {
126 log.info('Only 1 record provided — nothing to deduplicate.');
127 const result = {
128 id: 1,
129 record: records[0],
130 clusterId: 'c_1',
131 clusterSize: 1,
132 isCanonical: true,
133 duplicateOf: null,
134 matchScore: null,
135 matchReasons: [],
136 confidence: 1,
137 };
138 await Actor.pushData(result);
139 return;
140 }
141
142
143 const results = deduplicateRecords(records, {
144 matchFields,
145 threshold: threshold ?? 0.85,
146 });
147
148
149 const clusters = new Set(results.map((r) => r.clusterId));
150 const duplicates = results.filter((r) => !r.isCanonical).length;
151
152 log.info(`Dedup complete: ${records.length} records → ${clusters.size} unique (${duplicates} duplicates found)`);
153
154 await Actor.pushData(results);
155 log.info(`Pushed ${results.length} results to dataset`);
156
157}
158
159
160
161await Actor.init();
162
163if (Actor.config.get('metaOrigin') === 'STANDBY') {
164 startStandbyServer();
165} else {
166 await runBatchMode();
167 await Actor.exit();
168}
169
170
171
172function getLandingPageHtml() {
173 return `<!DOCTYPE html>
174<html lang="en">
175<head>
176<meta charset="UTF-8">
177<meta name="viewport" content="width=device-width, initial-scale=1.0">
178<title>Superclean Dedupe — API</title>
179<style>
180: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}
181*{margin:0;padding:0;box-sizing:border-box}
182body{font-family:var(--font-body);background:var(--color-bg);color:var(--color-text);line-height:1.6;min-height:100vh}
183.container{max-width:720px;margin:0 auto;padding:48px 24px}
184.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}
185h1{font-family:var(--font-display);font-size:28px;font-weight:700;margin-bottom:12px;letter-spacing:-0.02em}
186h1 .hl{color:var(--color-accent)}
187.subtitle{color:var(--color-text-muted);font-size:16px;max-width:520px;margin-bottom:32px;line-height:1.7}
188.link{color:var(--color-accent);text-decoration:none}
189.link:hover{text-decoration:underline}
190h2{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)}
191p{color:var(--color-text-muted);margin-bottom:16px;font-size:14px;line-height:1.7}
192pre{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)}
193code{font-family:var(--font-display);font-size:12px}
194.comment{color:var(--color-text-muted)}
195table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
196th{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)}
197td{padding:8px 12px;border-bottom:1px solid var(--color-border);color:var(--color-text-muted)}
198td:first-child{color:var(--color-text);font-family:var(--font-display);font-size:12px}
199.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:16px}
200.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}
201.card:hover{border-color:var(--color-accent)}
202.card-name{font-family:var(--font-display);font-size:12px;font-weight:700;margin-bottom:6px}
203.card-desc{font-size:12px;color:var(--color-text-muted);line-height:1.5}
204footer{margin-top:64px;padding-top:24px;border-top:1px solid var(--color-border);font-size:12px;color:var(--color-text-muted)}
205</style>
206<link rel="preconnect" href="https://fonts.googleapis.com">
207<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
208</head>
209<body>
210<div class="container">
211<div class="logo">Superlative</div>
212<h1>Superclean <span class="hl">Dedupe</span></h1>
213<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>
214
215<h2>Pair Comparison</h2>
216<p>Compare two records to check if they're duplicates:</p>
217<pre><span class="comment"># Are these the same person?</span>
218curl "https://superlativetech--superclean-dedupe.apify.actor?token=TOKEN\\
219&a={\\"name\\":\\"Jim Smith\\",\\"email\\":\\"jim@acme.com\\"}\\
220&b={\\"name\\":\\"James Smith\\",\\"email\\":\\"jim@acme.com\\"}"
221
222<span class="comment"># Response</span>
223{
224 "isDuplicate": true,
225 "score": 0.92,
226 "reasons": ["email_exact", "name_jaro-winkler:0.87"],
227 "confidence": 0.92,
228 "fieldsUsed": ["email", "name"]
229}</pre>
230
231<h2>Query Parameters</h2>
232<table>
233<tr><th>Parameter</th><th>Required</th><th>Description</th></tr>
234<tr><td>a</td><td>Yes</td><td>First record as JSON object</td></tr>
235<tr><td>b</td><td>Yes</td><td>Second record as JSON object</td></tr>
236<tr><td>threshold</td><td>No</td><td>Similarity threshold (default 0.85)</td></tr>
237</table>
238
239<h2>Supported Fields</h2>
240<p>Fields are auto-detected from your records:</p>
241<table>
242<tr><th>Field</th><th>Method</th><th>Weight</th></tr>
243<tr><td>email</td><td>Exact (normalized)</td><td>1.0</td></tr>
244<tr><td>phone</td><td>Exact (digits)</td><td>0.9</td></tr>
245<tr><td>name / fullName</td><td>Jaro-Winkler</td><td>0.8</td></tr>
246<tr><td>company</td><td>Token Set Ratio</td><td>0.7</td></tr>
247<tr><td>domain / website</td><td>Exact (extracted)</td><td>0.7</td></tr>
248<tr><td>title / jobTitle</td><td>Token Set Ratio</td><td>0.3</td></tr>
249</table>
250
251<h2>Batch Dedup</h2>
252<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>
253
254<h2>Pricing</h2>
255<p>Free to use — you only pay standard Apify platform usage.</p>
256
257<h2>More from Superlative</h2>
258<div class="cards">
259<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>
260<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>
261<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>
262<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>
263<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>
264<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>
265</div>
266
267<footer>© 2026 Superlative · <a class="link" href="https://superlative.tech">superlative.tech</a></footer>
268</div>
269</body>
270</html>`;
271}