fullstackgtm 0.39.0 → 0.41.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 +74 -0
- package/README.md +42 -1
- package/dist/cli.js +615 -6
- package/dist/connectors/atsBoards.d.ts +60 -0
- package/dist/connectors/atsBoards.js +179 -0
- package/dist/connectors/prospectSources.d.ts +7 -5
- package/dist/connectors/prospectSources.js +82 -47
- 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/docs/api.md +2 -2
- package/package.json +1 -1
- package/src/cli.ts +696 -7
- package/src/connectors/atsBoards.ts +242 -0
- package/src/connectors/prospectSources.ts +94 -43
- 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
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
|
|
19
|
+
export type AtsBoardSource = "greenhouse" | "lever" | "ashby";
|
|
20
|
+
|
|
21
|
+
/** One open role, normalized across the three providers. */
|
|
22
|
+
export type AtsJob = {
|
|
23
|
+
/** Role title, verbatim from the board. */
|
|
24
|
+
title: string;
|
|
25
|
+
/**
|
|
26
|
+
* A short JD snippet. When `keywords` are supplied, this is a window around
|
|
27
|
+
* the first matching keyword (kept verbatim so the keyword survives into the
|
|
28
|
+
* evidence quote); otherwise it is the leading slice of the description.
|
|
29
|
+
*/
|
|
30
|
+
jdSnippet: string;
|
|
31
|
+
/** Canonical posting URL (the signal's sourceUrl). */
|
|
32
|
+
url: string;
|
|
33
|
+
/**
|
|
34
|
+
* Stable per-role identity within a board (provider id when present, else the
|
|
35
|
+
* title). Used by the reposted-role heuristic to tell "same role, gone, back"
|
|
36
|
+
* from "two roles that happen to share a title".
|
|
37
|
+
*/
|
|
38
|
+
firstSeenKey: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type FetchImpl = typeof fetch;
|
|
42
|
+
|
|
43
|
+
const DEFAULT_BASE_URLS: Record<AtsBoardSource, string> = {
|
|
44
|
+
greenhouse: "https://boards-api.greenhouse.io",
|
|
45
|
+
lever: "https://api.lever.co",
|
|
46
|
+
ashby: "https://api.ashbyhq.com",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** JD snippet length — long enough to carry a verbatim keyword window. */
|
|
50
|
+
const SNIPPET_LEN = 240;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch open roles for one board. Network failures (offline, non-2xx, malformed
|
|
54
|
+
* JSON) yield `[]` rather than throwing, matching the per-source resilience of
|
|
55
|
+
* the prospect sources — a single board's outage must not abort a watchlist run.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchAtsJobs(opts: {
|
|
58
|
+
source: AtsBoardSource;
|
|
59
|
+
boardToken: string;
|
|
60
|
+
accountDomain: string;
|
|
61
|
+
keywords?: string[];
|
|
62
|
+
fetchImpl?: FetchImpl;
|
|
63
|
+
apiBaseUrl?: string;
|
|
64
|
+
}): Promise<AtsJob[]> {
|
|
65
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
66
|
+
const base = (opts.apiBaseUrl ?? DEFAULT_BASE_URLS[opts.source]).replace(/\/$/, "");
|
|
67
|
+
const token = opts.boardToken.trim();
|
|
68
|
+
if (!token) return [];
|
|
69
|
+
const keywords = opts.keywords ?? [];
|
|
70
|
+
try {
|
|
71
|
+
if (opts.source === "greenhouse") {
|
|
72
|
+
return await fetchGreenhouse(fetchImpl, base, token, keywords);
|
|
73
|
+
}
|
|
74
|
+
if (opts.source === "lever") {
|
|
75
|
+
return await fetchLever(fetchImpl, base, token, keywords);
|
|
76
|
+
}
|
|
77
|
+
return await fetchAshby(fetchImpl, base, token, keywords);
|
|
78
|
+
} catch {
|
|
79
|
+
// Offline / 5xx / malformed payload: an empty board, never a crash.
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Greenhouse — GET /v1/boards/<token>/jobs?content=true
|
|
86
|
+
|
|
87
|
+
async function fetchGreenhouse(
|
|
88
|
+
fetchImpl: FetchImpl,
|
|
89
|
+
base: string,
|
|
90
|
+
token: string,
|
|
91
|
+
keywords: string[],
|
|
92
|
+
): Promise<AtsJob[]> {
|
|
93
|
+
const response = await fetchImpl(`${base}/v1/boards/${encodeURIComponent(token)}/jobs?content=true`, {
|
|
94
|
+
headers: { Accept: "application/json" },
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) return [];
|
|
97
|
+
const data = (await response.json()) as {
|
|
98
|
+
jobs?: Array<{ id?: number | string; title?: string; absolute_url?: string; content?: string }>;
|
|
99
|
+
};
|
|
100
|
+
const out: AtsJob[] = [];
|
|
101
|
+
for (const job of data.jobs ?? []) {
|
|
102
|
+
const title = strOrEmpty(job.title);
|
|
103
|
+
if (!title) continue;
|
|
104
|
+
const description = decodeHtmlish(strOrEmpty(job.content));
|
|
105
|
+
out.push({
|
|
106
|
+
title,
|
|
107
|
+
jdSnippet: snippetFor(description, keywords),
|
|
108
|
+
url: strOrEmpty(job.absolute_url),
|
|
109
|
+
firstSeenKey: job.id != null ? String(job.id) : title,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Lever — GET /v0/postings/<token>?mode=json (returns a bare array)
|
|
117
|
+
|
|
118
|
+
async function fetchLever(
|
|
119
|
+
fetchImpl: FetchImpl,
|
|
120
|
+
base: string,
|
|
121
|
+
token: string,
|
|
122
|
+
keywords: string[],
|
|
123
|
+
): Promise<AtsJob[]> {
|
|
124
|
+
const response = await fetchImpl(`${base}/v0/postings/${encodeURIComponent(token)}?mode=json`, {
|
|
125
|
+
headers: { Accept: "application/json" },
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) return [];
|
|
128
|
+
const data = (await response.json()) as Array<{
|
|
129
|
+
id?: string;
|
|
130
|
+
text?: string;
|
|
131
|
+
hostedUrl?: string;
|
|
132
|
+
descriptionPlain?: string;
|
|
133
|
+
description?: string;
|
|
134
|
+
}>;
|
|
135
|
+
const out: AtsJob[] = [];
|
|
136
|
+
for (const posting of Array.isArray(data) ? data : []) {
|
|
137
|
+
const title = strOrEmpty(posting.text);
|
|
138
|
+
if (!title) continue;
|
|
139
|
+
const description = strOrEmpty(posting.descriptionPlain) || decodeHtmlish(strOrEmpty(posting.description));
|
|
140
|
+
out.push({
|
|
141
|
+
title,
|
|
142
|
+
jdSnippet: snippetFor(description, keywords),
|
|
143
|
+
url: strOrEmpty(posting.hostedUrl),
|
|
144
|
+
firstSeenKey: strOrEmpty(posting.id) || title,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Ashby — POST /posting-api/job-board/<token> with { jobBoardName }
|
|
152
|
+
|
|
153
|
+
async function fetchAshby(
|
|
154
|
+
fetchImpl: FetchImpl,
|
|
155
|
+
base: string,
|
|
156
|
+
token: string,
|
|
157
|
+
keywords: string[],
|
|
158
|
+
): Promise<AtsJob[]> {
|
|
159
|
+
const response = await fetchImpl(`${base}/posting-api/job-board/${encodeURIComponent(token)}`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
162
|
+
body: JSON.stringify({ jobBoardName: token, includeCompensation: false }),
|
|
163
|
+
});
|
|
164
|
+
if (!response.ok) return [];
|
|
165
|
+
const data = (await response.json()) as {
|
|
166
|
+
jobs?: Array<{
|
|
167
|
+
id?: string;
|
|
168
|
+
title?: string;
|
|
169
|
+
jobUrl?: string;
|
|
170
|
+
applyUrl?: string;
|
|
171
|
+
descriptionPlain?: string;
|
|
172
|
+
descriptionHtml?: string;
|
|
173
|
+
}>;
|
|
174
|
+
};
|
|
175
|
+
const out: AtsJob[] = [];
|
|
176
|
+
for (const job of data.jobs ?? []) {
|
|
177
|
+
const title = strOrEmpty(job.title);
|
|
178
|
+
if (!title) continue;
|
|
179
|
+
const description = strOrEmpty(job.descriptionPlain) || decodeHtmlish(strOrEmpty(job.descriptionHtml));
|
|
180
|
+
out.push({
|
|
181
|
+
title,
|
|
182
|
+
jdSnippet: snippetFor(description, keywords),
|
|
183
|
+
url: strOrEmpty(job.jobUrl) || strOrEmpty(job.applyUrl),
|
|
184
|
+
firstSeenKey: strOrEmpty(job.id) || title,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Snippet selection: a verbatim window around the first matching keyword.
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Pick a verbatim JD snippet. If a keyword matches the description (case-
|
|
195
|
+
* insensitive), return a window centered on the first match — kept verbatim so
|
|
196
|
+
* the keyword text survives into the signal quote and the evidence gate holds.
|
|
197
|
+
* With no keywords (or no match), fall back to the leading slice. Returns "" for
|
|
198
|
+
* an empty description so the caller can decide whether the role still qualifies.
|
|
199
|
+
*/
|
|
200
|
+
export function snippetFor(description: string, keywords: string[]): string {
|
|
201
|
+
const text = description.trim();
|
|
202
|
+
if (!text) return "";
|
|
203
|
+
for (const keyword of keywords) {
|
|
204
|
+
const needle = keyword.trim();
|
|
205
|
+
if (!needle) continue;
|
|
206
|
+
const idx = text.toLowerCase().indexOf(needle.toLowerCase());
|
|
207
|
+
if (idx === -1) continue;
|
|
208
|
+
const pad = Math.max(0, Math.floor((SNIPPET_LEN - needle.length) / 2));
|
|
209
|
+
let start = Math.max(0, idx - pad);
|
|
210
|
+
let end = Math.min(text.length, idx + needle.length + pad);
|
|
211
|
+
// Snap to word boundaries so the window doesn't start/end mid-word, but
|
|
212
|
+
// never past the keyword itself (the verbatim span must stay intact).
|
|
213
|
+
while (start > 0 && /\S/.test(text[start - 1]) && start < idx) start += 1;
|
|
214
|
+
while (end < text.length && /\S/.test(text[end]) && end > idx + needle.length) end -= 1;
|
|
215
|
+
return text.slice(start, end).trim();
|
|
216
|
+
}
|
|
217
|
+
return text.slice(0, SNIPPET_LEN).trim();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Minimal HTML-ish cleanup for board `content`/`description` HTML: drop tags and
|
|
222
|
+
* decode the handful of entities Greenhouse/Ashby emit. Dependency-free; good
|
|
223
|
+
* enough to surface a readable, keyword-matchable snippet (the quote does not
|
|
224
|
+
* need to be byte-perfect HTML, only verbatim text the keyword appears in).
|
|
225
|
+
*/
|
|
226
|
+
function decodeHtmlish(raw: string): string {
|
|
227
|
+
return raw
|
|
228
|
+
.replace(/<\s*br\s*\/?\s*>/gi, " ")
|
|
229
|
+
.replace(/<\/?[^>]+>/g, " ")
|
|
230
|
+
.replace(/&/g, "&")
|
|
231
|
+
.replace(/</g, "<")
|
|
232
|
+
.replace(/>/g, ">")
|
|
233
|
+
.replace(/"/g, '"')
|
|
234
|
+
.replace(/'|'/g, "'")
|
|
235
|
+
.replace(/ /g, " ")
|
|
236
|
+
.replace(/\s+/g, " ")
|
|
237
|
+
.trim();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function strOrEmpty(value: unknown): string {
|
|
241
|
+
return typeof value === "string" ? value.trim() : "";
|
|
242
|
+
}
|
|
@@ -180,6 +180,52 @@ function normalizeLinkedin(value: string | undefined): string | undefined {
|
|
|
180
180
|
* full name + a company domain (or name). Returns the prospects with `email`
|
|
181
181
|
* filled where the waterfall found one; rows with no hit are returned unchanged.
|
|
182
182
|
*/
|
|
183
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
184
|
+
|
|
185
|
+
/** Run `count` async tasks with at most `limit` in flight. Order-independent. */
|
|
186
|
+
async function runWithConcurrency<T>(count: number, limit: number, worker: (index: number) => Promise<T>): Promise<T[]> {
|
|
187
|
+
const out: T[] = new Array(count);
|
|
188
|
+
let next = 0;
|
|
189
|
+
const runners = Array.from({ length: Math.max(1, Math.min(limit, count || 1)) }, async () => {
|
|
190
|
+
for (let i = next++; i < count; i = next++) out[i] = await worker(i);
|
|
191
|
+
});
|
|
192
|
+
await Promise.all(runners);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* POST a pipe0 sync run with exponential backoff on transient failure
|
|
198
|
+
* (throttle/5xx/network). pipe0's waterfall throttles under burst — a 429'd
|
|
199
|
+
* chunk returns all-failed — so concurrency must be paired with real backoff,
|
|
200
|
+
* not a single retry, or coverage collapses at scale (a parallel run without
|
|
201
|
+
* this dropped the work-email hit-rate from ~79% to ~17%). Retries 500ms →
|
|
202
|
+
* 1s → 2s → 4s (capped) so a throttled chunk lands on a later attempt instead
|
|
203
|
+
* of being lost.
|
|
204
|
+
*/
|
|
205
|
+
async function pipe0Post(
|
|
206
|
+
fetchImpl: FetchImpl,
|
|
207
|
+
base: string,
|
|
208
|
+
apiKey: string,
|
|
209
|
+
payload: unknown,
|
|
210
|
+
maxAttempts = 5,
|
|
211
|
+
): Promise<Pipe0RunResponse | undefined> {
|
|
212
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
217
|
+
body: JSON.stringify(payload),
|
|
218
|
+
});
|
|
219
|
+
if (response.ok) return (await response.json()) as Pipe0RunResponse;
|
|
220
|
+
// non-ok (429/5xx) — fall through to backoff + retry
|
|
221
|
+
} catch {
|
|
222
|
+
// network error — fall through to backoff + retry
|
|
223
|
+
}
|
|
224
|
+
if (attempt < maxAttempts - 1) await sleep(Math.min(8000, 500 * 2 ** attempt));
|
|
225
|
+
}
|
|
226
|
+
return undefined; // still failing after backoff — caller keeps the other chunks
|
|
227
|
+
}
|
|
228
|
+
|
|
183
229
|
export async function pipe0ResolveWorkEmails(opts: {
|
|
184
230
|
apiKey: string;
|
|
185
231
|
prospects: Prospect[];
|
|
@@ -189,43 +235,48 @@ export async function pipe0ResolveWorkEmails(opts: {
|
|
|
189
235
|
* batch rate-limit (a throttled chunk returns work_email status "failed"
|
|
190
236
|
* for every row, so one big batch is all-or-nothing). Default 3. */
|
|
191
237
|
chunkSize?: number;
|
|
238
|
+
/** Chunks resolved in parallel, bounded to respect pipe0's rate limit. Default
|
|
239
|
+
* 3 — paired with pipe0Post's backoff this cuts wall-clock from O(n) serial
|
|
240
|
+
* calls without throttling away coverage (higher defaults tanked the hit-rate
|
|
241
|
+
* at scale). */
|
|
242
|
+
concurrency?: number;
|
|
192
243
|
}): Promise<Prospect[]> {
|
|
193
244
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
194
245
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
195
246
|
const chunkSize = Math.max(1, opts.chunkSize ?? 3);
|
|
247
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
196
248
|
|
|
197
249
|
// Only rows with the waterfall's required inputs (name + company_domain|name).
|
|
198
250
|
const resolvable = opts.prospects.filter((p) => p.fullName && (p.companyDomain || p.companyName));
|
|
199
251
|
if (resolvable.length === 0) return opts.prospects;
|
|
200
252
|
|
|
201
|
-
const
|
|
202
|
-
for (let i = 0; i < resolvable.length; i += chunkSize)
|
|
203
|
-
|
|
204
|
-
|
|
253
|
+
const chunks: Prospect[][] = [];
|
|
254
|
+
for (let i = 0; i < resolvable.length; i += chunkSize) chunks.push(resolvable.slice(i, i + chunkSize));
|
|
255
|
+
|
|
256
|
+
const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
|
|
257
|
+
const input = chunks[ci].map((p) => ({
|
|
205
258
|
name: p.fullName,
|
|
206
259
|
...(p.companyDomain ? { company_domain: p.companyDomain } : { company_name: p.companyName }),
|
|
207
260
|
}));
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
if (!response.ok) continue; // throttled/5xx chunk — skip, keep the rest
|
|
216
|
-
body = (await response.json()) as Pipe0RunResponse;
|
|
217
|
-
} catch {
|
|
218
|
-
continue; // network hiccup on one chunk must not sink the whole run
|
|
219
|
-
}
|
|
220
|
-
for (const recordId of body.order ?? []) {
|
|
221
|
-
const fields = body.records?.[recordId]?.fields ?? {};
|
|
261
|
+
const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
|
|
262
|
+
pipes: [{ pipe_id: "person:workemail:waterfall@1" }],
|
|
263
|
+
input,
|
|
264
|
+
});
|
|
265
|
+
const entries: Array<[string, string]> = [];
|
|
266
|
+
for (const recordId of body?.order ?? []) {
|
|
267
|
+
const fields = body!.records?.[recordId]?.fields ?? {};
|
|
222
268
|
const email = fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined;
|
|
223
269
|
if (email) {
|
|
224
270
|
const domain = fieldValue(fields.company_domain) ?? fieldValue(fields.company_name);
|
|
225
|
-
|
|
271
|
+
entries.push([personKey(fieldValue(fields.name), domain), email]);
|
|
226
272
|
}
|
|
227
273
|
}
|
|
228
|
-
|
|
274
|
+
return entries;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const emailByKey = new Map<string, string>();
|
|
278
|
+
for (const entries of perChunk) for (const [k, v] of entries) emailByKey.set(k, v);
|
|
279
|
+
|
|
229
280
|
return opts.prospects.map((p) => {
|
|
230
281
|
const email = emailByKey.get(personKey(p.fullName, p.companyDomain ?? p.companyName));
|
|
231
282
|
return email ? { ...p, email } : p;
|
|
@@ -262,10 +313,13 @@ export async function pipe0ResolveCompanyDomains(opts: {
|
|
|
262
313
|
apiBaseUrl?: string;
|
|
263
314
|
fetchImpl?: FetchImpl;
|
|
264
315
|
chunkSize?: number;
|
|
316
|
+
/** Chunks resolved in parallel (bounded). Default 3. */
|
|
317
|
+
concurrency?: number;
|
|
265
318
|
}): Promise<Prospect[]> {
|
|
266
319
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
267
320
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
268
321
|
const chunkSize = Math.max(1, opts.chunkSize ?? 5);
|
|
322
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
269
323
|
|
|
270
324
|
const uniqueNames = [
|
|
271
325
|
...new Set(
|
|
@@ -277,30 +331,27 @@ export async function pipe0ResolveCompanyDomains(opts: {
|
|
|
277
331
|
];
|
|
278
332
|
if (uniqueNames.length === 0) return opts.prospects;
|
|
279
333
|
|
|
280
|
-
const
|
|
281
|
-
for (let i = 0; i < uniqueNames.length; i += chunkSize)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
for (const recordId of body.order ?? []) {
|
|
295
|
-
const fields = body.records?.[recordId]?.fields ?? {};
|
|
296
|
-
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
297
|
-
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
298
|
-
if (name && domain) domainByName.set(name.trim().toLowerCase(), domain);
|
|
299
|
-
}
|
|
300
|
-
} catch {
|
|
301
|
-
continue;
|
|
334
|
+
const chunks: string[][] = [];
|
|
335
|
+
for (let i = 0; i < uniqueNames.length; i += chunkSize) chunks.push(uniqueNames.slice(i, i + chunkSize));
|
|
336
|
+
|
|
337
|
+
const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
|
|
338
|
+
const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
|
|
339
|
+
pipes: [{ pipe_id: "company:identity@1" }],
|
|
340
|
+
input: chunks[ci].map((company_name) => ({ company_name })),
|
|
341
|
+
});
|
|
342
|
+
const entries: Array<[string, string]> = [];
|
|
343
|
+
for (const recordId of body?.order ?? []) {
|
|
344
|
+
const fields = body!.records?.[recordId]?.fields ?? {};
|
|
345
|
+
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
346
|
+
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
347
|
+
if (name && domain) entries.push([name.trim().toLowerCase(), domain]);
|
|
302
348
|
}
|
|
303
|
-
|
|
349
|
+
return entries;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const domainByName = new Map<string, string>();
|
|
353
|
+
for (const entries of perChunk) for (const [k, v] of entries) domainByName.set(k, v);
|
|
354
|
+
|
|
304
355
|
return opts.prospects.map((p) => {
|
|
305
356
|
if (p.companyDomain || !p.companyName) return p;
|
|
306
357
|
const domain = domainByName.get(p.companyName.trim().toLowerCase());
|