@dench.com/cli 3.11.6 → 3.11.7-staging.488
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/app-sdk.d.ts +6 -0
- package/campaign.ts +157 -43
- package/lib/api-schemas.ts +130 -57
- package/lib/campaign-agent.ts +136 -50
- package/lib/campaign-csv.ts +183 -53
- package/lib/enrichment-gateway.ts +9 -0
- package/lib/references.generated.json +4 -4
- package/package.json +1 -1
package/lib/campaign-csv.ts
CHANGED
|
@@ -11,6 +11,10 @@ export type RecipientTable = {
|
|
|
11
11
|
hasHeader: boolean;
|
|
12
12
|
columnLabels: string[];
|
|
13
13
|
columnSamples: string[];
|
|
14
|
+
/** Where the file's quoting breaks (`scanCsv`); no one is read from it. */
|
|
15
|
+
error?: string;
|
|
16
|
+
/** Rows with filled cells past the header's last column. */
|
|
17
|
+
raggedRows?: number;
|
|
14
18
|
};
|
|
15
19
|
|
|
16
20
|
export type RecipientField =
|
|
@@ -65,8 +69,10 @@ export const MAX_CSV_CHARS = 5_000_000;
|
|
|
65
69
|
|
|
66
70
|
const EMAIL_RE = /[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
|
|
67
71
|
const NAMED_EMAIL_RE = /^(.*?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/;
|
|
72
|
+
// A slug may be in any script (linkedin.com/in/张伟): exports don't always
|
|
73
|
+
// percent-encode it, and `isLinkedinProfileUrl` accepts it.
|
|
68
74
|
const LINKEDIN_URL_RE =
|
|
69
|
-
/(?<![A-Za-z0-9.-])(?:https?:\/\/)?(?:[a-z]{1,4}\.)?linkedin\.com\/in\/[
|
|
75
|
+
/(?<![A-Za-z0-9.-])(?:https?:\/\/)?(?:[a-z]{1,4}\.)?linkedin\.com\/in\/[\p{L}\p{N}\-_.%]+\/?/iu;
|
|
70
76
|
|
|
71
77
|
const FILE_EXTENSION_TLDS = new Set([
|
|
72
78
|
"png",
|
|
@@ -337,8 +343,16 @@ export function extractLinkedinUrl(raw: string): string | null {
|
|
|
337
343
|
return isLinkedinProfileUrl(url) ? url : null;
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
// One person per profile, whatever host prefix (uk., www.) or encoding.
|
|
340
347
|
function linkedinKey(url: string): string {
|
|
341
|
-
|
|
348
|
+
const slug = url.match(/linkedin\.com\/in\/([^/?#]+)/i)?.[1] ?? url;
|
|
349
|
+
let decoded = slug;
|
|
350
|
+
try {
|
|
351
|
+
decoded = decodeURIComponent(slug);
|
|
352
|
+
} catch {
|
|
353
|
+
decoded = slug;
|
|
354
|
+
}
|
|
355
|
+
return `li:${decoded.toLowerCase()}`;
|
|
342
356
|
}
|
|
343
357
|
|
|
344
358
|
function normalizeEmail(raw: string): string {
|
|
@@ -390,17 +404,31 @@ export function detectDelimiter(text: string): CsvDelimiter {
|
|
|
390
404
|
return best ?? ",";
|
|
391
405
|
}
|
|
392
406
|
|
|
393
|
-
|
|
407
|
+
// A quote opens a quoted value only at the start of a cell; a quoted value
|
|
408
|
+
// must close, with nothing but spaces after it. A broken quote would fold
|
|
409
|
+
// the rows after it into one person's cell, so the file is refused with the
|
|
410
|
+
// line. The same rules as the app's `scanCsv` (outreach-recipients.ts).
|
|
411
|
+
export function scanCsv(
|
|
394
412
|
text: string,
|
|
395
413
|
delimiter: CsvDelimiter = ",",
|
|
396
|
-
): string[][] {
|
|
414
|
+
): { rows: string[][]; error?: string } {
|
|
397
415
|
const rows: string[][] = [];
|
|
398
416
|
let row: string[] = [];
|
|
399
417
|
let cell = "";
|
|
400
418
|
let inQuotes = false;
|
|
419
|
+
let afterQuote = false;
|
|
420
|
+
let line = 1;
|
|
421
|
+
let quoteLine = 1;
|
|
422
|
+
let error: string | undefined;
|
|
401
423
|
const src = text.replace(/^\uFEFF/, "");
|
|
424
|
+
const endCell = () => {
|
|
425
|
+
row.push(cell);
|
|
426
|
+
cell = "";
|
|
427
|
+
afterQuote = false;
|
|
428
|
+
};
|
|
402
429
|
for (let i = 0; i < src.length; i++) {
|
|
403
430
|
const ch = src[i];
|
|
431
|
+
if (ch === "\n" || (ch === "\r" && src[i + 1] !== "\n")) line += 1;
|
|
404
432
|
if (inQuotes) {
|
|
405
433
|
if (ch === '"') {
|
|
406
434
|
if (src[i + 1] === '"') {
|
|
@@ -408,28 +436,51 @@ export function splitCsvRows(
|
|
|
408
436
|
i += 1;
|
|
409
437
|
} else {
|
|
410
438
|
inQuotes = false;
|
|
439
|
+
afterQuote = true;
|
|
411
440
|
}
|
|
412
441
|
} else {
|
|
413
442
|
cell += ch;
|
|
414
443
|
}
|
|
415
|
-
} else if (ch === '"') {
|
|
416
|
-
inQuotes = true;
|
|
417
444
|
} else if (ch === delimiter) {
|
|
418
|
-
|
|
419
|
-
cell = "";
|
|
445
|
+
endCell();
|
|
420
446
|
} else if (ch === "\n" || ch === "\r") {
|
|
421
|
-
if (ch === "\r" && src[i + 1] === "\n")
|
|
422
|
-
|
|
423
|
-
|
|
447
|
+
if (ch === "\r" && src[i + 1] === "\n") {
|
|
448
|
+
i += 1;
|
|
449
|
+
line += 1;
|
|
450
|
+
}
|
|
451
|
+
endCell();
|
|
424
452
|
if (row.some((value) => value.trim() !== "")) rows.push(row);
|
|
425
453
|
row = [];
|
|
454
|
+
} else if (afterQuote) {
|
|
455
|
+
if (ch.trim() && !error) {
|
|
456
|
+
error = `Line ${line} has text after a closing quote. Put the whole value in quotes and try again.`;
|
|
457
|
+
}
|
|
458
|
+
if (ch.trim()) cell += ch;
|
|
459
|
+
} else if (ch === '"' && !cell.trim()) {
|
|
460
|
+
cell = "";
|
|
461
|
+
inQuotes = true;
|
|
462
|
+
quoteLine = line;
|
|
426
463
|
} else {
|
|
427
464
|
cell += ch;
|
|
428
465
|
}
|
|
429
466
|
}
|
|
430
|
-
|
|
467
|
+
if (inQuotes && !error) {
|
|
468
|
+
error = `Line ${quoteLine} opens a quote that never closes, so the rows after it can't be read. Fix the quote and try again.`;
|
|
469
|
+
}
|
|
470
|
+
endCell();
|
|
431
471
|
if (row.some((value) => value.trim() !== "")) rows.push(row);
|
|
432
|
-
return rows;
|
|
472
|
+
return error ? { rows, error } : { rows };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function splitCsvRows(
|
|
476
|
+
text: string,
|
|
477
|
+
delimiter: CsvDelimiter = ",",
|
|
478
|
+
): string[][] {
|
|
479
|
+
return scanCsv(text, delimiter).rows;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function widestRow(rows: string[][], floor = 0): number {
|
|
483
|
+
return rows.reduce((width, row) => Math.max(width, row.length), floor);
|
|
433
484
|
}
|
|
434
485
|
|
|
435
486
|
function cellIsExactly(cell: string, value: string): boolean {
|
|
@@ -450,7 +501,7 @@ type ColumnStats = {
|
|
|
450
501
|
};
|
|
451
502
|
|
|
452
503
|
function columnStats(rows: string[][]): ColumnStats[] {
|
|
453
|
-
const width =
|
|
504
|
+
const width = widestRow(rows);
|
|
454
505
|
const stats: ColumnStats[] = Array.from({ length: width }, () => ({
|
|
455
506
|
samples: 0,
|
|
456
507
|
emailHits: 0,
|
|
@@ -516,21 +567,43 @@ function firstRowIsHeader(row: string[]): boolean {
|
|
|
516
567
|
|
|
517
568
|
function rowPersonalization(
|
|
518
569
|
row: string[],
|
|
519
|
-
|
|
570
|
+
names: readonly string[],
|
|
520
571
|
skip: (col: number) => boolean,
|
|
521
572
|
): Record<string, string> {
|
|
522
573
|
const bag: Record<string, string> = {};
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (!name || skip(col)) return;
|
|
574
|
+
names.forEach((name, col) => {
|
|
575
|
+
if (skip(col)) return;
|
|
526
576
|
const value = row[col]?.trim() ?? "";
|
|
527
|
-
if (
|
|
528
|
-
const key = `crm:${name}`;
|
|
529
|
-
if (!(key in bag)) bag[key] = value;
|
|
577
|
+
if (value) bag[`crm:${name}`] = value;
|
|
530
578
|
});
|
|
531
579
|
return bag;
|
|
532
580
|
}
|
|
533
581
|
|
|
582
|
+
// What each column is called as a merge value: its header, or "Column D".
|
|
583
|
+
// A stored variable name holds printable ASCII only (Convex refuses any
|
|
584
|
+
// other field name): accents drop ("Café" → "Cafe"), a header with nothing
|
|
585
|
+
// left ("職位") falls back to its letter, and names are unique ("Interest",
|
|
586
|
+
// "Interest 2"). The same rule as the app's `columnVariableNames`.
|
|
587
|
+
export function columnVariableNames(table: RecipientTable): string[] {
|
|
588
|
+
const used = new Set<string>();
|
|
589
|
+
return table.columnLabels.map((_, col) => {
|
|
590
|
+
const base =
|
|
591
|
+
(table.header[col] ?? "")
|
|
592
|
+
.normalize("NFKD")
|
|
593
|
+
.replace(/[̀-ͯ]/g, "")
|
|
594
|
+
.replace(/[{}|]/g, " ")
|
|
595
|
+
.replace(/[^\x20-\x7e]/g, " ")
|
|
596
|
+
.replace(/\s+/g, " ")
|
|
597
|
+
.trim()
|
|
598
|
+
.slice(0, 100)
|
|
599
|
+
.trim() || `Column ${columnLetter(col)}`;
|
|
600
|
+
let name = base;
|
|
601
|
+
for (let n = 2; used.has(name.toLowerCase()); n += 1) name = `${base} ${n}`;
|
|
602
|
+
used.add(name.toLowerCase());
|
|
603
|
+
return name;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
534
607
|
function findNameColumns(header: string[]): {
|
|
535
608
|
first?: number;
|
|
536
609
|
last?: number;
|
|
@@ -600,12 +673,17 @@ export function parseRecipientTable(
|
|
|
600
673
|
csvText: string,
|
|
601
674
|
delimiter: CsvDelimiter = ",",
|
|
602
675
|
): RecipientTable {
|
|
603
|
-
const rows =
|
|
676
|
+
const { rows, error } = scanCsv(csvText, delimiter);
|
|
604
677
|
const first = rows[0] ?? [];
|
|
605
678
|
const hasHeader = rows.length > 0 && firstRowIsHeader(first);
|
|
606
679
|
const header = hasHeader ? first.map((cell) => cell.trim()) : [];
|
|
607
680
|
const dataRows = hasHeader ? rows.slice(1) : rows;
|
|
608
|
-
const width =
|
|
681
|
+
const width = widestRow(dataRows, header.length);
|
|
682
|
+
const raggedRows = hasHeader
|
|
683
|
+
? dataRows.filter((row) =>
|
|
684
|
+
row.slice(header.length).some((cell) => cell.trim()),
|
|
685
|
+
).length
|
|
686
|
+
: 0;
|
|
609
687
|
const columnLabels = Array.from(
|
|
610
688
|
{ length: width },
|
|
611
689
|
(_, col) => header[col] || columnLetter(col),
|
|
@@ -617,25 +695,44 @@ export function parseRecipientTable(
|
|
|
617
695
|
.find((cell) => cell.length > 0) ?? "";
|
|
618
696
|
return sample.length > 40 ? `${sample.slice(0, 39)}…` : sample;
|
|
619
697
|
});
|
|
620
|
-
return {
|
|
698
|
+
return {
|
|
699
|
+
header,
|
|
700
|
+
rows: dataRows,
|
|
701
|
+
hasHeader,
|
|
702
|
+
columnLabels,
|
|
703
|
+
columnSamples,
|
|
704
|
+
...(error ? { error } : {}),
|
|
705
|
+
...(raggedRows ? { raggedRows } : {}),
|
|
706
|
+
};
|
|
621
707
|
}
|
|
622
708
|
|
|
709
|
+
const EMAIL_HEADER_RE =
|
|
710
|
+
/^((work|business|primary|professional|personal)[\s_-]*)?e-?mail([\s_-]*address)?$/i;
|
|
711
|
+
const LINKEDIN_HEADER_RE =
|
|
712
|
+
/^(linkedin([\s_-]*(profile|url|link|profile[\s_-]*(url|link)))?|(linkedin[\s_-]*)?profile[\s_-]*(url|link))$/i;
|
|
713
|
+
|
|
714
|
+
// Best first: a column whose header names the channel ("Email") before one
|
|
715
|
+
// that only holds more of it ("Backup contact"), then most-filled, leftmost.
|
|
623
716
|
export function detectRecipientColumns(
|
|
624
717
|
table: RecipientTable,
|
|
625
718
|
): DetectedRecipientColumns {
|
|
626
719
|
const stats = columnStats(table.rows);
|
|
627
|
-
const rank = (hits: (stat: ColumnStats) => number) =>
|
|
720
|
+
const rank = (hits: (stat: ColumnStats) => number, header: RegExp) =>
|
|
628
721
|
stats
|
|
629
|
-
.map((stat, col) => ({
|
|
722
|
+
.map((stat, col) => ({
|
|
723
|
+
col,
|
|
724
|
+
hits: hits(stat),
|
|
725
|
+
named: header.test(table.header[col]?.trim() ?? "") ? 1 : 0,
|
|
726
|
+
}))
|
|
630
727
|
.filter((entry) => entry.hits > 0)
|
|
631
|
-
.sort((a, b) => b.hits - a.hits || a.col - b.col)
|
|
728
|
+
.sort((a, b) => b.named - a.named || b.hits - a.hits || a.col - b.col)
|
|
632
729
|
.map((entry) => entry.col);
|
|
633
730
|
const names = table.hasHeader ? findNameColumns(table.header) : {};
|
|
634
731
|
return {
|
|
635
732
|
firstName: names.first,
|
|
636
733
|
lastName: names.last,
|
|
637
|
-
email: rank(emailHitsOf),
|
|
638
|
-
linkedin: rank(linkedinHitsOf),
|
|
734
|
+
email: rank(emailHitsOf, EMAIL_HEADER_RE),
|
|
735
|
+
linkedin: rank(linkedinHitsOf, LINKEDIN_HEADER_RE),
|
|
639
736
|
};
|
|
640
737
|
}
|
|
641
738
|
|
|
@@ -668,10 +765,12 @@ export function recipientsFromTable(
|
|
|
668
765
|
hasHeader: table.hasHeader,
|
|
669
766
|
error,
|
|
670
767
|
});
|
|
768
|
+
if (table.error) return empty(table.error);
|
|
671
769
|
if (table.rows.length === 0 && !table.hasHeader) {
|
|
672
770
|
return empty("This file is empty.");
|
|
673
771
|
}
|
|
674
772
|
const stats = columnStats(table.rows);
|
|
773
|
+
const columnNames = columnVariableNames(table);
|
|
675
774
|
const detected = detectRecipientColumns(table);
|
|
676
775
|
const nameColumns = resolveNameColumns(detected, mapping);
|
|
677
776
|
const preferred = (field: "email" | "linkedin") =>
|
|
@@ -698,7 +797,7 @@ export function recipientsFromTable(
|
|
|
698
797
|
nameColumns.first === col ||
|
|
699
798
|
nameColumns.last === col;
|
|
700
799
|
const recipients: CsvRecipient[] = [];
|
|
701
|
-
const seen = new
|
|
800
|
+
const seen = new Map<string, CsvRecipient>();
|
|
702
801
|
let noChannel = 0;
|
|
703
802
|
let duplicates = 0;
|
|
704
803
|
for (const row of table.rows) {
|
|
@@ -716,31 +815,68 @@ export function recipientsFromTable(
|
|
|
716
815
|
noChannel += 1;
|
|
717
816
|
continue;
|
|
718
817
|
}
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
...(linkedinUrl ? [linkedinKey(linkedinUrl)] : []),
|
|
722
|
-
];
|
|
723
|
-
if (keys.some((key) => seen.has(key))) {
|
|
724
|
-
duplicates += 1;
|
|
725
|
-
continue;
|
|
726
|
-
}
|
|
727
|
-
for (const key of keys) seen.add(key);
|
|
818
|
+
const emailKey = address ? normalizeEmail(address) : undefined;
|
|
819
|
+
const profileKey = linkedinUrl ? linkedinKey(linkedinUrl) : undefined;
|
|
728
820
|
const personalization = table.hasHeader
|
|
729
|
-
? rowPersonalization(row,
|
|
821
|
+
? rowPersonalization(row, columnNames, consumed)
|
|
730
822
|
: undefined;
|
|
731
823
|
const name =
|
|
732
824
|
rowDisplayName(row, nameColumns)?.trim() ||
|
|
733
825
|
extractNameFromNamedEmail(emailRaw) ||
|
|
734
826
|
(linkedinUrl ? derivedNameFromLinkedinUrl(linkedinUrl) : undefined);
|
|
735
|
-
|
|
827
|
+
const matches = new Set(
|
|
828
|
+
[emailKey, profileKey].flatMap((key) => {
|
|
829
|
+
const person = key ? seen.get(key) : undefined;
|
|
830
|
+
return person ? [person] : [];
|
|
831
|
+
}),
|
|
832
|
+
);
|
|
833
|
+
if (matches.size > 0) {
|
|
834
|
+
// The same person again: keep what this row adds (their LinkedIn, a
|
|
835
|
+
// name, a column that was blank), never what the first row said.
|
|
836
|
+
duplicates += 1;
|
|
837
|
+
const [existing] = matches;
|
|
838
|
+
if (matches.size > 1 || !existing) continue;
|
|
839
|
+
if (address && emailKey && !existing.email) {
|
|
840
|
+
existing.email = address;
|
|
841
|
+
seen.set(emailKey, existing);
|
|
842
|
+
}
|
|
843
|
+
if (linkedinUrl && profileKey && !existing.linkedinProfileUrl) {
|
|
844
|
+
existing.linkedinProfileUrl = linkedinUrl;
|
|
845
|
+
seen.set(profileKey, existing);
|
|
846
|
+
}
|
|
847
|
+
if (name && !existing.displayName) existing.displayName = name;
|
|
848
|
+
if (personalization && Object.keys(personalization).length > 0) {
|
|
849
|
+
existing.personalization = {
|
|
850
|
+
...personalization,
|
|
851
|
+
...existing.personalization,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const person: CsvRecipient = {
|
|
736
857
|
...(address ? { email: address } : {}),
|
|
737
858
|
...(linkedinUrl ? { linkedinProfileUrl: linkedinUrl } : {}),
|
|
738
859
|
...(name ? { displayName: name } : {}),
|
|
739
860
|
...(personalization && Object.keys(personalization).length > 0
|
|
740
861
|
? { personalization }
|
|
741
862
|
: {}),
|
|
742
|
-
}
|
|
863
|
+
};
|
|
864
|
+
if (emailKey) seen.set(emailKey, person);
|
|
865
|
+
if (profileKey) seen.set(profileKey, person);
|
|
866
|
+
recipients.push(person);
|
|
743
867
|
}
|
|
868
|
+
// A channel switched off with `--map email=none` reads nothing from its
|
|
869
|
+
// column, so the column is reported ignored, not as that channel.
|
|
870
|
+
const channelColumn = (
|
|
871
|
+
field: "email" | "linkedin",
|
|
872
|
+
index: number,
|
|
873
|
+
hits: (stat: ColumnStats) => number,
|
|
874
|
+
) =>
|
|
875
|
+
preferred(field) === index || isChannelColumn(stats[index], hits)
|
|
876
|
+
? mapping[field] === null
|
|
877
|
+
? ("ignored" as const)
|
|
878
|
+
: field
|
|
879
|
+
: undefined;
|
|
744
880
|
const columns: CsvColumn[] = table.columnLabels.map((label, index) => {
|
|
745
881
|
const role: ColumnRole =
|
|
746
882
|
nameColumns.first === index
|
|
@@ -749,22 +885,16 @@ export function recipientsFromTable(
|
|
|
749
885
|
? "last name"
|
|
750
886
|
: nameColumns.full === index
|
|
751
887
|
? "full name"
|
|
752
|
-
:
|
|
753
|
-
|
|
754
|
-
? "
|
|
755
|
-
: preferred("linkedin") === index ||
|
|
756
|
-
isChannelColumn(stats[index], linkedinHitsOf)
|
|
757
|
-
? "linkedin"
|
|
758
|
-
: table.hasHeader && table.header[index]?.trim()
|
|
759
|
-
? "merge value"
|
|
760
|
-
: "ignored";
|
|
888
|
+
: (channelColumn("email", index, emailHitsOf) ??
|
|
889
|
+
channelColumn("linkedin", index, linkedinHitsOf) ??
|
|
890
|
+
(table.hasHeader ? "merge value" : "ignored"));
|
|
761
891
|
return {
|
|
762
892
|
index,
|
|
763
893
|
label,
|
|
764
894
|
sample: table.columnSamples[index] ?? "",
|
|
765
895
|
role,
|
|
766
|
-
...(role === "merge value" || role === "full name"
|
|
767
|
-
? { mergeKey: `crm:${
|
|
896
|
+
...((role === "merge value" || role === "full name") && table.hasHeader
|
|
897
|
+
? { mergeKey: `crm:${columnNames[index]}` }
|
|
768
898
|
: {}),
|
|
769
899
|
};
|
|
770
900
|
});
|
|
@@ -1519,6 +1519,12 @@ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
|
1519
1519
|
export type SignalBuyerBody = {
|
|
1520
1520
|
companyDomain: string;
|
|
1521
1521
|
titles?: string[];
|
|
1522
|
+
/** Where the person is; the gateway drops people placed elsewhere. */
|
|
1523
|
+
locations?: string[];
|
|
1524
|
+
/** The company's industries, checked against the person's job. */
|
|
1525
|
+
industries?: string[];
|
|
1526
|
+
/** Kinds of company (Public, Nonprofit, …), checked on the company. */
|
|
1527
|
+
companyTypes?: string[];
|
|
1522
1528
|
withEmail?: boolean;
|
|
1523
1529
|
limit?: number;
|
|
1524
1530
|
};
|
|
@@ -1556,6 +1562,9 @@ export async function findSignalBuyer(
|
|
|
1556
1562
|
companyDomain: body.companyDomain,
|
|
1557
1563
|
};
|
|
1558
1564
|
if (body.titles?.length) payload.titles = body.titles;
|
|
1565
|
+
if (body.locations?.length) payload.locations = body.locations;
|
|
1566
|
+
if (body.industries?.length) payload.industries = body.industries;
|
|
1567
|
+
if (body.companyTypes?.length) payload.companyTypes = body.companyTypes;
|
|
1559
1568
|
if (body.withEmail !== undefined) payload.withEmail = body.withEmail;
|
|
1560
1569
|
if (body.limit !== undefined) payload.limit = body.limit;
|
|
1561
1570
|
return (await callGatewayJson("/v1/enrichment/signals/buyer", {
|