AutomationFlowsWeb Scraping › CRM List Hygiene & Duplicate Merge Planner

CRM List Hygiene & Duplicate Merge Planner

CRM List Hygiene & Duplicate Merge Planner. Uses httpRequest. Event-driven trigger; 24 nodes.

Event trigger★★★★☆ complexity24 nodesHTTP Request
Web Scraping Trigger: Event Nodes: 24 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "CRM List Hygiene & Duplicate Merge Planner",
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all",
    "saveManualExecutions": true
  },
  "nodes": [
    {
      "name": "Run Audit Now",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -1180,
        260
      ],
      "parameters": {},
      "id": "242dc348-b0d6-4daa-8dd5-fc10d69013bc"
    },
    {
      "name": "Weekly Audit",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1180,
        440
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 6,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "7a5b4bd8-8cc9-4cd0-8213-bd82f24aa404"
    },
    {
      "name": "Init Audit Run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -960,
        350
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Init Audit Run\"  (Code node, Run Once for All Items)\n * ----------------------------------------------------------\n * Sets up one audit pass. Everything tunable lives here so the rest of the\n * workflow has no configuration in it.\n *\n * DRY_RUN is on by default and stays on until someone deliberately turns it off.\n * A list-cleaning tool that merges 400 contacts on its first run, in a client's\n * production CRM, is not a tool \u2014 it is an incident. This one produces a plan\n * first and merges only when told to.\n */\n\nconst CONFIG = {\n  // --- safety ------------------------------------------------------------\n  dryRun: true,              // produce the plan; write nothing\n  mockMode: true,            // use the built-in fixture contacts, no credentials\n\n  // --- source ------------------------------------------------------------\n  locationId: 'REPLACE_WITH_LOCATION_ID',\n  pageSize: 10,              // raise to 100 against a real API\n  maxPages: 50,              // hard ceiling \u2014 a paginator with no ceiling is a bug\n  maxContacts: 25000,        // memory guard, see README for the >25k approach\n\n  // --- matching ----------------------------------------------------------\n  // A pair is a duplicate if any rule below fires. Rules are ordered by how much\n  // you should trust them; the weakest one is deliberately conservative.\n  nameSimilarityThreshold: 0.90,   // Jaro-Winkler, 0-1\n  requireSecondSignal: true,       // a fuzzy name match alone is never enough\n\n  // --- deliverability ----------------------------------------------------\n  defaultCountry: 'US',\n  staleAfterDays: 730,             // no engagement in 2 years\n};\n\nfunction processAll(items) {\n  const startedAt = new Date().toISOString();\n\n  return [{\n    json: {\n      config: CONFIG,\n      audit: {\n        runId: 'audit_' + startedAt.slice(0, 10).replace(/-/g, '') + '_'\n          + Math.random().toString(16).slice(2, 8),\n        startedAt,\n        page: 0,\n        cursor: null,\n        hasMore: true,\n        contacts: [],\n        pagesFetched: 0,\n        partial: false,\n        partialReason: null,\n      },\n      // Seeds the shared retry classifier.\n      retry: { attempt: 0, maxAttempts: 5, waitSeconds: 0, history: [] },\n    },\n    pairedItem: items.length ? 0 : undefined,\n  }];\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { CONFIG, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all());\n}"
      },
      "id": "08932967-05b5-4e8b-8226-bdfa8900e70d"
    },
    {
      "name": "Build Page Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -740,
        350
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Build Page Request\"  (Code node, Run Once for All Items)\n * --------------------------------------------------------------\n * The head of the pagination loop. Three nodes feed into it \u2014 the initialiser,\n * the accumulator (next page), and the backoff wait (retry the same page) \u2014 and\n * only one of them hands over a complete state object.\n *\n * That is the whole reason this node exists. When an HTTP node fails, what comes\n * out of its error output is the error, not the item that went in, so anything\n * downstream of a retry has to be able to rebuild the loop state from somewhere\n * else. This node does that in one place, and the HTTP node reads its cursor from\n * here rather than from whatever happened to be on the item.\n */\n\n/** Pull the freshest loop state from wherever it survived. */\nfunction resolveState(item, fallbacks) {\n  if (item && item.json && item.json.audit && item.json.config) return item.json;\n  for (const fallback of fallbacks) {\n    if (fallback && fallback.audit && fallback.config) return fallback;\n  }\n  return null;\n}\n\nfunction buildRequest(state) {\n  const { config, audit } = state;\n\n  return {\n    config,\n    audit,\n    retry: state.retry || { attempt: 0, maxAttempts: 5, waitSeconds: 0, history: [] },\n    request: {\n      locationId: config.locationId,\n      limit: config.pageSize,\n      // Cursor-based, not offset-based. Offset pagination over a table that is\n      // being written to while you read it silently skips and repeats records;\n      // for a dedupe job that is the one failure mode you cannot tolerate.\n      cursor: audit.cursor || '',\n      page: audit.page + 1,\n    },\n    useLive: config.mockMode !== true,\n  };\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { resolveState, buildRequest };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  const fallbacks = [];\n  for (const nodeName of ['Accumulate Page', 'Init Audit Run']) {\n    try {\n      const upstream = $(nodeName).all();\n      if (upstream && upstream.length) fallbacks.push(upstream[upstream.length - 1].json);\n    } catch (error) {\n      // That node has not run on this path yet. Expected on the first pass.\n    }\n  }\n\n  const state = resolveState($input.all()[0], fallbacks);\n  if (!state) {\n    throw new Error('Build Page Request could not resolve the audit state. '\n      + 'Check that \"Init Audit Run\" ran before this node.');\n  }\n  return [{ json: buildRequest(state) }];\n}"
      },
      "id": "2be14fa8-35cc-4fa9-97ae-0848e35e1185"
    },
    {
      "name": "Use Live CRM?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -520,
        350
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "use-live",
              "leftValue": "={{ $json.useLive }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "b4dccc94-313d-448b-be00-61b1afc194cd"
    },
    {
      "name": "CRM: Fetch Contact Page",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -280,
        220
      ],
      "parameters": {
        "method": "GET",
        "url": "https://services.leadconnectorhq.com/contacts/",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "specifyQuery": "keypair",
        "queryParameters": {
          "parameters": [
            {
              "name": "locationId",
              "value": "={{ $('Build Page Request').item.json.request.locationId }}"
            },
            {
              "name": "limit",
              "value": "={{ $('Build Page Request').item.json.request.limit }}"
            },
            {
              "name": "startAfterId",
              "value": "={{ $('Build Page Request').item.json.request.cursor }}"
            }
          ]
        },
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-07-28"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "36baeac7-13c0-4aaa-9d02-e1286cef3ae5"
    },
    {
      "name": "Mock Contact Page",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -280,
        480
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Mock Contact Page\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------\n * Stands in for the CRM's paginated contact endpoint so the whole audit runs\n * with no credentials and no client account. Same response shape as the live\n * call, same cursor semantics, so nothing downstream can tell the difference.\n *\n * The fixture list is 24 contacts from a CRM that has been running for three\n * years: five real duplicate clusters, two near-misses that must NOT be merged,\n * dead email addresses, un-textable numbers, a DND record, and a contact with no\n * reachable channel at all. If a dedupe tool gets this list right it will get a\n * real one right.\n */\n\nconst MOCK_CONTACTS = [\n  // --- cluster A: same person, three records, three spellings of the email ---\n  { id: 'ct_1001', firstName: 'Robert', lastName: 'Chen', email: 'rob.chen@northsidehvac.example.com',\n    phone: '+14155550143', companyName: 'Northside HVAC', city: 'San Francisco', postalCode: '94110',\n    country: 'US', tags: ['source:website_form'], dnd: false, emailStatus: 'valid',\n    createdAt: '2023-02-11T10:00:00Z', lastActivityAt: '2026-07-30T14:22:00Z' },\n  { id: 'ct_1002', firstName: 'Bob', lastName: 'Chen', email: 'r.chen@northsidehvac.example.com',\n    phone: '415-555-0143', companyName: 'Northside HVAC Inc', city: 'San Francisco', postalCode: '94110',\n    country: 'US', tags: ['trade-show-2024'], dnd: false, emailStatus: 'valid',\n    createdAt: '2024-05-02T09:30:00Z', lastActivityAt: '2025-11-02T09:00:00Z' },\n  { id: 'ct_1003', firstName: 'Robert', lastName: 'Chen', email: '',\n    phone: '(415) 555 0143', companyName: '', city: '', postalCode: '',\n    country: 'US', tags: [], dnd: false, emailStatus: '',\n    createdAt: '2025-01-19T16:45:00Z', lastActivityAt: null },\n\n  // --- cluster B: one Gmail inbox, two \"different\" addresses -----------------\n  { id: 'ct_1004', firstName: 'Sarah', lastName: 'Okonkwo', email: 'sarah.okonkwo@gmail.com',\n    phone: '', companyName: '', city: 'Manchester', postalCode: 'M1 4BT',\n    country: 'GB', tags: ['source:facebook_lead_ads'], dnd: false, emailStatus: 'valid',\n    createdAt: '2024-08-01T11:00:00Z', lastActivityAt: '2026-06-14T08:10:00Z' },\n  { id: 'ct_1005', firstName: 'Sarah', lastName: 'Okonkwo', email: 's.arah.okonkwo+forms@googlemail.com',\n    phone: '+447700900461', companyName: '', city: 'Manchester', postalCode: 'M1 4BT',\n    country: 'GB', tags: ['newsletter'], dnd: false, emailStatus: 'valid',\n    createdAt: '2025-03-22T13:05:00Z', lastActivityAt: '2026-08-01T19:40:00Z' },\n\n  // --- cluster C: same number, written two ways -----------------------------\n  { id: 'ct_1006', firstName: 'Aminath', lastName: 'Rasheed', email: 'aminath.r@example.com',\n    phone: '+9607712345', companyName: '', city: 'Mal\u00e9', postalCode: '20026',\n    country: 'MV', tags: ['existing-client'], dnd: false, emailStatus: 'valid',\n    createdAt: '2023-09-14T07:00:00Z', lastActivityAt: '2026-08-05T06:30:00Z' },\n  { id: 'ct_1007', firstName: 'Aminath', lastName: '', email: '',\n    phone: '00960 771 2345', companyName: 'Rasheed Trading', city: '', postalCode: '',\n    country: '', tags: [], dnd: false, emailStatus: '',\n    createdAt: '2024-01-08T05:20:00Z', lastActivityAt: null },\n\n  // --- cluster D: nickname + full name, one mobile --------------------------\n  { id: 'ct_1008', firstName: 'Mike', lastName: 'Johnson', email: 'mike.johnson@example.com',\n    phone: '+14155550188', companyName: '', city: 'Oakland', postalCode: '94607',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2023-11-30T18:00:00Z', lastActivityAt: '2025-02-11T12:00:00Z' },\n  { id: 'ct_1009', firstName: 'Michael', lastName: 'Johnson', email: 'mjohnson@example.com',\n    phone: '4155550188', companyName: 'Johnson Plumbing', city: 'Oakland', postalCode: '94607',\n    country: 'US', tags: ['quote-requested'], dnd: false, emailStatus: 'valid',\n    createdAt: '2025-06-04T10:15:00Z', lastActivityAt: '2026-07-19T15:45:00Z' },\n\n  // --- cluster E: typo'd surname, identical email ---------------------------\n  { id: 'ct_1010', firstName: 'Jennifer', lastName: 'Nakamura', email: 'jen.nakamura@brightpath.example.com',\n    phone: '+61412345678', companyName: 'Brightpath', city: 'Sydney', postalCode: '2000',\n    country: 'AU', tags: ['vip'], dnd: false, emailStatus: 'valid',\n    createdAt: '2023-04-17T08:00:00Z', lastActivityAt: '2026-08-09T02:00:00Z' },\n  { id: 'ct_1011', firstName: 'Jennifer', lastName: 'Nakamurra', email: 'jen.nakamura@brightpath.example.com',\n    phone: '', companyName: '', city: '', postalCode: '',\n    country: 'AU', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2025-10-01T00:00:00Z', lastActivityAt: null },\n\n  // --- near-misses: similar people who are NOT the same person --------------\n  { id: 'ct_1012', firstName: 'David', lastName: 'Smith', email: 'dsmith@acmedental.example.com',\n    phone: '+14155550201', companyName: 'Acme Dental', city: 'San Jose', postalCode: '95112',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2024-02-20T09:00:00Z', lastActivityAt: '2026-05-30T11:00:00Z' },\n  { id: 'ct_1013', firstName: 'David', lastName: 'Smyth', email: 'dsmyth@acmedental.example.com',\n    phone: '+14155550202', companyName: 'Acme Dental', city: 'San Jose', postalCode: '95112',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2024-02-20T09:05:00Z', lastActivityAt: '2026-05-30T11:05:00Z' },\n  // Shared family address, two real people.\n  { id: 'ct_1014', firstName: 'Anna', lastName: 'Petrova', email: 'petrov.family@example.net',\n    phone: '+14155550210', companyName: '', city: 'Daly City', postalCode: '94014',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2024-07-07T07:07:00Z', lastActivityAt: '2026-03-01T10:00:00Z' },\n  { id: 'ct_1015', firstName: 'Igor', lastName: 'Petrov', email: 'petrov.family@example.net',\n    phone: '+14155550211', companyName: '', city: 'Daly City', postalCode: '94014',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2024-07-07T07:09:00Z', lastActivityAt: '2026-03-01T10:02:00Z' },\n\n  // --- deliverability problems ---------------------------------------------\n  { id: 'ct_1016', firstName: 'Info', lastName: '', email: 'info@westsideclinic.example.com',\n    phone: '+14155550222', companyName: 'Westside Clinic', city: 'San Francisco', postalCode: '94115',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2023-06-01T12:00:00Z', lastActivityAt: '2026-01-15T09:00:00Z' },\n  { id: 'ct_1017', firstName: 'Test', lastName: 'Test', email: 'test@mailinator.com',\n    phone: '5555555555', companyName: '', city: '', postalCode: '',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2025-04-04T04:04:00Z', lastActivityAt: null },\n  { id: 'ct_1018', firstName: 'Karen', lastName: 'Whitfield', email: 'karen.whitfield@gmial.com',\n    phone: '+14155550233', companyName: '', city: 'Sacramento', postalCode: '95814',\n    country: 'US', tags: [], dnd: false, emailStatus: 'bounced',\n    createdAt: '2023-01-05T08:00:00Z', lastActivityAt: '2023-03-01T08:00:00Z' },\n  { id: 'ct_1019', firstName: 'Daniel', lastName: 'Okafor', email: 'daniel.okafor@example.com',\n    phone: '+2348012345678', companyName: '', city: 'Lagos', postalCode: '',\n    country: '', tags: ['unsubscribed'], dnd: true, emailStatus: 'unsubscribed',\n    createdAt: '2024-09-09T09:09:00Z', lastActivityAt: '2024-10-01T09:00:00Z' },\n  { id: 'ct_1020', firstName: '', lastName: '', email: 'not-an-email',\n    phone: '123', companyName: '', city: '', postalCode: '',\n    country: 'US', tags: [], dnd: false, emailStatus: '',\n    createdAt: '2025-12-25T00:00:00Z', lastActivityAt: null },\n\n  // --- clean singles --------------------------------------------------------\n  { id: 'ct_1021', firstName: 'Priya', lastName: 'Venkatesan', email: 'priya.v@brightpathdental.example.com',\n    phone: '+919845012345', companyName: 'Brightpath Dental', city: 'Bengaluru', postalCode: '560001',\n    country: 'IN', tags: ['referral'], dnd: false, emailStatus: 'valid',\n    createdAt: '2025-08-14T16:40:00Z', lastActivityAt: '2026-08-10T12:00:00Z' },\n  { id: 'ct_1022', firstName: 'Lucas', lastName: 'van der Berg', email: 'lucas@vanderberg-bouw.example.com',\n    phone: '', companyName: 'Van der Berg Bouw', city: 'Utrecht', postalCode: '3511',\n    country: '', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2026-08-14T00:00:00Z', lastActivityAt: '2026-08-14T00:00:00Z' },\n  { id: 'ct_1023', firstName: 'Dana', lastName: 'Reyes', email: '',\n    phone: '+14155550177', companyName: '', city: '', postalCode: '',\n    country: 'US', tags: ['referral'], dnd: false, emailStatus: '',\n    createdAt: '2026-08-14T15:15:00Z', lastActivityAt: '2026-08-14T15:15:00Z' },\n  // Nobody has touched this record in three years.\n  { id: 'ct_1024', firstName: 'Harold', lastName: 'Beckett', email: 'h.beckett@example.org',\n    phone: '+14155550244', companyName: '', city: 'Fresno', postalCode: '93701',\n    country: 'US', tags: [], dnd: false, emailStatus: 'valid',\n    createdAt: '2022-05-05T05:05:00Z', lastActivityAt: '2023-01-20T05:05:00Z' },\n];\n\n/** Mimics a cursor-paginated endpoint. The cursor is opaque on purpose \u2014 the\n *  loop must not be able to cheat by doing arithmetic on it. */\nfunction fetchPage(cursor, limit) {\n  const offset = cursor ? Number(Buffer.from(String(cursor), 'base64').toString('utf8')) || 0 : 0;\n  const size = Math.max(1, Number(limit) || 10);\n  const slice = MOCK_CONTACTS.slice(offset, offset + size);\n  const nextOffset = offset + slice.length;\n  const hasMore = nextOffset < MOCK_CONTACTS.length;\n\n  return {\n    contacts: slice,\n    meta: {\n      total: MOCK_CONTACTS.length,\n      returned: slice.length,\n      nextCursor: hasMore ? Buffer.from(String(nextOffset), 'utf8').toString('base64') : null,\n      source: 'MOCK_MODE',\n    },\n  };\n}\n\nfunction processAll(items) {\n  return items.map((item, index) => {\n    const request = (item.json && item.json.request) || {};\n    return {\n      json: { ...item.json, response: fetchPage(request.cursor, request.limit) },\n      pairedItem: index,\n    };\n  });\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { MOCK_CONTACTS, fetchPage, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all());\n}"
      },
      "id": "aecf03a6-5adb-45d0-853e-57b708745903"
    },
    {
      "name": "Classify Fetch Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -40,
        20
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Classify API Error\"  (Code node, Run Once for All Items)\n * --------------------------------------------------------------\n * Wired to the non-2xx branch of the paginated fetch. Identical to the file in demo 01 apart\n * from this header \u2014 the same three-way decision applies to any API in any workflow, which\n * is exactly why it lives in one reviewable file instead of being retyped.\n * Turns a raw failure into a decision: retry after N seconds, alert a human, or\n * give up and queue the page for manual handling \u2014 never \"fail silently\" and\n * never \"retry forever\".\n *\n * Why this is a Code node and not the HTTP node's built-in retry:\n *\n *   n8n's node-level \"Retry On Fail\" is ignored when \"On Error\" is set to either\n *   Continue option \u2014 the node continues down the error branch on the first\n *   failure instead of retrying (n8n issue #10763). So if you want retries *and*\n *   a graceful error path, you have to own the retry loop yourself. This node\n *   plus a Wait node is that loop.\n *\n * It also does the thing most retry code skips: it reads Retry-After. A 429 that\n * says \"wait 30 seconds\" and gets retried in 2 seconds is not a retry, it is a\n * second offence, and providers extend the block for it.\n */\n\nconst RETRY = {\n  maxAttempts: 5,\n  baseDelaySeconds: 2,     // 2, 4, 8, 16, 32 before jitter\n  maxDelaySeconds: 120,\n  jitterRatio: 0.3,        // \u00b130%, so parallel executions do not retry in lockstep\n};\n\n// Transport-level failures worth retrying \u2014 the request never reached the app.\nconst RETRYABLE_NETWORK_CODES = [\n  'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',\n  'EPIPE', 'ESOCKETTIMEDOUT', 'ERR_SOCKET_CONNECTION_TIMEOUT',\n];\n\nconst RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504, 522, 524];\n\n/**\n * n8n wraps HTTP failures differently depending on node version, whether the\n * response was JSON, and whether the failure was transport or application level.\n * Dig the useful bits out of all of them rather than assuming one shape.\n */\nfunction extractError(json) {\n  // Preferred shape: the HTTP node is configured Full Response + Never Error, so\n  // a 429 arrives as an ordinary item with statusCode, headers and body intact.\n  // That is the only configuration in which Retry-After is readable at all \u2014 on\n  // the error output, n8n hands you the error, not the response envelope.\n  const fullResponse = typeof json.statusCode === 'number' && json.headers !== undefined;\n  const container = fullResponse ? json : (json.error || json);\n\n  const status = Number(\n    container.statusCode ?? container.status ?? container.httpCode ??\n    (container.response && (container.response.status ?? container.response.statusCode)) ??\n    (container.context && container.context.statusCode) ??\n    (typeof container.code === 'number' ? container.code : undefined) ?? 0\n  ) || 0;\n\n  const headers =\n    container.headers ||\n    (container.response && container.response.headers) ||\n    (container.context && container.context.headers) || {};\n\n  const body =\n    (fullResponse ? container.body : undefined) ??\n    (container.response && (container.response.body ?? container.response.data)) ??\n    container.body ?? container.data ?? null;\n\n  const networkCode = typeof container.code === 'string' ? container.code\n    : typeof container.errno === 'string' ? container.errno : '';\n\n  const message = String(\n    container.message || container.description ||\n    (body && (body.message || body.error || body.msg)) ||\n    'Unknown error'\n  ).slice(0, 500);\n\n  return { status, headers, body, networkCode, message };\n}\n\n/** Header lookup that does not care about casing, because proxies do not either. */\nfunction header(headers, name) {\n  if (!headers) return undefined;\n  const target = name.toLowerCase();\n  for (const [key, value] of Object.entries(headers)) {\n    if (String(key).toLowerCase() === target) return value;\n  }\n  return undefined;\n}\n\n/**\n * Retry-After is legal in two forms: delay-seconds (\"120\") and an HTTP-date\n * (\"Wed, 21 Oct 2026 07:28:00 GMT\"). Both appear in the wild. Also handles the\n * x-ratelimit-reset variants, which some APIs give as a unix timestamp.\n */\nfunction retryAfterSeconds(headers, nowMs) {\n  const raw = header(headers, 'retry-after');\n  if (raw !== undefined && raw !== null && String(raw).trim() !== '') {\n    const text = String(raw).trim();\n    if (/^\\d+$/.test(text)) return Number(text);\n    const date = new Date(text);\n    if (!isNaN(date.getTime())) {\n      return Math.max(0, Math.ceil((date.getTime() - nowMs) / 1000));\n    }\n  }\n\n  const reset = header(headers, 'x-ratelimit-reset') ?? header(headers, 'ratelimit-reset');\n  if (reset !== undefined && /^\\d+$/.test(String(reset))) {\n    const value = Number(reset);\n    // Ten digits or more is an absolute unix timestamp; anything less is a delta.\n    const seconds = String(value).length >= 10\n      ? Math.ceil((value * 1000 - nowMs) / 1000)\n      : value;\n    if (seconds > 0) return seconds;\n  }\n\n  return null;\n}\n\nfunction backoffSeconds(attempt) {\n  const exponential = RETRY.baseDelaySeconds * Math.pow(2, Math.max(0, attempt - 1));\n  const capped = Math.min(exponential, RETRY.maxDelaySeconds);\n  const jitter = capped * RETRY.jitterRatio * (Math.random() * 2 - 1);\n  return Math.max(1, Math.round(capped + jitter));\n}\n\n/**\n * Returns a decision object. `disposition` is the only field the IF nodes read:\n *   'retry'      -> Wait node, then back to the HTTP node\n *   'alert'      -> notify a human immediately; the whole integration is down\n *   'quarantine' -> this one lead is bad; park it, keep processing the rest\n */\nfunction classify(json, nowMs, runIndex) {\n  const state = json.retry || { attempt: 0, maxAttempts: RETRY.maxAttempts, history: [] };\n\n  // Attempt counting has to survive the item losing its state: when an HTTP node\n  // fails, what arrives on the error output is the error, not necessarily the\n  // item that went in. n8n's $runIndex counts how many times this node has run\n  // inside the current loop, so it is the reliable floor for \"which attempt is\n  // this\". Without it, a retry loop that loses state retries forever.\n  const floor = Number.isFinite(Number(runIndex)) ? Number(runIndex) : 0;\n  const attempt = Math.max(Number(state.attempt || 0), floor) + 1;\n  const maxAttempts = Number(state.maxAttempts || RETRY.maxAttempts);\n\n  const error = extractError(json);\n  const { status, networkCode } = error;\n\n  let category;\n  let disposition;\n  let operatorMessage;\n  let retryable = false;\n\n  if (status === 401 || status === 403) {\n    category = 'auth';\n    disposition = 'alert';\n    operatorMessage = status === 401\n      ? 'CRM rejected the credentials (401). The token has expired or been revoked \u2014 reconnect the credential in n8n. Nothing will sync until this is fixed.'\n      : 'CRM accepted the credentials but refused the action (403). The connected user is missing a scope or the location ID is wrong.';\n  } else if (status === 429) {\n    category = 'rate_limit';\n    retryable = true;\n    operatorMessage = 'Rate limited by the CRM. Backing off and retrying.';\n  } else if (status === 422 || status === 400) {\n    category = 'validation';\n    disposition = 'quarantine';\n    operatorMessage = 'The CRM rejected this payload (' + status + '). This is a data problem with one lead, not an outage \u2014 parked for review. Detail: ' + error.message;\n  } else if (status === 404) {\n    category = 'not_found';\n    disposition = 'quarantine';\n    operatorMessage = 'Endpoint or record not found (404). Check the URL and the location/subaccount ID.';\n  } else if (status === 409) {\n    category = 'conflict';\n    disposition = 'quarantine';\n    operatorMessage = 'Conflict (409) \u2014 the CRM believes this contact already exists. Re-run the search rather than retrying the write.';\n  } else if (RETRYABLE_STATUSES.includes(status)) {\n    category = 'server';\n    retryable = true;\n    operatorMessage = 'CRM returned ' + status + '. Transient server-side failure, retrying.';\n  } else if (RETRYABLE_NETWORK_CODES.includes(networkCode)) {\n    category = 'network';\n    retryable = true;\n    operatorMessage = 'Network failure (' + networkCode + ') before the CRM answered. Retrying.';\n  } else if (status >= 400 && status < 500) {\n    category = 'client';\n    disposition = 'quarantine';\n    operatorMessage = 'CRM returned ' + status + '. Not retryable \u2014 the request itself is wrong.';\n  } else {\n    category = 'unknown';\n    retryable = true;\n    operatorMessage = 'Unclassified failure: ' + error.message + '. Treating as transient.';\n  }\n\n  let waitSeconds = 0;\n  if (retryable) {\n    if (attempt >= maxAttempts) {\n      disposition = 'quarantine';\n      operatorMessage = 'Gave up after ' + maxAttempts + ' attempts. Last failure: ' + operatorMessage;\n    } else {\n      disposition = 'retry';\n      // Honour the server's own instruction when it gives one; it knows better\n      // than our exponential curve does.\n      const serverHint = retryAfterSeconds(error.headers, nowMs);\n      waitSeconds = serverHint !== null\n        ? Math.min(serverHint, RETRY.maxDelaySeconds)\n        : backoffSeconds(attempt);\n      if (serverHint !== null) {\n        operatorMessage += ' Server asked for ' + serverHint + 's, honouring it.';\n      }\n    }\n  }\n\n  const history = Array.isArray(state.history) ? state.history.slice(-9) : [];\n  history.push({\n    attempt,\n    at: new Date(nowMs).toISOString(),\n    status,\n    category,\n    waitSeconds,\n  });\n\n  return {\n    ...json,\n    retry: { attempt, maxAttempts, waitSeconds, history },\n    failure: {\n      category,\n      disposition,\n      status,\n      networkCode,\n      message: error.message,\n      operatorMessage,\n      // Body is capped: a 500 from a CRM sometimes returns an entire HTML error\n      // page, and stuffing that into every execution log is how you fill a disk.\n      responseSnippet: error.body\n        ? String(typeof error.body === 'string' ? error.body : JSON.stringify(error.body)).slice(0, 600)\n        : null,\n    },\n    shouldRetry: disposition === 'retry',\n    shouldAlert: disposition === 'alert',\n    isQuarantined: disposition === 'quarantine',\n  };\n}\n\nfunction processAll(items, runIndex) {\n  const nowMs = Date.now();\n  return items.map((item, index) => ({\n    json: classify(item.json || {}, nowMs, runIndex || 0),\n    pairedItem: index,\n  }));\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    RETRY, extractError, header, retryAfterSeconds, backoffSeconds, classify, processAll,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all(), typeof $runIndex !== 'undefined' ? $runIndex : 0);\n}"
      },
      "id": "231412fc-e490-40fd-894f-7a1b9ab9da37"
    },
    {
      "name": "Retry Fetch?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        180,
        20
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "should-retry",
              "leftValue": "={{ $json.shouldRetry }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "b39c773e-8141-4e41-94a0-a33b8d1044b6"
    },
    {
      "name": "Backoff Wait",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        180,
        -180
      ],
      "parameters": {
        "amount": "={{ $json.retry.waitSeconds }}",
        "unit": "seconds"
      },
      "id": "af21aea2-9060-4c62-ba86-7ac793e07d0d"
    },
    {
      "name": "Accumulate Page",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        200,
        350
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Accumulate Page\"  (Code node, Run Once for All Items)\n * -----------------------------------------------------------\n * Folds one fetched page into the running audit state and decides whether the\n * loop continues.\n *\n * Four stop conditions, all of them necessary:\n *   1. the API says there is no next cursor\n *   2. the page came back empty\n *   3. maxPages \u2014 a paginator with no ceiling will happily run until the\n *      execution times out, and a cursor bug turns that into an infinite loop\n *   4. maxContacts \u2014 this node holds every contact in memory so the clustering\n *      step can see the whole set at once. That is fine for tens of thousands\n *      and wrong for millions; see the README for the batched variant.\n *\n * It also detects a stalled cursor (the API returning the same cursor twice),\n * which is the specific way pagination loops usually fail in production.\n */\n\nfunction accumulate(state, response) {\n  const config = state.config;\n  const audit = state.audit;\n\n  const contacts = (response && (response.contacts || response.data || response.items)) || [];\n  const meta = (response && response.meta) || {};\n  const nextCursor = meta.nextCursor ?? meta.next_cursor ?? meta.cursor ?? null;\n\n  const merged = audit.contacts.concat(contacts);\n  const page = audit.page + 1;\n\n  let hasMore = Boolean(nextCursor) && contacts.length > 0;\n  let stopReason = null;\n\n  if (contacts.length === 0) {\n    hasMore = false;\n    stopReason = 'empty_page';\n  } else if (!nextCursor) {\n    hasMore = false;\n    stopReason = 'no_next_cursor';\n  } else if (nextCursor === audit.cursor) {\n    // The API handed back the cursor we just used. Continuing means fetching\n    // the same page forever.\n    hasMore = false;\n    stopReason = 'cursor_did_not_advance';\n  } else if (page >= config.maxPages) {\n    hasMore = false;\n    stopReason = 'max_pages_reached';\n  } else if (merged.length >= config.maxContacts) {\n    hasMore = false;\n    stopReason = 'max_contacts_reached';\n  }\n\n  const partial = Boolean(stopReason) &&\n    !['no_next_cursor', 'empty_page'].includes(stopReason);\n\n  return {\n    config,\n    retry: { attempt: 0, maxAttempts: 5, waitSeconds: 0, history: [] },\n    audit: {\n      ...audit,\n      page,\n      cursor: nextCursor,\n      hasMore,\n      contacts: merged,\n      pagesFetched: page,\n      partial: audit.partial || partial,\n      partialReason: audit.partialReason || (partial ? stopReason : null),\n      lastStopReason: stopReason,\n      total: meta.total ?? null,\n    },\n  };\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { accumulate };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  const item = $input.all()[0];\n  const json = item.json || {};\n\n  // Mock path: the item still carries config + audit + response.\n  // Live path: the HTTP node replaced the item with the raw API response, so\n  // the loop state comes back from the node that built the request.\n  const state = json.audit && json.config\n    ? json\n    : $('Build Page Request').all()[0].json;\n  // Mock: { response }. Live with Full Response on: { statusCode, headers, body }.\n  const response = json.response !== undefined ? json.response\n    : json.body !== undefined ? json.body : json;\n\n  return [{ json: accumulate(state, response) }];\n}"
      },
      "id": "93bad2b4-4670-4d0b-b002-2e56373ac9d7"
    },
    {
      "name": "More Pages?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        400,
        350
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "has-more",
              "leftValue": "={{ $json.audit.hasMore }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "a866b3e8-56cf-4679-bb67-7fe3e607fc2a"
    },
    {
      "name": "Cluster & Score Contacts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Cluster & Score Contacts\"  (Code node, Run Once for All Items)\n * --------------------------------------------------------------------\n * The whole audit happens here. Input is every contact the paginator collected;\n * output is one item containing a merge plan, a suppression list, per-contact\n * deliverability scores and a summary.\n *\n * Three things in here are worth reading properly:\n *\n * 1. BLOCKING. Comparing every contact to every other contact is O(n\u00b2) \u2014 on\n *    40,000 contacts that is 800 million comparisons and the execution dies.\n *    Instead each contact is filed under a few cheap keys (last 9 phone digits,\n *    normalised email, soundex of surname + postcode) and only contacts sharing\n *    a key are ever compared. That turns it into roughly O(n).\n *\n * 2. NEGATIVE EVIDENCE. Most dedupe tools only look for reasons to merge. The\n *    expensive mistakes come from ignoring reasons not to: two people sharing a\n *    family email address, a husband and wife at one company, \"David Smith\" and\n *    \"David Smyth\" who are two different dentists at the same practice. Every\n *    pair here is scored for conflict as well as similarity, and a conflicted\n *    pair is sent for review rather than merged. Merging two real customers is\n *    not recoverable; leaving a duplicate is.\n *\n * 3. NOTHING IS ASSERTED THAT HAS NOT BEEN CHECKED. This node does not claim an\n *    address is deliverable \u2014 it has not sent anything. It reports syntax,\n *    known-bad patterns, and the CRM's own bounce/unsubscribe flags, and marks\n *    everything else \"unverified\". A hygiene report that overstates what it\n *    knows is worse than no report.\n */\n\n// ---------------------------------------------------------------------------\n// Phone / email normalisation (kept local: an n8n Code node cannot import)\n// ---------------------------------------------------------------------------\nconst COUNTRY_RULES = {\n  US: { cc: '1', nsnLengths: [10], trunk: '1' }, CA: { cc: '1', nsnLengths: [10], trunk: '1' },\n  GB: { cc: '44', nsnLengths: [10, 9], trunk: '0' }, IE: { cc: '353', nsnLengths: [9, 8], trunk: '0' },\n  AU: { cc: '61', nsnLengths: [9], trunk: '0' }, NZ: { cc: '64', nsnLengths: [9, 8], trunk: '0' },\n  IN: { cc: '91', nsnLengths: [10], trunk: '0' }, AE: { cc: '971', nsnLengths: [9], trunk: '0' },\n  SG: { cc: '65', nsnLengths: [8], trunk: '' }, ZA: { cc: '27', nsnLengths: [9], trunk: '0' },\n  DE: { cc: '49', nsnLengths: [10, 11], trunk: '0' }, NG: { cc: '234', nsnLengths: [10], trunk: '0' },\n  NL: { cc: '31', nsnLengths: [9], trunk: '0' }, MV: { cc: '960', nsnLengths: [7], trunk: '' },\n};\n\nconst CC_LOOKUP = Object.entries(COUNTRY_RULES)\n  .sort((a, b) => b[1].cc.length - a[1].cc.length)\n  .map(([iso, rule]) => ({ iso, cc: rule.cc }));\n\nconst DISPOSABLE_DOMAINS = ['mailinator.com', 'guerrillamail.com', '10minutemail.com',\n  'tempmail.com', 'yopmail.com', 'trashmail.com', 'sharklasers.com', 'getnada.com'];\n\nconst ROLE_LOCAL_PARTS = ['info', 'admin', 'support', 'sales', 'contact', 'hello',\n  'office', 'billing', 'noreply', 'no-reply', 'help', 'team', 'enquiries'];\n\nconst TYPO_DOMAINS = { 'gmial.com': 'gmail.com', 'gmai.com': 'gmail.com', 'gmail.co': 'gmail.com',\n  'yaho.com': 'yahoo.com', 'hotmial.com': 'hotmail.com', 'outlok.com': 'outlook.com' };\n\nfunction normalizePhone(raw, defaultCountry) {\n  const text = String(raw ?? '').trim();\n  if (!text) return { e164: '', valid: false, reason: 'missing' };\n\n  const hadPlus = text.startsWith('+');\n  let digits = text.replace(/\\D/g, '');\n  if (!digits) return { e164: '', valid: false, reason: 'no_digits' };\n\n  let international = hadPlus;\n  if (!international && digits.startsWith('00')) { digits = digits.slice(2); international = true; }\n\n  let cc = '';\n  let nsn = '';\n  if (international) {\n    const match = CC_LOOKUP.find((entry) => digits.startsWith(entry.cc));\n    if (!match) return { e164: '+' + digits, valid: digits.length >= 8 && digits.length <= 15, reason: 'unknown_country' };\n    cc = match.cc;\n    nsn = digits.slice(cc.length);\n  } else {\n    const rule = COUNTRY_RULES[defaultCountry] || COUNTRY_RULES.US;\n    cc = rule.cc;\n    nsn = digits;\n    if (rule.trunk && nsn.startsWith(rule.trunk) && !rule.nsnLengths.includes(nsn.length)) {\n      const stripped = nsn.slice(rule.trunk.length);\n      if (rule.nsnLengths.includes(stripped.length)) nsn = stripped;\n    }\n  }\n\n  const iso = (CC_LOOKUP.find((entry) => entry.cc === cc) || {}).iso || defaultCountry;\n  const rule = COUNTRY_RULES[iso];\n  if (rule && rule.trunk && nsn.startsWith(rule.trunk) &&\n      !rule.nsnLengths.includes(nsn.length) &&\n      rule.nsnLengths.includes(nsn.length - rule.trunk.length)) {\n    nsn = nsn.slice(rule.trunk.length);\n  }\n\n  const lengthOk = rule ? rule.nsnLengths.includes(nsn.length) : (nsn.length >= 7 && nsn.length <= 12);\n  const placeholder = /^(\\d)\\1+$/.test(nsn) ||\n    ['1234567890', '0123456789', '5555555555'].includes(nsn);\n  const nanpBad = cc === '1' && nsn.length === 10 && (/^[01]/.test(nsn) || /^[01]/.test(nsn.slice(3)));\n\n  const valid = lengthOk && !placeholder && !nanpBad;\n  return {\n    e164: valid ? '+' + cc + nsn : '',\n    national: nsn,\n    country: iso,\n    valid,\n    reason: !lengthOk ? 'bad_length' : placeholder ? 'placeholder' : nanpBad ? 'invalid_nanp' : 'ok',\n  };\n}\n\nfunction normalizeEmail(raw) {\n  const text = String(raw ?? '').trim().toLowerCase().replace(/\\s+/g, '');\n  if (!text) return { value: '', key: '', valid: false, reason: 'missing', domain: '', flags: {} };\n\n  const parts = text.split('@');\n  if (parts.length !== 2 || !parts[0] || !parts[1]) {\n    return { value: text, key: '', valid: false, reason: 'unparseable', domain: '', flags: {} };\n  }\n\n  let [local, domain] = parts;\n  const typo = Boolean(TYPO_DOMAINS[domain]);\n\n  const valid = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/\n    .test(text) && !/\\.\\./.test(text);\n\n  const bare = local.split('+')[0];\n  let keyLocal = bare;\n  let keyDomain = domain === 'googlemail.com' ? 'gmail.com' : domain;\n  if (keyDomain === 'gmail.com') keyLocal = keyLocal.replace(/\\./g, '');\n\n  return {\n    value: text,\n    key: valid ? keyLocal + '@' + keyDomain : '',\n    domain: keyDomain,\n    valid,\n    reason: valid ? 'ok' : 'bad_syntax',\n    flags: {\n      role: ROLE_LOCAL_PARTS.includes(bare),\n      disposable: DISPOSABLE_DOMAINS.includes(domain),\n      typoDomain: typo,\n      suggestion: typo ? bare + '@' + TYPO_DOMAINS[domain] : '',\n    },\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Name handling\n// ---------------------------------------------------------------------------\nconst NICKNAMES = {\n  bob: 'robert', rob: 'robert', bobby: 'robert', robbie: 'robert',\n  mike: 'michael', mick: 'michael', micky: 'michael',\n  jen: 'jennifer', jenny: 'jennifer', jenn: 'jennifer',\n  dave: 'david', dan: 'daniel', danny: 'daniel', tom: 'thomas', tommy: 'thomas',\n  bill: 'william', will: 'william', billy: 'william', liz: 'elizabeth', beth: 'elizabeth',\n  kate: 'katherine', katie: 'katherine', cathy: 'catherine', sue: 'susan',\n  chris: 'christopher', steve: 'stephen', jim: 'james', jimmy: 'james',\n  tony: 'anthony', nick: 'nicholas', alex: 'alexander', sam: 'samuel',\n  matt: 'matthew', greg: 'gregory', pat: 'patrick', ed: 'edward', ted: 'edward',\n};\n\nfunction canonicalName(value) {\n  return String(value ?? '').toLowerCase().normalize('NFD')\n    .replace(/[\\u0300-\\u036f]/g, '')       // strip accents: \"Mal\u00e9\" -> \"male\"\n    .replace(/[^a-z\\s'-]/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nfunction expandNickname(name) {\n  const canonical = canonicalName(name);\n  return NICKNAMES[canonical] || canonical;\n}\n\n/** Soundex, used only as a blocking key \u2014 cheap, phonetic, and good enough to\n *  put \"Nakamura\" and \"Nakamurra\" in the same bucket. */\nfunction soundex(value) {\n  const text = canonicalName(value).replace(/[^a-z]/g, '');\n  if (!text) return '';\n  const codes = { b: 1, f: 1, p: 1, v: 1, c: 2, g: 2, j: 2, k: 2, q: 2, s: 2, x: 2, z: 2,\n    d: 3, t: 3, l: 4, m: 5, n: 5, r: 6 };\n  let result = text[0].toUpperCase();\n  let previous = codes[text[0]] || 0;\n  for (let i = 1; i < text.length && result.length < 4; i++) {\n    const code = codes[text[i]] || 0;\n    if (code && code !== previous) result += code;\n    if (!'hw'.includes(text[i])) previous = code;\n  }\n  return (result + '000').slice(0, 4);\n}\n\n/** Jaro-Winkler similarity, 0-1. Better than Levenshtein for short human names:\n *  it rewards a shared prefix, which is exactly how names get mistyped. */\nfunction jaroWinkler(a, b) {\n  const s1 = canonicalName(a);\n  const s2 = canonicalName(b);\n  if (!s1 || !s2) return 0;\n  if (s1 === s2) return 1;\n\n  const matchWindow = Math.max(0, Math.floor(Math.max(s1.length, s2.length) / 2) - 1);\n  const s1Matched = new Array(s1.length).fill(false);\n  const s2Matched = new Array(s2.length).fill(false);\n  let matches = 0;\n\n  for (let i = 0; i < s1.length; i++) {\n    const start = Math.max(0, i - matchWindow);\n    const end = Math.min(i + matchWindow + 1, s2.length);\n    for (let j = start; j < end; j++) {\n      if (s2Matched[j] || s1[i] !== s2[j]) continue;\n      s1Matched[i] = true;\n      s2Matched[j] = true;\n      matches += 1;\n      break;\n    }\n  }\n  if (matches === 0) return 0;\n\n  let transpositions = 0;\n  let k = 0;\n  for (let i = 0; i < s1.length; i++) {\n    if (!s1Matched[i]) continue;\n    while (!s2Matched[k]) k += 1;\n    if (s1[i] !== s2[k]) transpositions += 1;\n    k += 1;\n  }\n  transpositions /= 2;\n\n  const jaro = (matches / s1.length + matches / s2.length +\n    (matches - transpositions) / matches) / 3;\n\n  let prefix = 0;\n  while (prefix < 4 && prefix < s1.length && prefix < s2.length && s1[prefix] === s2[prefix]) {\n    prefix += 1;\n  }\n  return jaro + prefix * 0.1 * (1 - jaro);\n}\n\n// ---------------------------------------------------------------------------\n// Union-Find, so that A~B and B~C put A, B and C in one cluster\n// ---------------------------------------------------------------------------\nfunction createUnionFind(size) {\n  const parent = Array.from({ length: size }, (unused, index) => index);\n  const rank = new Array(size).fill(0);\n\n  function find(index) {\n    while (parent[index] !== index) {\n      parent[index] = parent[parent[index]];  // path compression\n      index = parent[index];\n    }\n    return index;\n  }\n\n  function union(a, b) {\n    const rootA = find(a);\n    const rootB = find(b);\n    if (rootA === rootB) return;\n    if (rank[rootA] < rank[rootB]) parent[rootA] = rootB;\n    else if (rank[rootA] > rank[rootB]) parent[rootB] = rootA;\n    else { parent[rootB] = rootA; rank[rootA] += 1; }\n  }\n\n  return { find, union };\n}\n\n// ---------------------------------------------------------------------------\n// Per-contact preparation\n// ---------------------------------------------------------------------------\nfunction prepareContact(contact, config, nowMs) {\n  const phone = normalizePhone(contact.phone, contact.country || config.defaultCountry);\n  const email = normalizeEmail(contact.email);\n\n  const first = canonicalName(contact.firstName);\n  const last = canonicalName(contact.lastName);\n  const firstCanonical = expandNickname(first);\n\n  const lastActivityMs = contact.lastActivityAt ? Date.parse(contact.lastActivityAt) : NaN;\n  const ageDays = Number.isNaN(lastActivityMs) ? null\n    : Math.floor((nowMs - lastActivityMs) / 86400000);\n\n  // Completeness: how much of this record is actually filled in. Drives which\n  // record survives a merge.\n  const fields = [contact.firstName, contact.lastName, contact.email, contact.phone,\n    contact.companyName, contact.city, contact.postalCode, contact.country];\n  const completeness = fields.filter((value) => String(value ?? '').trim() !== '').length;\n\n  return {\n    raw: contact,\n    id: String(contact.id),\n    phone,\n    email,\n    first,\n    firstCanonical,\n    last,\n    fullName: [first, last].filter(Boolean).join(' '),\n    postalCode: String(contact.postalCode ?? '').toUpperCase().replace(/\\s+/g, ''),\n    company: canonicalName(contact.companyName),\n    completeness,\n    ageDays,\n    createdMs: Date.parse(contact.createdAt) || 0,\n    lastActivityMs: Number.isNaN(lastActivityMs) ? 0 : lastActivityMs,\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Pair comparison\n// ---------------------------------------------------------------------------\n/**\n * Returns { verdict, confidence, signals[], conflicts[] }.\n * verdict is one of 'merge' | 'review' | 'separate'.\n */\nfunction comparePair(a, b, config) {\n  const signals = [];\n  const conflicts = [];\n  let confidence = 0;\n\n  const bothPhones = a.phone.e164 && b.phone.e164;\n  const bothEmails = a.email.key && b.email.key;\n\n  // --- positive evidence -------------------------------------------------\n  if (bothEmails && a.email.key === b.email.key) {\n    confidence = Math.max(confidence, 95);\n    signals.push('exact_email');\n  }\n  if (bothPhones && a.phone.e164 === b.phone.e164) {\n    confidence = Math.max(confidence, 90);\n    signals.push('exact_phone');\n  }\n  if (bothPhones && a.phone.e164 !== b.phone.e164 &&\n      a.phone.national && a.phone.national === b.phone.national) {\n    confidence = Math.max(confidence, 80);\n    signals.push('same_national_number');\n  }\n\n  const nameSimilarity = jaroWinkler(a.fullName, b.fullName);\n  const firstMatch = a.firstCanonical && a.firstCanonical === b.firstCanonical;\n  const lastSimilarity = jaroWinkler(a.last, b.last);\n\n  const secondSignal =\n    (a.postalCode && a.postalCode === b.postalCode) ? 'same_postcode'\n      : (a.company && a.company === b.company) ? 'same_company'\n        : (a.email.domain && a.email.domain === b.email.domain &&\n           !['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com'].includes(a.email.domain))\n          ? 'same_email_domain'\n          : null;\n\n  if (nameSimilarity >= config.nameSimilarityThreshold && secondSignal) {\n    confidence = Math.max(confidence, 70);\n    signals.push('similar_name');\n    signals.push(secondSignal);\n  }\n\n  if (firstMatch && lastSimilarity >= 0.92 && secondSignal) {\n    confidence = Math.max(confidence, 65);\n    signals.push('nickname_or_typo_name');\n  }\n\n  if (confidence === 0) return { verdict: 'separate', confidence: 0, signals, conflicts, nameSimilarity };\n\n  // --- negative evidence -------------------------------------------------\n  // Two records that each carry a phone number, and the numbers differ, are\n  // usually two people. This is the single most valuable check in the file.\n  if (bothPhones && a.phone.e164 !== b.phone.e164 && !signals.includes('same_national_number')) {\n    conflicts.push('different_phone_numbers');\n  }\n  if (bothEmails && a.email.key !== b.email.key && bothPhones &&\n      a.phone.e164 !== b.phone.e164) {\n    conflicts.push('different_email_and_phone');\n  }\n  // Shared inbox, different humans: \"petrov.family@example.net\".\n  if (a.first && b.first && !firstMatch && jaroWinkler(a.first, b.first) < 0.70) {\n    conflicts.push('different_first_names');\n  }\n  if (a.last && b.last && lastSimilarity < 0.80) {\n    conflicts.push('different_last_names');\n  }\n\n  let verdict;\n  if (conflicts.length === 0 && confidence >= 90) verdict = 'merge';\n  else if (conflicts.length === 0 && confidence >= 65) verdict = 'review';\n  else if (conflicts.length && confidence >= 90) verdict = 'review';   // strong match, real doubt\n  else verdict = 'separate';\n\n  return { verdict, confidence, signals, conflicts, nameSimilarity };\n}\n\n// ---------------------------------------------------------------------------\n// Blocking: only compare contacts that share a cheap key\n// ---------------------------------------------------------------------------\nfunction buildBlocks(prepared) {\n  const blocks = new Map();\n\n  const add = (key, index) => {\n    if (!key) return;\n    if (!blocks.has(key)) blocks.set(key, []);\n    blocks.get(key).push(index);\n  };\n\n  prepared.forEach((contact, index) => {\n    if (contact.phone.national) add('p:' + contact.phone.national.slice(-9), index);\n    if (contact.email.key) add('e:' + contact.email.key, index);\n    if (contact.last) add('n:' + soundex(contact.last) + ':' + (contact.postalCode || contact.email.domain || ''), index);\n    if (contact.last && contact.firstCanonical) add('f:' + contact.firstCanonical + ':' + soundex(contact.last), index);\n  });\n\n  // A block containing most of the list is not a block \u2014 it is a full scan with\n  // extra steps. Usually caused by thousands of contacts sharing a blank field.\n  const MAX_BLOCK = 200;\n  const usable = [];\n  const oversized = [];\n  for (const [key, members] of blocks) {\n    if (members.length < 2) continue;\n    if (members.length > MAX_BLOCK) { oversized.push({ key, size: members.length }); continue; }\n    usable.push(members);\n  }\n\n  return { blocks: usable, oversized };\n}\n\n// ---------------------------------------------------------------------------\n// Deliverability\n// ---------------------------------------------------------------------------\nfunction scoreDeliverability(contact, config) {\n  const issues = [];\n  const raw = contact.raw;\n  const status = String(raw.emailStatus ?? '').toLowerCase();\n\n  if (!contact.email.value) issues.push('EMAIL_MISSING');\n  else if (!contact.email.valid) issues.push('EMAIL_INVALID_SYNTAX');\n  if (contact.email.flags.role) issues.push('EMAIL_ROLE_ADDRESS');\n  if (contact.email.flags.disposable) issues.push('EMAIL_DISPOSABLE');\n  if (contact.email.flags.typoDomain) issues.push('EMAIL_LIKELY_TYPO_DOMAIN');\n  if (status === 'bounced') issues.push('EMAIL_HARD_BOUNCED');\n  if (status === 'unsubscribed') issues.push('EMAIL_UNSUBSCRIBED');\n\n  if (!raw.phone) issues.push('PHONE_MISSING');\n  else if (!contact.phone.valid) issues.push('PHONE_INVALID_' + contact.phone.reason.toUpperCase());\n  if (raw.dnd === true) issues.push('DND_SET');\n\n  if (contact.ageDays === null) issues.push('NO_RECORDED_ACTIVITY');\n  else if (contact.ageDays > config.staleAfterDays) issues.push('STALE_' + contact.ageDays + 'D');\n\n  const mailable = contact.email.valid && !contact.email.flags.disposable &&\n    status !== 'bounced' && status !== 'unsubscribed' && raw.dnd !== true;\n  const textable = contact.phone.valid && raw.dnd !== true;\n\n  return {\n    id: contact.id,\n    name: [raw.firstName, raw.lastName].filter(Boolean).join(' '),\n    email: contact.email.value,\n    emailSuggestion: contact.email.flags.suggestion || '',\n    phone: contact.phone.e164 || raw.phone || '',\n    mailable,\n    textable,\n    // Deliberately not \"verified\". Nothing here has been sent or looked up over\n    // the network, so nothing here can honestly claim an address exists.\n    emailVerification: 'unverified_syntax_only',\n    reachable: mailable || textable,\n    issues,\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Main\n// ---------------------------------------------------------------------------\nfunction audit(contacts, config, nowMs) {\n  const prepared = contacts.map((contact) => prepareContact(contact, config, nowMs));\n  const { blocks, oversized } = buildBlocks(prepared);\n\n  const unionFind = createUnionFind(prepared.length);\n  const pairs = [];\n  const reviewPairs = [];\n  const comparedKeys = new Set();\n  let comparisons = 0;\n\n  for (const members of blocks) {\n    for (let i = 0; i < members.length; i++) {\n      for (let j = i + 1; j < members.length; j++) {\n        const a = members[i];\n        const b = members[j];\n        const key = a < b ? a + ':' + b : b + ':' + a;\n        if (comparedKeys.has(key)) continue;      // blocks overlap; compare once\n        comparedKeys.add(key);\n        comparisons += 1;\n\n        const result = comparePair(prepared[a], prepared[b], config);\n        if (result.verdict === 'merge') {\n          unionFind.union(a, b);\n          pairs.push({ a: prepared[a].id, b: prepared[b].id, ...result });\n        } else if (result.verdict === 'review') {\n          reviewPairs.push({ a: prepared[a].id, b: prepared[b].id, ...result });\n        }\n      }\n    }\n  }\n\n  // --- group into clusters -----------------------------------------------\n  const clusterMap = new Map();\n  prepared.forEach((contact, index) => {\n    const root = unionFind.find(index);\n    if (!clusterMap.has(root)) clusterMap.set(root, []);\n    clusterMap.get(root).push(contact);\n  });\n\n  const mergePlan = [];\n  for (const members of clusterMap.values()) {\n    if (members.length < 2) continue;\n\n    // Survivor: most complete record, then most recently active, then oldest\n    // (keeping the original id preserves the account's own history), then id\n    // order so the plan is byte-identical on every run.\n    const ordered = members.slice().sort((a, b) =>\n      b.completeness - a.completeness ||\n      b.lastActivityMs - a.lastActivityMs ||\n      a.createdMs - b.createdMs ||\n      a.id.localeCompare(b.id));\n\n    const survivor = ordered[0];\n    const losers = ordered.slice(1);\n\n    // What the survivor actually gains. If the answer is \"nothing\", the merge is\n    // still worth doing \u2014 but the client should see that it is a pure cleanup.\n    const gains = {};\n    for (const field of ['firstName', 'lastName', 'email', 'phone', 'companyName',\n      'city', 'postalCode', 'country']) {\n    
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

CRM List Hygiene & Duplicate Merge Planner. Uses httpRequest. Event-driven trigger; 24 nodes.

Source: https://github.com/ihthicodes/n8n-agency-demos/blob/main/demos/02-crm-list-hygiene/workflow.json — original creator credit. Request a take-down →

More Web Scraping workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Web Scraping

This workflow listens for an “Approved” label on a Trello card, reads the AI draft bookkeeping JSON from card comments, and posts the corresponding transaction to Xero. It then adds a Xero deep link b

Trello Trigger, HTTP Request
Web Scraping

02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.

Execute Workflow Trigger, HTTP Request, Sea Table
Web Scraping

This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t

Execute Command, Read Write File, HTTP Request +3
Web Scraping

[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.

HTTP Request, GitHub, Stop And Error +1
Web Scraping

[](https://youtu.be/c7yCZhmMjtI)

HTTP Request, GitHub, Stop And Error +1