fullstackgtm 0.40.0 → 0.42.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/CHANGELOG.md +57 -0
- package/README.md +30 -1
- package/dist/cli.js +615 -6
- package/dist/connector.js +34 -0
- package/dist/connectors/atsBoards.d.ts +60 -0
- package/dist/connectors/atsBoards.js +179 -0
- package/dist/connectors/hubspot.js +124 -0
- package/dist/connectors/prospectSources.d.ts +7 -5
- package/dist/connectors/prospectSources.js +82 -47
- package/dist/connectors/salesforce.js +112 -0
- package/dist/draft.d.ts +182 -0
- package/dist/draft.js +333 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/judge.d.ts +209 -0
- package/dist/judge.js +490 -0
- package/dist/judgeEval.d.ts +141 -0
- package/dist/judgeEval.js +331 -0
- package/dist/llm.d.ts +13 -0
- package/dist/llm.js +18 -2
- package/dist/schedule.js +11 -1
- package/dist/signals.d.ts +197 -0
- package/dist/signals.js +515 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +8 -1
- package/package.json +1 -1
- package/src/cli.ts +696 -7
- package/src/connector.ts +37 -0
- package/src/connectors/atsBoards.ts +242 -0
- package/src/connectors/hubspot.ts +125 -0
- package/src/connectors/prospectSources.ts +94 -43
- package/src/connectors/salesforce.ts +113 -0
- package/src/draft.ts +463 -0
- package/src/index.ts +94 -0
- package/src/judge.ts +661 -0
- package/src/judgeEval.ts +459 -0
- package/src/llm.ts +31 -2
- package/src/schedule.ts +11 -1
- package/src/signals.ts +685 -0
- package/src/types.ts +11 -0
package/dist/connector.js
CHANGED
|
@@ -227,7 +227,41 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
// Bulk fast-path: route independent, approved create_record CONTACT ops
|
|
231
|
+
// through the connector's batch API (batched resolve-first read + batched
|
|
232
|
+
// writes) instead of one round-trip per record. Only ops that are safe to
|
|
233
|
+
// batch are eligible — every other op (grouped, needs an override, carries a
|
|
234
|
+
// company association, conflicted, or any non-create op) stays on the serial
|
|
235
|
+
// path below, so the safety contract is unchanged. Results are merged in by
|
|
236
|
+
// id; the serial loop short-circuits anything already resolved here.
|
|
237
|
+
const batched = new Map();
|
|
238
|
+
if (connector.applyCreateContactsBatch && !guardFailure) {
|
|
239
|
+
const eligible = plan.operations.filter((operation) => approved.has(operation.id) &&
|
|
240
|
+
operation.operation === "create_record" &&
|
|
241
|
+
operation.objectType === "contact" &&
|
|
242
|
+
options.valueOverrides?.[operation.id] === undefined &&
|
|
243
|
+
!requiresHumanInput(operation.afterValue) &&
|
|
244
|
+
!operation.groupId &&
|
|
245
|
+
!operation.afterValue?.associateCompanyName &&
|
|
246
|
+
!conflicts.has(operation.id) &&
|
|
247
|
+
!irreversibleStale.has(operation.id) &&
|
|
248
|
+
!(staleByFilter && staleByFilter.has(operation.objectId)));
|
|
249
|
+
// Only worth the batch machinery for more than a couple of creates.
|
|
250
|
+
if (eligible.length > 2) {
|
|
251
|
+
const batchResults = await connector.applyCreateContactsBatch(eligible);
|
|
252
|
+
for (const result of batchResults)
|
|
253
|
+
batched.set(result.operationId, result);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
230
256
|
for (const operation of plan.operations) {
|
|
257
|
+
const batchedResult = batched.get(operation.id);
|
|
258
|
+
if (batchedResult) {
|
|
259
|
+
results.push(batchedResult);
|
|
260
|
+
attempted += 1;
|
|
261
|
+
if (batchedResult.status === "applied")
|
|
262
|
+
applied += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
231
265
|
if (!approved.has(operation.id)) {
|
|
232
266
|
results.push({
|
|
233
267
|
operationId: operation.id,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ATS job-board adapters for the `signals` layer — the free, no-auth example of
|
|
3
|
+
* "watch an account for hiring motion." Each public board JSON endpoint turns a
|
|
4
|
+
* company's board token into a list of open roles; the signals layer maps each
|
|
5
|
+
* role to a `job` signal whose `quote` is the role title plus a JD snippet that
|
|
6
|
+
* VERBATIM contains a configured keyword (so the downstream judge/drafter can
|
|
7
|
+
* gate a why-now on something a human can verify).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the `prospectSources.ts` raw-fetch idiom exactly: zero runtime deps
|
|
10
|
+
* (global fetch only, injectable for tests), a base-url override per provider,
|
|
11
|
+
* and a per-request `try { } catch { continue }` so one provider's outage never
|
|
12
|
+
* sinks a multi-source fetch. No SDK, no key.
|
|
13
|
+
*
|
|
14
|
+
* Governance: read-only. These adapters fetch public board JSON and return open
|
|
15
|
+
* roles. They write nothing — the signals layer (and only with `--save`) is the
|
|
16
|
+
* one that persists, and it never touches the CRM.
|
|
17
|
+
*/
|
|
18
|
+
export type AtsBoardSource = "greenhouse" | "lever" | "ashby";
|
|
19
|
+
/** One open role, normalized across the three providers. */
|
|
20
|
+
export type AtsJob = {
|
|
21
|
+
/** Role title, verbatim from the board. */
|
|
22
|
+
title: string;
|
|
23
|
+
/**
|
|
24
|
+
* A short JD snippet. When `keywords` are supplied, this is a window around
|
|
25
|
+
* the first matching keyword (kept verbatim so the keyword survives into the
|
|
26
|
+
* evidence quote); otherwise it is the leading slice of the description.
|
|
27
|
+
*/
|
|
28
|
+
jdSnippet: string;
|
|
29
|
+
/** Canonical posting URL (the signal's sourceUrl). */
|
|
30
|
+
url: string;
|
|
31
|
+
/**
|
|
32
|
+
* Stable per-role identity within a board (provider id when present, else the
|
|
33
|
+
* title). Used by the reposted-role heuristic to tell "same role, gone, back"
|
|
34
|
+
* from "two roles that happen to share a title".
|
|
35
|
+
*/
|
|
36
|
+
firstSeenKey: string;
|
|
37
|
+
};
|
|
38
|
+
type FetchImpl = typeof fetch;
|
|
39
|
+
/**
|
|
40
|
+
* Fetch open roles for one board. Network failures (offline, non-2xx, malformed
|
|
41
|
+
* JSON) yield `[]` rather than throwing, matching the per-source resilience of
|
|
42
|
+
* the prospect sources — a single board's outage must not abort a watchlist run.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fetchAtsJobs(opts: {
|
|
45
|
+
source: AtsBoardSource;
|
|
46
|
+
boardToken: string;
|
|
47
|
+
accountDomain: string;
|
|
48
|
+
keywords?: string[];
|
|
49
|
+
fetchImpl?: FetchImpl;
|
|
50
|
+
apiBaseUrl?: string;
|
|
51
|
+
}): Promise<AtsJob[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Pick a verbatim JD snippet. If a keyword matches the description (case-
|
|
54
|
+
* insensitive), return a window centered on the first match — kept verbatim so
|
|
55
|
+
* the keyword text survives into the signal quote and the evidence gate holds.
|
|
56
|
+
* With no keywords (or no match), fall back to the leading slice. Returns "" for
|
|
57
|
+
* an empty description so the caller can decide whether the role still qualifies.
|
|
58
|
+
*/
|
|
59
|
+
export declare function snippetFor(description: string, keywords: string[]): string;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ATS job-board adapters for the `signals` layer — the free, no-auth example of
|
|
3
|
+
* "watch an account for hiring motion." Each public board JSON endpoint turns a
|
|
4
|
+
* company's board token into a list of open roles; the signals layer maps each
|
|
5
|
+
* role to a `job` signal whose `quote` is the role title plus a JD snippet that
|
|
6
|
+
* VERBATIM contains a configured keyword (so the downstream judge/drafter can
|
|
7
|
+
* gate a why-now on something a human can verify).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the `prospectSources.ts` raw-fetch idiom exactly: zero runtime deps
|
|
10
|
+
* (global fetch only, injectable for tests), a base-url override per provider,
|
|
11
|
+
* and a per-request `try { } catch { continue }` so one provider's outage never
|
|
12
|
+
* sinks a multi-source fetch. No SDK, no key.
|
|
13
|
+
*
|
|
14
|
+
* Governance: read-only. These adapters fetch public board JSON and return open
|
|
15
|
+
* roles. They write nothing — the signals layer (and only with `--save`) is the
|
|
16
|
+
* one that persists, and it never touches the CRM.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_BASE_URLS = {
|
|
19
|
+
greenhouse: "https://boards-api.greenhouse.io",
|
|
20
|
+
lever: "https://api.lever.co",
|
|
21
|
+
ashby: "https://api.ashbyhq.com",
|
|
22
|
+
};
|
|
23
|
+
/** JD snippet length — long enough to carry a verbatim keyword window. */
|
|
24
|
+
const SNIPPET_LEN = 240;
|
|
25
|
+
/**
|
|
26
|
+
* Fetch open roles for one board. Network failures (offline, non-2xx, malformed
|
|
27
|
+
* JSON) yield `[]` rather than throwing, matching the per-source resilience of
|
|
28
|
+
* the prospect sources — a single board's outage must not abort a watchlist run.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchAtsJobs(opts) {
|
|
31
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
32
|
+
const base = (opts.apiBaseUrl ?? DEFAULT_BASE_URLS[opts.source]).replace(/\/$/, "");
|
|
33
|
+
const token = opts.boardToken.trim();
|
|
34
|
+
if (!token)
|
|
35
|
+
return [];
|
|
36
|
+
const keywords = opts.keywords ?? [];
|
|
37
|
+
try {
|
|
38
|
+
if (opts.source === "greenhouse") {
|
|
39
|
+
return await fetchGreenhouse(fetchImpl, base, token, keywords);
|
|
40
|
+
}
|
|
41
|
+
if (opts.source === "lever") {
|
|
42
|
+
return await fetchLever(fetchImpl, base, token, keywords);
|
|
43
|
+
}
|
|
44
|
+
return await fetchAshby(fetchImpl, base, token, keywords);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Offline / 5xx / malformed payload: an empty board, never a crash.
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Greenhouse — GET /v1/boards/<token>/jobs?content=true
|
|
53
|
+
async function fetchGreenhouse(fetchImpl, base, token, keywords) {
|
|
54
|
+
const response = await fetchImpl(`${base}/v1/boards/${encodeURIComponent(token)}/jobs?content=true`, {
|
|
55
|
+
headers: { Accept: "application/json" },
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok)
|
|
58
|
+
return [];
|
|
59
|
+
const data = (await response.json());
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const job of data.jobs ?? []) {
|
|
62
|
+
const title = strOrEmpty(job.title);
|
|
63
|
+
if (!title)
|
|
64
|
+
continue;
|
|
65
|
+
const description = decodeHtmlish(strOrEmpty(job.content));
|
|
66
|
+
out.push({
|
|
67
|
+
title,
|
|
68
|
+
jdSnippet: snippetFor(description, keywords),
|
|
69
|
+
url: strOrEmpty(job.absolute_url),
|
|
70
|
+
firstSeenKey: job.id != null ? String(job.id) : title,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Lever — GET /v0/postings/<token>?mode=json (returns a bare array)
|
|
77
|
+
async function fetchLever(fetchImpl, base, token, keywords) {
|
|
78
|
+
const response = await fetchImpl(`${base}/v0/postings/${encodeURIComponent(token)}?mode=json`, {
|
|
79
|
+
headers: { Accept: "application/json" },
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
return [];
|
|
83
|
+
const data = (await response.json());
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const posting of Array.isArray(data) ? data : []) {
|
|
86
|
+
const title = strOrEmpty(posting.text);
|
|
87
|
+
if (!title)
|
|
88
|
+
continue;
|
|
89
|
+
const description = strOrEmpty(posting.descriptionPlain) || decodeHtmlish(strOrEmpty(posting.description));
|
|
90
|
+
out.push({
|
|
91
|
+
title,
|
|
92
|
+
jdSnippet: snippetFor(description, keywords),
|
|
93
|
+
url: strOrEmpty(posting.hostedUrl),
|
|
94
|
+
firstSeenKey: strOrEmpty(posting.id) || title,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Ashby — POST /posting-api/job-board/<token> with { jobBoardName }
|
|
101
|
+
async function fetchAshby(fetchImpl, base, token, keywords) {
|
|
102
|
+
const response = await fetchImpl(`${base}/posting-api/job-board/${encodeURIComponent(token)}`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
105
|
+
body: JSON.stringify({ jobBoardName: token, includeCompensation: false }),
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
return [];
|
|
109
|
+
const data = (await response.json());
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const job of data.jobs ?? []) {
|
|
112
|
+
const title = strOrEmpty(job.title);
|
|
113
|
+
if (!title)
|
|
114
|
+
continue;
|
|
115
|
+
const description = strOrEmpty(job.descriptionPlain) || decodeHtmlish(strOrEmpty(job.descriptionHtml));
|
|
116
|
+
out.push({
|
|
117
|
+
title,
|
|
118
|
+
jdSnippet: snippetFor(description, keywords),
|
|
119
|
+
url: strOrEmpty(job.jobUrl) || strOrEmpty(job.applyUrl),
|
|
120
|
+
firstSeenKey: strOrEmpty(job.id) || title,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Snippet selection: a verbatim window around the first matching keyword.
|
|
127
|
+
/**
|
|
128
|
+
* Pick a verbatim JD snippet. If a keyword matches the description (case-
|
|
129
|
+
* insensitive), return a window centered on the first match — kept verbatim so
|
|
130
|
+
* the keyword text survives into the signal quote and the evidence gate holds.
|
|
131
|
+
* With no keywords (or no match), fall back to the leading slice. Returns "" for
|
|
132
|
+
* an empty description so the caller can decide whether the role still qualifies.
|
|
133
|
+
*/
|
|
134
|
+
export function snippetFor(description, keywords) {
|
|
135
|
+
const text = description.trim();
|
|
136
|
+
if (!text)
|
|
137
|
+
return "";
|
|
138
|
+
for (const keyword of keywords) {
|
|
139
|
+
const needle = keyword.trim();
|
|
140
|
+
if (!needle)
|
|
141
|
+
continue;
|
|
142
|
+
const idx = text.toLowerCase().indexOf(needle.toLowerCase());
|
|
143
|
+
if (idx === -1)
|
|
144
|
+
continue;
|
|
145
|
+
const pad = Math.max(0, Math.floor((SNIPPET_LEN - needle.length) / 2));
|
|
146
|
+
let start = Math.max(0, idx - pad);
|
|
147
|
+
let end = Math.min(text.length, idx + needle.length + pad);
|
|
148
|
+
// Snap to word boundaries so the window doesn't start/end mid-word, but
|
|
149
|
+
// never past the keyword itself (the verbatim span must stay intact).
|
|
150
|
+
while (start > 0 && /\S/.test(text[start - 1]) && start < idx)
|
|
151
|
+
start += 1;
|
|
152
|
+
while (end < text.length && /\S/.test(text[end]) && end > idx + needle.length)
|
|
153
|
+
end -= 1;
|
|
154
|
+
return text.slice(start, end).trim();
|
|
155
|
+
}
|
|
156
|
+
return text.slice(0, SNIPPET_LEN).trim();
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Minimal HTML-ish cleanup for board `content`/`description` HTML: drop tags and
|
|
160
|
+
* decode the handful of entities Greenhouse/Ashby emit. Dependency-free; good
|
|
161
|
+
* enough to surface a readable, keyword-matchable snippet (the quote does not
|
|
162
|
+
* need to be byte-perfect HTML, only verbatim text the keyword appears in).
|
|
163
|
+
*/
|
|
164
|
+
function decodeHtmlish(raw) {
|
|
165
|
+
return raw
|
|
166
|
+
.replace(/<\s*br\s*\/?\s*>/gi, " ")
|
|
167
|
+
.replace(/<\/?[^>]+>/g, " ")
|
|
168
|
+
.replace(/&/g, "&")
|
|
169
|
+
.replace(/</g, "<")
|
|
170
|
+
.replace(/>/g, ">")
|
|
171
|
+
.replace(/"/g, '"')
|
|
172
|
+
.replace(/'|'/g, "'")
|
|
173
|
+
.replace(/ /g, " ")
|
|
174
|
+
.replace(/\s+/g, " ")
|
|
175
|
+
.trim();
|
|
176
|
+
}
|
|
177
|
+
function strOrEmpty(value) {
|
|
178
|
+
return typeof value === "string" ? value.trim() : "";
|
|
179
|
+
}
|
|
@@ -555,6 +555,129 @@ export function createHubspotConnector(options) {
|
|
|
555
555
|
providerData: { id: contactId, created: true },
|
|
556
556
|
};
|
|
557
557
|
}
|
|
558
|
+
/** Bulk resolve-first lookup: which match values already exist (value→id). */
|
|
559
|
+
async function searchContactsByValues(property, values) {
|
|
560
|
+
const found = new Map();
|
|
561
|
+
for (let i = 0; i < values.length; i += 100) {
|
|
562
|
+
const chunk = values.slice(i, i + 100);
|
|
563
|
+
const data = await request(`/crm/v3/objects/contacts/search`, {
|
|
564
|
+
method: "POST",
|
|
565
|
+
body: JSON.stringify({
|
|
566
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "IN", values: chunk }] }],
|
|
567
|
+
properties: [property],
|
|
568
|
+
limit: 100,
|
|
569
|
+
}),
|
|
570
|
+
});
|
|
571
|
+
for (const rec of (data?.results ?? [])) {
|
|
572
|
+
const v = rec?.properties?.[property];
|
|
573
|
+
if (typeof v === "string" && rec.id)
|
|
574
|
+
found.set(v.toLowerCase(), String(rec.id));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return found;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Batch create_record for contacts: bulk resolve-first (search IN), then
|
|
581
|
+
* bulk-create the genuine misses (batch/create, 100/call) — two API calls per
|
|
582
|
+
* ~100 leads instead of two per lead. On a batch-create rejection (e.g. an
|
|
583
|
+
* email collision HubSpot 409s the whole batch over), it falls back to
|
|
584
|
+
* per-record createRecord for that chunk, so one bad row never sinks the rest
|
|
585
|
+
* and resolve-first is preserved. Returns one result per input op.
|
|
586
|
+
*/
|
|
587
|
+
async function applyCreateContactsBatch(operations) {
|
|
588
|
+
const byId = new Map();
|
|
589
|
+
// Group by the HubSpot search property (matchKey is usually uniform across a
|
|
590
|
+
// plan, but a mixed plan stays correct this way).
|
|
591
|
+
const groups = new Map();
|
|
592
|
+
for (const op of operations) {
|
|
593
|
+
const matchKey = op.afterValue?.matchKey || "email";
|
|
594
|
+
const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
|
|
595
|
+
let arr = groups.get(searchProperty);
|
|
596
|
+
if (!arr)
|
|
597
|
+
groups.set(searchProperty, (arr = []));
|
|
598
|
+
arr.push(op);
|
|
599
|
+
}
|
|
600
|
+
for (const [searchProperty, ops] of groups) {
|
|
601
|
+
const wanted = ops
|
|
602
|
+
.map((op) => String(op.afterValue.matchValue ?? "").trim())
|
|
603
|
+
.filter(Boolean);
|
|
604
|
+
const existing = await searchContactsByValues(searchProperty, [...new Set(wanted.map((v) => v.toLowerCase()))]);
|
|
605
|
+
const toCreate = [];
|
|
606
|
+
const seenInBatch = new Set();
|
|
607
|
+
for (const op of ops) {
|
|
608
|
+
const payload = op.afterValue;
|
|
609
|
+
const matchKey = payload.matchKey || "email";
|
|
610
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
611
|
+
if (!matchValue) {
|
|
612
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const lower = matchValue.toLowerCase();
|
|
616
|
+
const dedupeKey = `${matchKey}:${lower}`;
|
|
617
|
+
const already = createdContactsByMatch.get(dedupeKey);
|
|
618
|
+
if (already) {
|
|
619
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: already, existing: true } });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const existingId = existing.get(lower);
|
|
623
|
+
if (existingId) {
|
|
624
|
+
createdContactsByMatch.set(dedupeKey, existingId);
|
|
625
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existingId}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } });
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (seenInBatch.has(lower)) {
|
|
629
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
seenInBatch.add(lower);
|
|
633
|
+
toCreate.push(op);
|
|
634
|
+
}
|
|
635
|
+
for (let i = 0; i < toCreate.length; i += 100) {
|
|
636
|
+
const chunk = toCreate.slice(i, i + 100);
|
|
637
|
+
const inputs = chunk.map((op) => {
|
|
638
|
+
const payload = op.afterValue;
|
|
639
|
+
const properties = { ...payload.properties };
|
|
640
|
+
if (payload.ownerId && !properties.hubspot_owner_id)
|
|
641
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
642
|
+
properties.hs_object_source_detail_2 = `fullstackgtm acquire (${op.id})`;
|
|
643
|
+
return { properties };
|
|
644
|
+
});
|
|
645
|
+
try {
|
|
646
|
+
const data = await request(`/crm/v3/objects/contacts/batch/create`, {
|
|
647
|
+
method: "POST",
|
|
648
|
+
body: JSON.stringify({ inputs }),
|
|
649
|
+
});
|
|
650
|
+
const created = (data?.results ?? []);
|
|
651
|
+
const idByValue = new Map();
|
|
652
|
+
for (const rec of created) {
|
|
653
|
+
const v = rec?.properties?.[searchProperty];
|
|
654
|
+
if (typeof v === "string" && rec.id)
|
|
655
|
+
idByValue.set(v.toLowerCase(), String(rec.id));
|
|
656
|
+
}
|
|
657
|
+
for (const op of chunk) {
|
|
658
|
+
const payload = op.afterValue;
|
|
659
|
+
const matchKey = payload.matchKey || "email";
|
|
660
|
+
const matchValue = String(payload.matchValue).trim();
|
|
661
|
+
const id = idByValue.get(matchValue.toLowerCase());
|
|
662
|
+
if (id) {
|
|
663
|
+
createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, id);
|
|
664
|
+
byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${id}).`, providerData: { id, created: true } });
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
// Result didn't echo this value — resolve it per-record to be safe.
|
|
668
|
+
byId.set(op.id, await createRecord(op));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
// Whole-chunk rejection (e.g. a collision). Isolate per record.
|
|
674
|
+
for (const op of chunk)
|
|
675
|
+
byId.set(op.id, await createRecord(op));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return operations.map((op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." });
|
|
680
|
+
}
|
|
558
681
|
async function createTask(operation) {
|
|
559
682
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
560
683
|
if (associationTypeId === undefined) {
|
|
@@ -801,6 +924,7 @@ export function createHubspotConnector(options) {
|
|
|
801
924
|
fetchSnapshot,
|
|
802
925
|
fetchChanges,
|
|
803
926
|
applyOperation,
|
|
927
|
+
applyCreateContactsBatch,
|
|
804
928
|
readField,
|
|
805
929
|
};
|
|
806
930
|
}
|
|
@@ -56,11 +56,6 @@ export declare function fetchPipe0CrustdataProspects(opts: {
|
|
|
56
56
|
apiBaseUrl?: string;
|
|
57
57
|
fetchImpl?: FetchImpl;
|
|
58
58
|
}): Promise<Prospect[]>;
|
|
59
|
-
/**
|
|
60
|
-
* Resolve real work emails for people via pipe0's waterfall. Input rows need a
|
|
61
|
-
* full name + a company domain (or name). Returns the prospects with `email`
|
|
62
|
-
* filled where the waterfall found one; rows with no hit are returned unchanged.
|
|
63
|
-
*/
|
|
64
59
|
export declare function pipe0ResolveWorkEmails(opts: {
|
|
65
60
|
apiKey: string;
|
|
66
61
|
prospects: Prospect[];
|
|
@@ -70,6 +65,11 @@ export declare function pipe0ResolveWorkEmails(opts: {
|
|
|
70
65
|
* batch rate-limit (a throttled chunk returns work_email status "failed"
|
|
71
66
|
* for every row, so one big batch is all-or-nothing). Default 3. */
|
|
72
67
|
chunkSize?: number;
|
|
68
|
+
/** Chunks resolved in parallel, bounded to respect pipe0's rate limit. Default
|
|
69
|
+
* 3 — paired with pipe0Post's backoff this cuts wall-clock from O(n) serial
|
|
70
|
+
* calls without throttling away coverage (higher defaults tanked the hit-rate
|
|
71
|
+
* at scale). */
|
|
72
|
+
concurrency?: number;
|
|
73
73
|
}): Promise<Prospect[]>;
|
|
74
74
|
/** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
|
|
75
75
|
export declare function hostFromUrl(url: string | undefined): string | undefined;
|
|
@@ -87,6 +87,8 @@ export declare function pipe0ResolveCompanyDomains(opts: {
|
|
|
87
87
|
apiBaseUrl?: string;
|
|
88
88
|
fetchImpl?: FetchImpl;
|
|
89
89
|
chunkSize?: number;
|
|
90
|
+
/** Chunks resolved in parallel (bounded). Default 3. */
|
|
91
|
+
concurrency?: number;
|
|
90
92
|
}): Promise<Prospect[]>;
|
|
91
93
|
/**
|
|
92
94
|
* Stable identity keys for a prospect (prefixed by kind). A match on ANY key
|
|
@@ -98,44 +98,83 @@ function normalizeLinkedin(value) {
|
|
|
98
98
|
* full name + a company domain (or name). Returns the prospects with `email`
|
|
99
99
|
* filled where the waterfall found one; rows with no hit are returned unchanged.
|
|
100
100
|
*/
|
|
101
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
102
|
+
/** Run `count` async tasks with at most `limit` in flight. Order-independent. */
|
|
103
|
+
async function runWithConcurrency(count, limit, worker) {
|
|
104
|
+
const out = new Array(count);
|
|
105
|
+
let next = 0;
|
|
106
|
+
const runners = Array.from({ length: Math.max(1, Math.min(limit, count || 1)) }, async () => {
|
|
107
|
+
for (let i = next++; i < count; i = next++)
|
|
108
|
+
out[i] = await worker(i);
|
|
109
|
+
});
|
|
110
|
+
await Promise.all(runners);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* POST a pipe0 sync run with exponential backoff on transient failure
|
|
115
|
+
* (throttle/5xx/network). pipe0's waterfall throttles under burst — a 429'd
|
|
116
|
+
* chunk returns all-failed — so concurrency must be paired with real backoff,
|
|
117
|
+
* not a single retry, or coverage collapses at scale (a parallel run without
|
|
118
|
+
* this dropped the work-email hit-rate from ~79% to ~17%). Retries 500ms →
|
|
119
|
+
* 1s → 2s → 4s (capped) so a throttled chunk lands on a later attempt instead
|
|
120
|
+
* of being lost.
|
|
121
|
+
*/
|
|
122
|
+
async function pipe0Post(fetchImpl, base, apiKey, payload, maxAttempts = 5) {
|
|
123
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify(payload),
|
|
129
|
+
});
|
|
130
|
+
if (response.ok)
|
|
131
|
+
return (await response.json());
|
|
132
|
+
// non-ok (429/5xx) — fall through to backoff + retry
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// network error — fall through to backoff + retry
|
|
136
|
+
}
|
|
137
|
+
if (attempt < maxAttempts - 1)
|
|
138
|
+
await sleep(Math.min(8000, 500 * 2 ** attempt));
|
|
139
|
+
}
|
|
140
|
+
return undefined; // still failing after backoff — caller keeps the other chunks
|
|
141
|
+
}
|
|
101
142
|
export async function pipe0ResolveWorkEmails(opts) {
|
|
102
143
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
103
144
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
104
145
|
const chunkSize = Math.max(1, opts.chunkSize ?? 3);
|
|
146
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
105
147
|
// Only rows with the waterfall's required inputs (name + company_domain|name).
|
|
106
148
|
const resolvable = opts.prospects.filter((p) => p.fullName && (p.companyDomain || p.companyName));
|
|
107
149
|
if (resolvable.length === 0)
|
|
108
150
|
return opts.prospects;
|
|
109
|
-
const
|
|
110
|
-
for (let i = 0; i < resolvable.length; i += chunkSize)
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
const chunks = [];
|
|
152
|
+
for (let i = 0; i < resolvable.length; i += chunkSize)
|
|
153
|
+
chunks.push(resolvable.slice(i, i + chunkSize));
|
|
154
|
+
const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
|
|
155
|
+
const input = chunks[ci].map((p) => ({
|
|
113
156
|
name: p.fullName,
|
|
114
157
|
...(p.companyDomain ? { company_domain: p.companyDomain } : { company_name: p.companyName }),
|
|
115
158
|
}));
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
});
|
|
123
|
-
if (!response.ok)
|
|
124
|
-
continue; // throttled/5xx chunk — skip, keep the rest
|
|
125
|
-
body = (await response.json());
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
continue; // network hiccup on one chunk must not sink the whole run
|
|
129
|
-
}
|
|
130
|
-
for (const recordId of body.order ?? []) {
|
|
159
|
+
const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
|
|
160
|
+
pipes: [{ pipe_id: "person:workemail:waterfall@1" }],
|
|
161
|
+
input,
|
|
162
|
+
});
|
|
163
|
+
const entries = [];
|
|
164
|
+
for (const recordId of body?.order ?? []) {
|
|
131
165
|
const fields = body.records?.[recordId]?.fields ?? {};
|
|
132
166
|
const email = fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined;
|
|
133
167
|
if (email) {
|
|
134
168
|
const domain = fieldValue(fields.company_domain) ?? fieldValue(fields.company_name);
|
|
135
|
-
|
|
169
|
+
entries.push([personKey(fieldValue(fields.name), domain), email]);
|
|
136
170
|
}
|
|
137
171
|
}
|
|
138
|
-
|
|
172
|
+
return entries;
|
|
173
|
+
});
|
|
174
|
+
const emailByKey = new Map();
|
|
175
|
+
for (const entries of perChunk)
|
|
176
|
+
for (const [k, v] of entries)
|
|
177
|
+
emailByKey.set(k, v);
|
|
139
178
|
return opts.prospects.map((p) => {
|
|
140
179
|
const email = emailByKey.get(personKey(p.fullName, p.companyDomain ?? p.companyName));
|
|
141
180
|
return email ? { ...p, email } : p;
|
|
@@ -169,6 +208,7 @@ export async function pipe0ResolveCompanyDomains(opts) {
|
|
|
169
208
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
170
209
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
171
210
|
const chunkSize = Math.max(1, opts.chunkSize ?? 5);
|
|
211
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
172
212
|
const uniqueNames = [
|
|
173
213
|
...new Set(opts.prospects
|
|
174
214
|
.filter((p) => !p.companyDomain && p.companyName)
|
|
@@ -177,33 +217,28 @@ export async function pipe0ResolveCompanyDomains(opts) {
|
|
|
177
217
|
];
|
|
178
218
|
if (uniqueNames.length === 0)
|
|
179
219
|
return opts.prospects;
|
|
180
|
-
const
|
|
181
|
-
for (let i = 0; i < uniqueNames.length; i += chunkSize)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
for (const recordId of body.order ?? []) {
|
|
196
|
-
const fields = body.records?.[recordId]?.fields ?? {};
|
|
197
|
-
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
198
|
-
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
199
|
-
if (name && domain)
|
|
200
|
-
domainByName.set(name.trim().toLowerCase(), domain);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
continue;
|
|
220
|
+
const chunks = [];
|
|
221
|
+
for (let i = 0; i < uniqueNames.length; i += chunkSize)
|
|
222
|
+
chunks.push(uniqueNames.slice(i, i + chunkSize));
|
|
223
|
+
const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
|
|
224
|
+
const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
|
|
225
|
+
pipes: [{ pipe_id: "company:identity@1" }],
|
|
226
|
+
input: chunks[ci].map((company_name) => ({ company_name })),
|
|
227
|
+
});
|
|
228
|
+
const entries = [];
|
|
229
|
+
for (const recordId of body?.order ?? []) {
|
|
230
|
+
const fields = body.records?.[recordId]?.fields ?? {};
|
|
231
|
+
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
232
|
+
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
233
|
+
if (name && domain)
|
|
234
|
+
entries.push([name.trim().toLowerCase(), domain]);
|
|
205
235
|
}
|
|
206
|
-
|
|
236
|
+
return entries;
|
|
237
|
+
});
|
|
238
|
+
const domainByName = new Map();
|
|
239
|
+
for (const entries of perChunk)
|
|
240
|
+
for (const [k, v] of entries)
|
|
241
|
+
domainByName.set(k, v);
|
|
207
242
|
return opts.prospects.map((p) => {
|
|
208
243
|
if (p.companyDomain || !p.companyName)
|
|
209
244
|
return p;
|