lacspace-leads 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib.cjs CHANGED
@@ -21,8 +21,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var lib_exports = {};
22
22
  __export(lib_exports, {
23
23
  ALL_FIELDS: () => ALL_FIELDS,
24
+ CALLING_CODES: () => CALLING_CODES,
25
+ DEFAULT_FIELDS: () => DEFAULT_FIELDS,
24
26
  ENRICHED_FIELDS: () => ENRICHED_FIELDS,
27
+ FIELD_PRESETS: () => FIELD_PRESETS,
25
28
  LeadsError: () => LeadsError,
29
+ callingCode: () => callingCode,
30
+ cleanWebsite: () => cleanWebsite,
26
31
  columnsOf: () => columnsOf,
27
32
  composeQuery: () => composeQuery,
28
33
  convertFile: () => convertFile,
@@ -30,19 +35,25 @@ __export(lib_exports, {
30
35
  defaultFilename: () => defaultFilename,
31
36
  detectFormat: () => detectFormat,
32
37
  enrichContacts: () => enrichContacts,
38
+ expandQueries: () => expandQueries,
33
39
  extractEmails: () => extractEmails,
34
40
  extractSocials: () => extractSocials,
35
41
  filterLeads: () => filterLeads,
36
42
  mapsSearchUrl: () => mapsSearchUrl,
37
43
  normalizeFields: () => normalizeFields,
44
+ normalizePhone: () => normalizePhone,
38
45
  parseLatLng: () => parseLatLng,
39
46
  parseRating: () => parseRating,
40
47
  parseReviewCount: () => parseReviewCount,
41
48
  readRows: () => readRows,
49
+ resolvePreset: () => resolvePreset,
42
50
  scrapeLeads: () => scrapeLeads,
43
51
  searchLeads: () => scrapeLeads,
52
+ searchLeadsBatch: () => searchLeadsBatch,
53
+ searchLeadsMulti: () => searchLeadsMulti,
44
54
  serialize: () => serialize,
45
55
  serializeRows: () => serializeRows,
56
+ sortLeads: () => sortLeads,
46
57
  toRows: () => toRows
47
58
  });
48
59
  module.exports = __toCommonJS(lib_exports);
@@ -64,13 +75,42 @@ var ALL_FIELDS = [
64
75
  "facebook",
65
76
  "instagram",
66
77
  "whatsapp",
78
+ "linkedin",
79
+ "twitter",
80
+ "youtube",
81
+ "tiktok",
82
+ "telegram",
67
83
  "plusCode",
68
84
  "latitude",
69
85
  "longitude",
70
86
  "hours",
71
87
  "mapsUrl"
72
88
  ];
73
- var ENRICHED_FIELDS = ["email", "facebook", "instagram", "whatsapp"];
89
+ var ENRICHED_FIELDS = [
90
+ "email",
91
+ "facebook",
92
+ "instagram",
93
+ "whatsapp",
94
+ "linkedin",
95
+ "twitter",
96
+ "youtube",
97
+ "tiktok",
98
+ "telegram"
99
+ ];
100
+ var FIELD_PRESETS = {
101
+ /** Just enough to place a business: name, phone, website. */
102
+ minimal: ["name", "phone", "website"],
103
+ /** Cold-outreach columns: who, how to reach them, where. */
104
+ outreach: ["name", "phone", "email", "website", "address"],
105
+ /** Contact-book columns incl. socials (implies enrichment). */
106
+ contact: ["name", "phone", "email", "website", "facebook", "instagram", "whatsapp"],
107
+ /** Map/geo columns for plotting. */
108
+ geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
109
+ /** Everything Maps shows, no website enrichment. */
110
+ full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
111
+ /** Every field, including enriched ones. */
112
+ everything: [...ALL_FIELDS]
113
+ };
74
114
  var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
75
115
 
76
116
  // src/query.ts
@@ -81,8 +121,38 @@ function composeQuery(opts) {
81
121
  if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
82
122
  return where ? `${type} in ${where}` : type;
83
123
  }
84
- function mapsSearchUrl(query) {
85
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
124
+ function mapsSearchUrl(query, opts = {}) {
125
+ const hl = (opts.hl ?? "en").split("-")[0] || "en";
126
+ const params = new URLSearchParams({ hl });
127
+ if (opts.gl) params.set("gl", opts.gl.toLowerCase());
128
+ return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
129
+ }
130
+ function expandQueries(opts) {
131
+ if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
132
+ const split = (s) => {
133
+ const parts = (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
134
+ return parts.length ? parts : [void 0];
135
+ };
136
+ const types = split(opts.type);
137
+ const cities = split(opts.city);
138
+ const areas = split(opts.area);
139
+ const out = [];
140
+ for (const type of types) {
141
+ for (const city of cities) {
142
+ for (const area of areas) {
143
+ const entry = {};
144
+ if (type) entry.type = type;
145
+ if (city) entry.city = city;
146
+ if (area) entry.area = area;
147
+ out.push(entry);
148
+ }
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ function resolvePreset(name) {
154
+ if (!name) return void 0;
155
+ return FIELD_PRESETS[name.trim().toLowerCase()];
86
156
  }
87
157
  function normalizeFields(input) {
88
158
  if (!input) return [...ALL_FIELDS];
@@ -123,12 +193,23 @@ function extractSocials(html) {
123
193
  const hrefs = [...html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1] ?? "");
124
194
  for (const href of hrefs) {
125
195
  const u = href.trim();
126
- if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?/i.test(u)) {
196
+ if (!u) continue;
197
+ if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?|dialog\//i.test(u)) {
127
198
  out.facebook = absolutize(u);
128
- } else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u)) {
199
+ } else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u) && !/\/(share|p)\//i.test(u)) {
129
200
  out.instagram = absolutize(u);
130
201
  } else if (!out.whatsapp && /(wa\.me\/|api\.whatsapp\.com\/|whatsapp:\/\/)/i.test(u)) {
131
202
  out.whatsapp = u.startsWith("http") || u.startsWith("whatsapp") ? u : `https:${u}`;
203
+ } else if (!out.linkedin && /(^|\/\/|\.)linkedin\.com\/(company|in|school)\//i.test(u) && !/sharing|shareArticle/i.test(u)) {
204
+ out.linkedin = absolutize(u);
205
+ } else if (!out.twitter && /(^|\/\/|\.)(twitter|x)\.com\//i.test(u) && !/(intent|share|widgets|hashtag)\b/i.test(u)) {
206
+ out.twitter = absolutize(u);
207
+ } else if (!out.youtube && /(youtube\.com\/(channel|c|user|@)|youtu\.be\/)/i.test(u)) {
208
+ out.youtube = absolutize(u);
209
+ } else if (!out.tiktok && /(^|\/\/|\.)tiktok\.com\/@/i.test(u)) {
210
+ out.tiktok = absolutize(u);
211
+ } else if (!out.telegram && /(t\.me\/|telegram\.me\/)/i.test(u) && !/\/share\b/i.test(u)) {
212
+ out.telegram = absolutize(u);
132
213
  }
133
214
  }
134
215
  return out;
@@ -179,11 +260,9 @@ async function enrichContacts(website, opts = {}) {
179
260
  const email = extractEmails(page);
180
261
  if (email) {
181
262
  contacts.email = email;
182
- if (!contacts.facebook || !contacts.instagram || !contacts.whatsapp) {
183
- const s = extractSocials(page);
184
- if (!contacts.facebook && s.facebook) contacts.facebook = s.facebook;
185
- if (!contacts.instagram && s.instagram) contacts.instagram = s.instagram;
186
- if (!contacts.whatsapp && s.whatsapp) contacts.whatsapp = s.whatsapp;
263
+ const s = extractSocials(page);
264
+ for (const k of Object.keys(s)) {
265
+ if (!contacts[k] && s[k]) contacts[k] = s[k];
187
266
  }
188
267
  break;
189
268
  }
@@ -238,7 +317,148 @@ function dedupeLeads(leads, by = "website") {
238
317
  return out;
239
318
  }
240
319
 
320
+ // src/normalize.ts
321
+ var TRACKING_PARAMS = /^(utm_|fbclid$|gclid$|gbraid$|wbraid$|mc_|_hs|ref$|ref_src$|source$|igshid$|yclid$)/i;
322
+ function cleanWebsite(url) {
323
+ if (!url) return void 0;
324
+ let raw = url.trim();
325
+ if (!raw) return void 0;
326
+ const g = raw.match(/[?&](?:q|url)=([^&]+)/i);
327
+ if (g && /google\.[a-z.]+\/url/i.test(raw)) {
328
+ try {
329
+ raw = decodeURIComponent(g[1]);
330
+ } catch {
331
+ }
332
+ }
333
+ let u;
334
+ try {
335
+ u = new URL(raw.startsWith("http") ? raw : `https://${raw}`);
336
+ } catch {
337
+ return raw;
338
+ }
339
+ u.hash = "";
340
+ u.host = u.host.toLowerCase();
341
+ for (const key of [...u.searchParams.keys()]) {
342
+ if (TRACKING_PARAMS.test(key)) u.searchParams.delete(key);
343
+ }
344
+ let href = u.toString();
345
+ if (u.pathname === "/" && !u.search) href = href.replace(/\/$/, "");
346
+ return href;
347
+ }
348
+ var CALLING_CODES = {
349
+ NP: "977",
350
+ IN: "91",
351
+ US: "1",
352
+ CA: "1",
353
+ GB: "44",
354
+ AU: "61",
355
+ NZ: "64",
356
+ SG: "65",
357
+ MY: "60",
358
+ ID: "62",
359
+ TH: "66",
360
+ PH: "63",
361
+ VN: "84",
362
+ BD: "880",
363
+ PK: "92",
364
+ LK: "94",
365
+ AE: "971",
366
+ SA: "966",
367
+ QA: "974",
368
+ KW: "965",
369
+ BH: "973",
370
+ OM: "968",
371
+ CN: "86",
372
+ HK: "852",
373
+ JP: "81",
374
+ KR: "82",
375
+ DE: "49",
376
+ FR: "33",
377
+ ES: "34",
378
+ IT: "39",
379
+ NL: "31",
380
+ BE: "32",
381
+ CH: "41",
382
+ AT: "43",
383
+ SE: "46",
384
+ NO: "47",
385
+ DK: "45",
386
+ FI: "358",
387
+ IE: "353",
388
+ PT: "351",
389
+ PL: "48",
390
+ ZA: "27",
391
+ NG: "234",
392
+ KE: "254",
393
+ EG: "20",
394
+ BR: "55",
395
+ MX: "52",
396
+ AR: "54",
397
+ TR: "90"
398
+ };
399
+ function callingCode(country) {
400
+ if (!country) return void 0;
401
+ const c = country.trim().replace(/^\+/, "");
402
+ if (/^\d{1,4}$/.test(c)) return c;
403
+ return CALLING_CODES[c.toUpperCase()];
404
+ }
405
+ function normalizePhone(raw, country) {
406
+ if (!raw) return void 0;
407
+ const trimmed = raw.trim();
408
+ if (!trimmed) return void 0;
409
+ const isIntl = /^\+/.test(trimmed) || /^00\d/.test(trimmed);
410
+ let digits = trimmed.replace(/[^0-9]/g, "");
411
+ if (/^00\d/.test(trimmed)) digits = digits.replace(/^00/, "");
412
+ const cc = callingCode(country);
413
+ if (isIntl) {
414
+ return plausible(digits) ? `+${digits}` : trimmed;
415
+ }
416
+ if (!cc) return trimmed;
417
+ if (digits.startsWith(cc)) return plausible(digits) ? `+${digits}` : trimmed;
418
+ const local = digits.replace(/^0+/, "");
419
+ const e164 = `${cc}${local}`;
420
+ return plausible(e164) ? `+${e164}` : trimmed;
421
+ }
422
+ function plausible(digits) {
423
+ return digits.length >= 8 && digits.length <= 15;
424
+ }
425
+ var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
426
+ function sortLeads(leads, by, dir) {
427
+ if (!by) return [...leads];
428
+ const desc = dir ? dir === "desc" : by !== "name";
429
+ const val = (l) => {
430
+ if (by === "name") return l.name?.toLowerCase();
431
+ if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
432
+ return l[by];
433
+ };
434
+ return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
435
+ const av = val(a.lead);
436
+ const bv = val(b.lead);
437
+ const aMissing = av === void 0 || av === "";
438
+ const bMissing = bv === void 0 || bv === "";
439
+ if (aMissing && bMissing) return a.i - b.i;
440
+ if (aMissing) return 1;
441
+ if (bMissing) return -1;
442
+ let cmp = 0;
443
+ if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
444
+ else cmp = String(av).localeCompare(String(bv));
445
+ if (desc) cmp = -cmp;
446
+ return cmp !== 0 ? cmp : a.i - b.i;
447
+ }).map((x) => x.lead);
448
+ }
449
+
241
450
  // src/scrape.ts
451
+ async function pool(items, n, fn, signal) {
452
+ let next = 0;
453
+ const workers = Array.from({ length: Math.max(1, Math.min(n, items.length)) }, async () => {
454
+ for (; ; ) {
455
+ const i = next++;
456
+ if (i >= items.length || signal?.aborted) return;
457
+ await fn(items[i], i);
458
+ }
459
+ });
460
+ await Promise.all(workers);
461
+ }
242
462
  var LeadsError = class extends Error {
243
463
  code;
244
464
  cause;
@@ -363,7 +583,13 @@ async function extractDetail(page, fields, fallbackName) {
363
583
  }
364
584
  if (fields.has("reviews")) {
365
585
  const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
366
- lead.reviews = parseReviewCount(rv);
586
+ let count = parseReviewCount(rv);
587
+ if (count === void 0) {
588
+ const boxText = await box.innerText().catch(() => "");
589
+ const m = boxText.match(/\(([\d.,\s]+)\)/);
590
+ if (m) count = parseReviewCount(m[1]);
591
+ }
592
+ lead.reviews = count;
367
593
  }
368
594
  }
369
595
  }
@@ -415,12 +641,15 @@ async function scrapeLeads(opts) {
415
641
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
416
642
  const browser = await launchBrowser(headless);
417
643
  try {
644
+ const locale = opts.locale ?? "en-US";
418
645
  const context = await browser.newContext({
419
646
  viewport: { width: 1280, height: 900 },
420
- locale: "en-US"
647
+ locale
421
648
  });
422
649
  const page = await context.newPage();
423
- await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
650
+ const urlOpts = { hl: locale };
651
+ if (opts.region) urlOpts.gl = opts.region;
652
+ await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
424
653
  await dismissConsent(page);
425
654
  await loadResults(page, limit, delayMs, onProgress, signal);
426
655
  if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
@@ -470,22 +699,42 @@ async function scrapeLeads(opts) {
470
699
  }
471
700
  }
472
701
  onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
702
+ if (opts.cleanUrls ?? true) {
703
+ for (const lead of leads) {
704
+ if (lead.website) lead.website = cleanWebsite(lead.website);
705
+ }
706
+ }
473
707
  leads = dedupeLeads(leads, opts.dedupe ?? "website");
474
708
  if (wantEnrich) {
475
- for (let i = 0; i < leads.length; i++) {
476
- if (signal?.aborted || overBudget()) break;
477
- const site = leads[i].website;
478
- if (!site) continue;
479
- onProgress?.(`enriching ${i + 1}/${leads.length}: ${leads[i].name ?? site}\u2026`);
480
- const c = await enrichContacts(site);
481
- if (fields.has("email") && c.email) leads[i].email = c.email;
482
- if (fields.has("facebook") && c.facebook) leads[i].facebook = c.facebook;
483
- if (fields.has("instagram") && c.instagram) leads[i].instagram = c.instagram;
484
- if (fields.has("whatsapp") && c.whatsapp) leads[i].whatsapp = c.whatsapp;
485
- if (!fields.has("website")) delete leads[i].website;
709
+ const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
710
+ let done = 0;
711
+ await pool(
712
+ leads,
713
+ Math.max(1, Math.trunc(opts.concurrency ?? 3)),
714
+ async (lead) => {
715
+ if (overBudget()) return;
716
+ const site = lead.website;
717
+ if (site) {
718
+ onProgress?.(`enriching ${++done}/${leads.length}: ${lead.name ?? site}\u2026`);
719
+ const c = await enrichContacts(site);
720
+ if (fields.has("email") && c.email) lead.email = c.email;
721
+ for (const f of socialFields) {
722
+ const v = c[f];
723
+ if (fields.has(f) && v) lead[f] = v;
724
+ }
725
+ }
726
+ if (!fields.has("website")) delete lead.website;
727
+ },
728
+ signal
729
+ );
730
+ }
731
+ if (opts.country && fields.has("phone")) {
732
+ for (const lead of leads) {
733
+ if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
486
734
  }
487
735
  }
488
736
  if (opts.filters) leads = filterLeads(leads, opts.filters);
737
+ if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
489
738
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
490
739
  return leads;
491
740
  } finally {
@@ -494,6 +743,69 @@ async function scrapeLeads(opts) {
494
743
  }
495
744
  }
496
745
 
746
+ // src/batch.ts
747
+ async function searchLeadsBatch(queries, opts = {}) {
748
+ const {
749
+ total,
750
+ onProgress,
751
+ signal,
752
+ type: _t,
753
+ city: _c,
754
+ area: _a,
755
+ query: _q,
756
+ ...shared
757
+ } = opts;
758
+ void _t;
759
+ void _c;
760
+ void _a;
761
+ void _q;
762
+ let merged = [];
763
+ const seenDedupe = shared.dedupe ?? "website";
764
+ for (let i = 0; i < queries.length; i++) {
765
+ if (signal?.aborted) break;
766
+ const q = queries[i];
767
+ const label = (() => {
768
+ try {
769
+ return composeQuery(q);
770
+ } catch {
771
+ return q.query ?? q.type ?? "search";
772
+ }
773
+ })();
774
+ onProgress?.(`[${i + 1}/${queries.length}] ${label}`);
775
+ const subOpts = {
776
+ ...shared,
777
+ type: q.type ?? "",
778
+ // Defer sorting to the merged pass; keep per-search dedupe/filter local.
779
+ ...q.query ? { query: q.query } : {},
780
+ ...q.city ? { city: q.city } : {},
781
+ ...q.area ? { area: q.area } : {}
782
+ };
783
+ delete subOpts.sort;
784
+ if (signal) subOpts.signal = signal;
785
+ if (onProgress) subOpts.onProgress = (m) => onProgress(` ${m}`);
786
+ try {
787
+ const found = await scrapeLeads(subOpts);
788
+ merged = merged.concat(found);
789
+ } catch (err) {
790
+ onProgress?.(` ! skipped "${label}": ${err.message}`);
791
+ }
792
+ }
793
+ merged = dedupeLeads(merged, seenDedupe);
794
+ if (opts.sort) merged = sortLeads(merged, opts.sort, opts.sortDir);
795
+ if (total !== void 0 && total > 0) merged = merged.slice(0, total);
796
+ onProgress?.(`merged ${merged.length} unique lead${merged.length === 1 ? "" : "s"}.`);
797
+ return merged;
798
+ }
799
+ async function searchLeadsMulti(opts) {
800
+ const queries = expandQueries({
801
+ ...opts.type ? { type: opts.type } : {},
802
+ ...opts.city ? { city: opts.city } : {},
803
+ ...opts.area ? { area: opts.area } : {},
804
+ ...opts.query ? { query: opts.query } : {}
805
+ });
806
+ return searchLeadsBatch(queries, opts);
807
+ }
808
+
497
809
  // src/export.ts
498
810
  var import_csv = require("@lacspace/csv");
499
811
  var import_xlsx = require("@lacspace/xlsx");
@@ -510,6 +822,11 @@ var HEADERS = {
510
822
  facebook: "Facebook",
511
823
  instagram: "Instagram",
512
824
  whatsapp: "WhatsApp",
825
+ linkedin: "LinkedIn",
826
+ twitter: "Twitter/X",
827
+ youtube: "YouTube",
828
+ tiktok: "TikTok",
829
+ telegram: "Telegram",
513
830
  plusCode: "Plus Code",
514
831
  latitude: "Latitude",
515
832
  longitude: "Longitude",
@@ -527,13 +844,14 @@ function toRows(leads, fields = ALL_FIELDS) {
527
844
  });
528
845
  }
529
846
  function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
530
- if (format === "json") {
847
+ if (format === "json" || format === "ndjson") {
531
848
  const picked = leads.map((lead) => {
532
849
  const o = {};
533
850
  for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
534
851
  return o;
535
852
  });
536
- return { data: JSON.stringify(picked, null, 2), binary: false };
853
+ const data = format === "ndjson" ? picked.map((o) => JSON.stringify(o)).join("\n") + (picked.length ? "\n" : "") : JSON.stringify(picked, null, 2);
854
+ return { data, binary: false };
537
855
  }
538
856
  const rows = toRows(leads, fields);
539
857
  if (format === "csv") {
@@ -627,8 +945,13 @@ async function convertFile(input, opts = {}) {
627
945
  // Annotate the CommonJS export names for ESM import in node:
628
946
  0 && (module.exports = {
629
947
  ALL_FIELDS,
948
+ CALLING_CODES,
949
+ DEFAULT_FIELDS,
630
950
  ENRICHED_FIELDS,
951
+ FIELD_PRESETS,
631
952
  LeadsError,
953
+ callingCode,
954
+ cleanWebsite,
632
955
  columnsOf,
633
956
  composeQuery,
634
957
  convertFile,
@@ -636,18 +959,24 @@ async function convertFile(input, opts = {}) {
636
959
  defaultFilename,
637
960
  detectFormat,
638
961
  enrichContacts,
962
+ expandQueries,
639
963
  extractEmails,
640
964
  extractSocials,
641
965
  filterLeads,
642
966
  mapsSearchUrl,
643
967
  normalizeFields,
968
+ normalizePhone,
644
969
  parseLatLng,
645
970
  parseRating,
646
971
  parseReviewCount,
647
972
  readRows,
973
+ resolvePreset,
648
974
  scrapeLeads,
649
975
  searchLeads,
976
+ searchLeadsBatch,
977
+ searchLeadsMulti,
650
978
  serialize,
651
979
  serializeRows,
980
+ sortLeads,
652
981
  toRows
653
982
  });