salesprompter-cli 0.1.66 → 0.1.68
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/README.md +19 -3
- package/dist/cli.js +177 -11
- package/dist/linkedin-product-search.js +1081 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,11 +97,12 @@ salesprompter affiliate:launch \
|
|
|
97
97
|
--linkedin-url "https://www.linkedin.com/sales/search/people?query=..." \
|
|
98
98
|
--max-results 100
|
|
99
99
|
|
|
100
|
-
# End to end: collect,
|
|
101
|
-
salesprompter affiliate:
|
|
100
|
+
# End to end: collect, exclude customers, deduplicate, verify, enrich, and create a draft
|
|
101
|
+
salesprompter affiliate:grow \
|
|
102
102
|
--affiliate-link "https://example.com/?ref=you" \
|
|
103
103
|
--linkedin-url "https://www.linkedin.com/sales/search/people?query=..." \
|
|
104
104
|
--max-results 100 \
|
|
105
|
+
--seed-profile "https://www.linkedin.com/in/existing-customer" \
|
|
105
106
|
--steps 3 \
|
|
106
107
|
--timing-mode custom \
|
|
107
108
|
--daily-limit 25 \
|
|
@@ -125,6 +126,19 @@ salesprompter packs:list
|
|
|
125
126
|
salesprompter --help
|
|
126
127
|
```
|
|
127
128
|
|
|
129
|
+
Collect an exact signed-in LinkedIn product-search order locally, without uploading it:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
salesprompter products:collect \
|
|
133
|
+
--query-url "$LINKEDIN_PRODUCTS_URL" \
|
|
134
|
+
--checkpoint ./data/products.checkpoint.json \
|
|
135
|
+
--raw-jsonl ./data/products.raw.jsonl \
|
|
136
|
+
--out ./data/products.complete.json \
|
|
137
|
+
--relay-port 43117
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Use a signed-in in-app-browser worker with the loopback `GET /task` and `POST /page` protocol. Interrupted, bounded, challenged, or rate-limited runs remain checkpointed and do not create the complete artifact.
|
|
141
|
+
|
|
128
142
|
Download stored workspace leads without starting a new Sales Navigator scrape:
|
|
129
143
|
|
|
130
144
|
```bash
|
|
@@ -145,7 +159,9 @@ the app's CLI imports view.
|
|
|
145
159
|
|
|
146
160
|
- Use your own authorized data access and workspace credentials.
|
|
147
161
|
- Respect provider terms and customer data boundaries.
|
|
148
|
-
- `affiliate:
|
|
162
|
+
- `affiliate:grow` is an alias for `affiliate:run`. It excludes repeated `--seed-profile` customers, removes profiles already stored in prior workspace affiliate audiences, and verifies the final persisted count before preparing Instantly.
|
|
163
|
+
- If every collected profile is known or excluded, no audience or Instantly campaign is created. A stale Sales Navigator session also fails before persistence with a safe retry instruction.
|
|
164
|
+
- Affiliate outreach uses direct email enrichment first, then Phantombuster Email Finder for unresolved people before creating a draft Instantly campaign.
|
|
149
165
|
- Affiliate preparation uses exactly three emails with three observable variants per step; `affiliate:regenerate-sequence` rebuilds and syncs them through the Salesprompter app.
|
|
150
166
|
- `affiliate:list` shows safe workspace summaries. `affiliate:analytics` shows aggregate and step/variant outcomes without exposing lead data; unsupported per-step meeting and won fields render as `n/a`.
|
|
151
167
|
- `affiliate:run` defaults to `--timing-mode custom`, so its daily limit and weekday sending window are honored. Use `--timing-mode auto` only when capacity-derived scheduling is intended.
|
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
|
|
|
28
28
|
import { InstantlySyncProvider } from "./instantly.js";
|
|
29
29
|
import { backfillLinkedInCompanies } from "./linkedin-companies.js";
|
|
30
30
|
import { parseLinkedInCompanyPage } from "./linkedin-companies.js";
|
|
31
|
+
import { collectLinkedInProductsViaBrowserRelay } from "./linkedin-product-search.js";
|
|
31
32
|
import { crawlLinkedInProductCategory } from "./linkedin-products.js";
|
|
32
33
|
import { claimLinkedInSessionCookieForCli, claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient, recordLinkedInSessionCookieAudit, resolveConfiguredEnvValue } from "./linkedin-session.js";
|
|
33
34
|
import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
|
|
@@ -143,6 +144,18 @@ const AffiliateCampaignLaunchResponseSchema = z.object({
|
|
|
143
144
|
extraction: z
|
|
144
145
|
.object({
|
|
145
146
|
provider: z.string().min(1),
|
|
147
|
+
runId: z.string().min(1).optional(),
|
|
148
|
+
resultCount: z.number().int().nonnegative().optional(),
|
|
149
|
+
deduplication: z
|
|
150
|
+
.object({
|
|
151
|
+
collectedCount: z.number().int().nonnegative(),
|
|
152
|
+
duplicateCount: z.number().int().nonnegative(),
|
|
153
|
+
withinBatchDuplicateCount: z.number().int().nonnegative(),
|
|
154
|
+
workspaceDuplicateCount: z.number().int().nonnegative(),
|
|
155
|
+
excludedSeedProfileCount: z.number().int().nonnegative(),
|
|
156
|
+
netNewCount: z.number().int().nonnegative(),
|
|
157
|
+
})
|
|
158
|
+
.optional(),
|
|
146
159
|
monitorId: z.string().min(1).optional(),
|
|
147
160
|
agentId: z.string().min(1).optional(),
|
|
148
161
|
containerId: z.string().min(1).optional()
|
|
@@ -160,6 +173,21 @@ const AffiliateCampaignLaunchResponseSchema = z.object({
|
|
|
160
173
|
previewUrl: z.string().nullable().optional(),
|
|
161
174
|
next: z.string().optional()
|
|
162
175
|
});
|
|
176
|
+
const AffiliateAudienceVerificationSchema = z.object({
|
|
177
|
+
status: z.literal("ok"),
|
|
178
|
+
audience: z.object({
|
|
179
|
+
runId: z.string().uuid(),
|
|
180
|
+
status: z.enum(["launching", "running", "finished", "failed"]),
|
|
181
|
+
productDomain: z.string().min(1),
|
|
182
|
+
sourceType: z.string().min(1),
|
|
183
|
+
linkedInUrl: z.string().url(),
|
|
184
|
+
maxResults: z.number().int().positive(),
|
|
185
|
+
resultCount: z.number().int().nonnegative(),
|
|
186
|
+
provider: z.string().min(1).nullable(),
|
|
187
|
+
updatedAt: z.string().min(1),
|
|
188
|
+
}),
|
|
189
|
+
previewUrl: z.string().min(1),
|
|
190
|
+
});
|
|
163
191
|
const AffiliateCampaignLocalResultSchema = z
|
|
164
192
|
.object({
|
|
165
193
|
profileUrl: z.string().min(1),
|
|
@@ -598,7 +626,7 @@ const cliPacks = [
|
|
|
598
626
|
slug: "research",
|
|
599
627
|
title: "Research",
|
|
600
628
|
summary: "Scrape markets and enrich companies before outreach.",
|
|
601
|
-
commands: ["market:scrape", "companies:enrich"],
|
|
629
|
+
commands: ["products:collect", "market:scrape", "companies:enrich"],
|
|
602
630
|
installStatus: "included"
|
|
603
631
|
},
|
|
604
632
|
{
|
|
@@ -648,6 +676,7 @@ const helpAliasByCommandName = new Map([
|
|
|
648
676
|
["linkedin-companies:backfill", "companies:enrich"],
|
|
649
677
|
["linkedin-companies:scrape-local", "companies:scrape-linkedin"],
|
|
650
678
|
["dealroom-companies:scrape-local", "companies:scrape-dealroom"],
|
|
679
|
+
["linkedin-products:collect", "products:collect"],
|
|
651
680
|
["linkedin-products:scrape", "market:scrape"],
|
|
652
681
|
["salesnav:from-product-category", "leads:discover"],
|
|
653
682
|
["salesnav:people:collect", "leads:collect"],
|
|
@@ -698,6 +727,7 @@ const helpVisibleCommandNames = new Set([
|
|
|
698
727
|
"linkedin-companies:backfill",
|
|
699
728
|
"linkedin-companies:scrape-local",
|
|
700
729
|
"dealroom-companies:scrape-local",
|
|
730
|
+
"linkedin-products:collect",
|
|
701
731
|
"linkedin-products:scrape",
|
|
702
732
|
"salesnav:from-product-category",
|
|
703
733
|
"salesnav:people:collect",
|
|
@@ -6697,6 +6727,14 @@ async function launchAffiliateCampaignViaApp(session, payload) {
|
|
|
6697
6727
|
}), AffiliateCampaignLaunchResponseSchema);
|
|
6698
6728
|
return value;
|
|
6699
6729
|
}
|
|
6730
|
+
async function verifyAffiliateCampaignViaApp(session, audienceRunId) {
|
|
6731
|
+
const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-campaigns/${encodeURIComponent(audienceRunId)}`, {
|
|
6732
|
+
headers: {
|
|
6733
|
+
Authorization: `Bearer ${currentSession.accessToken}`,
|
|
6734
|
+
},
|
|
6735
|
+
}), AffiliateAudienceVerificationSchema);
|
|
6736
|
+
return value;
|
|
6737
|
+
}
|
|
6700
6738
|
async function prepareAffiliateOutreachViaApp(session, payload) {
|
|
6701
6739
|
const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-outreach`, {
|
|
6702
6740
|
method: "POST",
|
|
@@ -7932,7 +7970,7 @@ async function fetchLocalSalesNavigatorRequest(parsedRequest, retryOptions) {
|
|
|
7932
7970
|
retryDelayMs += await waitWithFullJitter(retryOptions.retryBaseDelayMs, retryOptions.retryMaxDelayMs, attempt);
|
|
7933
7971
|
continue;
|
|
7934
7972
|
}
|
|
7935
|
-
throw new
|
|
7973
|
+
throw new LocalSalesNavigatorHttpError(response.status, text, `Sales Navigator request failed with HTTP ${response.status}: ${preview}`);
|
|
7936
7974
|
}
|
|
7937
7975
|
try {
|
|
7938
7976
|
return { body: JSON.parse(text), retryCount, retryDelayMs };
|
|
@@ -7942,7 +7980,8 @@ async function fetchLocalSalesNavigatorRequest(parsedRequest, retryOptions) {
|
|
|
7942
7980
|
}
|
|
7943
7981
|
}
|
|
7944
7982
|
catch (error) {
|
|
7945
|
-
if (error instanceof LocalSalesNavigatorRateLimitError
|
|
7983
|
+
if (error instanceof LocalSalesNavigatorRateLimitError ||
|
|
7984
|
+
error instanceof LocalSalesNavigatorHttpError) {
|
|
7946
7985
|
throw error;
|
|
7947
7986
|
}
|
|
7948
7987
|
if (attempt < retryOptions.maxRetries) {
|
|
@@ -14773,6 +14812,10 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14773
14812
|
.max(accessibleResultLimit)
|
|
14774
14813
|
.parse(options.maxResults);
|
|
14775
14814
|
const session = await requireAuthSession();
|
|
14815
|
+
const excludedProfileUrls = z
|
|
14816
|
+
.array(z.string().url())
|
|
14817
|
+
.max(100)
|
|
14818
|
+
.parse(options.seedProfile ?? []);
|
|
14776
14819
|
const isSalesNavigatorPeopleSearch = new URL(linkedInUrl).pathname.includes("/sales/search/people");
|
|
14777
14820
|
let localResults;
|
|
14778
14821
|
let localCollection;
|
|
@@ -14786,7 +14829,7 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14786
14829
|
if (options.curlFile && browserRelayPort != null) {
|
|
14787
14830
|
throw new Error("Use either --curl-file or --browser-relay-port, not both.");
|
|
14788
14831
|
}
|
|
14789
|
-
|
|
14832
|
+
let parsedRequest = options.curlFile
|
|
14790
14833
|
? parseSalesNavigatorCurlRequest(await readFile(path.resolve(String(options.curlFile)), "utf8"))
|
|
14791
14834
|
: browserRelayPort != null
|
|
14792
14835
|
? {
|
|
@@ -14802,7 +14845,7 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14802
14845
|
: await createLocalAccountSearchBrowserRelay(browserRelayPort);
|
|
14803
14846
|
let collected;
|
|
14804
14847
|
try {
|
|
14805
|
-
|
|
14848
|
+
const collect = async () => await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
|
|
14806
14849
|
requestedProfiles: maxResults,
|
|
14807
14850
|
pageSize,
|
|
14808
14851
|
pageDelayMinMs,
|
|
@@ -14816,6 +14859,37 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14816
14859
|
? (request) => browserRelay.request(request)
|
|
14817
14860
|
: undefined,
|
|
14818
14861
|
});
|
|
14862
|
+
try {
|
|
14863
|
+
collected = await collect();
|
|
14864
|
+
}
|
|
14865
|
+
catch (error) {
|
|
14866
|
+
const rejectedSession = error instanceof LocalSalesNavigatorHttpError &&
|
|
14867
|
+
(error.status === 401 || error.status === 403);
|
|
14868
|
+
if (!rejectedSession ||
|
|
14869
|
+
options.curlFile ||
|
|
14870
|
+
browserRelay ||
|
|
14871
|
+
shouldDisableLinkedInDirectLookupAutodiscovery()) {
|
|
14872
|
+
throw error;
|
|
14873
|
+
}
|
|
14874
|
+
const extensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
|
|
14875
|
+
if (!extensionConfig)
|
|
14876
|
+
throw error;
|
|
14877
|
+
const extensionRequest = buildSalesNavigatorApiRequestFromSearchUrl(linkedInUrl, extensionConfig, pageSize);
|
|
14878
|
+
if (extensionRequest.headers.cookie === parsedRequest.headers.cookie &&
|
|
14879
|
+
extensionRequest.headers["csrf-token"] === parsedRequest.headers["csrf-token"]) {
|
|
14880
|
+
throw error;
|
|
14881
|
+
}
|
|
14882
|
+
writeProgress("Stored Sales Navigator session was rejected; retrying with the latest extension-synced session.");
|
|
14883
|
+
parsedRequest = extensionRequest;
|
|
14884
|
+
collected = await collect();
|
|
14885
|
+
}
|
|
14886
|
+
}
|
|
14887
|
+
catch (error) {
|
|
14888
|
+
if (error instanceof LocalSalesNavigatorHttpError &&
|
|
14889
|
+
(error.status === 401 || error.status === 403)) {
|
|
14890
|
+
throw new Error("Sales Navigator rejected the available session. No audience was persisted. Refresh the Sales Navigator search in the signed-in browser, then rerun this command with a current --curl-file or --browser-relay-port. Workspace deduplication makes the retry safe.");
|
|
14891
|
+
}
|
|
14892
|
+
throw error;
|
|
14819
14893
|
}
|
|
14820
14894
|
finally {
|
|
14821
14895
|
await browserRelay?.close();
|
|
@@ -14834,11 +14908,26 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14834
14908
|
dryRun: Boolean(options.dryRun),
|
|
14835
14909
|
localResults,
|
|
14836
14910
|
localCollection,
|
|
14911
|
+
...(excludedProfileUrls.length > 0 ? { excludedProfileUrls } : {}),
|
|
14837
14912
|
});
|
|
14913
|
+
const runId = payload.extraction?.runId;
|
|
14914
|
+
const verification = !options.dryRun && runId && z.string().uuid().safeParse(runId).success
|
|
14915
|
+
? await verifyAffiliateCampaignViaApp(session, runId)
|
|
14916
|
+
: null;
|
|
14917
|
+
if (verification) {
|
|
14918
|
+
const expectedResultCount = payload.extraction?.resultCount;
|
|
14919
|
+
if (verification.audience.status !== "finished") {
|
|
14920
|
+
throw new Error(`Affiliate audience ${runId} was persisted but canonical status is ${verification.audience.status}, not finished.`);
|
|
14921
|
+
}
|
|
14922
|
+
if (expectedResultCount != null &&
|
|
14923
|
+
verification.audience.resultCount !== expectedResultCount) {
|
|
14924
|
+
throw new Error(`Affiliate audience ${runId} readback mismatch: created ${expectedResultCount}, stored ${verification.audience.resultCount}.`);
|
|
14925
|
+
}
|
|
14926
|
+
}
|
|
14838
14927
|
if (options.out) {
|
|
14839
|
-
await writeJsonFile(options.out, payload);
|
|
14928
|
+
await writeJsonFile(options.out, { ...payload, verification });
|
|
14840
14929
|
}
|
|
14841
|
-
return { session, payload };
|
|
14930
|
+
return { session, payload, verification };
|
|
14842
14931
|
}
|
|
14843
14932
|
function addAffiliateAudienceOptions(command) {
|
|
14844
14933
|
return command
|
|
@@ -14847,6 +14936,7 @@ function addAffiliateAudienceOptions(command) {
|
|
|
14847
14936
|
.option("--max-results <number>", "Maximum audience results (people searches: 2500; Connections of: 1000)")
|
|
14848
14937
|
.option("--curl-file <path>", "Optional copied Sales Navigator Lead Search curl request")
|
|
14849
14938
|
.option("--browser-relay-port <number>", "Use the signed-in browser through a loopback relay")
|
|
14939
|
+
.option("--seed-profile <url>", "Exclude a known customer or seed profile; repeat for multiple profiles", collectStringOptionValue, [])
|
|
14850
14940
|
.option("--page-size <number>", "Direct Sales Navigator page size", "100")
|
|
14851
14941
|
.option("--page-delay-min-ms <number>", "Minimum delay between direct pages", "5000")
|
|
14852
14942
|
.option("--page-delay-max-ms <number>", "Maximum delay between direct pages", "8000")
|
|
@@ -14912,12 +15002,13 @@ addAffiliateAudienceOptions(program
|
|
|
14912
15002
|
.command("affiliate:launch")
|
|
14913
15003
|
.description("Build an affiliate audience from an affiliate link and LinkedIn search URL."))
|
|
14914
15004
|
.action(async (options) => {
|
|
14915
|
-
const { payload } = await runAffiliateLaunchCommand(options);
|
|
14916
|
-
printOutput(payload);
|
|
15005
|
+
const { payload, verification } = await runAffiliateLaunchCommand(options);
|
|
15006
|
+
printOutput({ ...payload, verification });
|
|
14917
15007
|
});
|
|
14918
15008
|
addAffiliateAudienceOptions(program
|
|
14919
15009
|
.command("affiliate:run")
|
|
14920
|
-
.
|
|
15010
|
+
.alias("affiliate:grow")
|
|
15011
|
+
.description("Collect, deduplicate, verify, and prepare a paused Instantly campaign end to end."))
|
|
14921
15012
|
.option("--campaign-name <name>", "Optional Instantly campaign name")
|
|
14922
15013
|
.option("--language <language>", "Sequence language", "English")
|
|
14923
15014
|
.option("--steps <number>", "Exactly 3 sequence emails (fixed)", "3")
|
|
@@ -14935,6 +15026,13 @@ addAffiliateAudienceOptions(program
|
|
|
14935
15026
|
if (options.dryRun) {
|
|
14936
15027
|
printOutput({
|
|
14937
15028
|
...launched.payload,
|
|
15029
|
+
verification: null,
|
|
15030
|
+
journey: {
|
|
15031
|
+
collection: "validated",
|
|
15032
|
+
persistence: "not_requested",
|
|
15033
|
+
outreach: "not_requested",
|
|
15034
|
+
activation: "not_requested",
|
|
15035
|
+
},
|
|
14938
15036
|
outreach: {
|
|
14939
15037
|
status: "dry-run",
|
|
14940
15038
|
provider: "instantly",
|
|
@@ -14949,7 +15047,28 @@ addAffiliateAudienceOptions(program
|
|
|
14949
15047
|
? launched.payload.extraction.runId
|
|
14950
15048
|
: null;
|
|
14951
15049
|
if (!runId) {
|
|
14952
|
-
|
|
15050
|
+
const deduplication = launched.payload.extraction?.deduplication;
|
|
15051
|
+
if (deduplication?.netNewCount === 0) {
|
|
15052
|
+
printOutput({
|
|
15053
|
+
status: "ok",
|
|
15054
|
+
audience: launched.payload,
|
|
15055
|
+
verification: null,
|
|
15056
|
+
journey: {
|
|
15057
|
+
collection: "complete",
|
|
15058
|
+
deduplication: "complete",
|
|
15059
|
+
persistence: "not_needed",
|
|
15060
|
+
outreach: "not_created",
|
|
15061
|
+
activation: "not_requested",
|
|
15062
|
+
},
|
|
15063
|
+
outreach: { status: "not_created", provider: "instantly" },
|
|
15064
|
+
next: "Change the Sales Navigator cohort; every collected profile was already known or excluded.",
|
|
15065
|
+
});
|
|
15066
|
+
return;
|
|
15067
|
+
}
|
|
15068
|
+
throw new Error("The audience provider did not return a durable run id. End-to-end outreach requires a finished Sales Navigator people audience.");
|
|
15069
|
+
}
|
|
15070
|
+
if (!launched.verification) {
|
|
15071
|
+
throw new Error(`Affiliate audience ${runId} was created but could not be independently verified. Outreach was not prepared.`);
|
|
14953
15072
|
}
|
|
14954
15073
|
const outreach = await prepareAffiliateOutreachViaApp(launched.session, {
|
|
14955
15074
|
audienceRunId: z.string().uuid().parse(runId),
|
|
@@ -14961,6 +15080,16 @@ addAffiliateAudienceOptions(program
|
|
|
14961
15080
|
printOutput({
|
|
14962
15081
|
status: "ok",
|
|
14963
15082
|
audience: launched.payload,
|
|
15083
|
+
verification: launched.verification,
|
|
15084
|
+
journey: {
|
|
15085
|
+
collection: "complete",
|
|
15086
|
+
deduplication: launched.payload.extraction?.deduplication
|
|
15087
|
+
? "complete"
|
|
15088
|
+
: "not_reported",
|
|
15089
|
+
persistence: "verified",
|
|
15090
|
+
outreach: finalOutreach.status,
|
|
15091
|
+
activation: finalOutreach.status === "active" ? "complete" : "not_requested",
|
|
15092
|
+
},
|
|
14964
15093
|
outreach: finalOutreach,
|
|
14965
15094
|
next: finalOutreach.status === "ready"
|
|
14966
15095
|
? `Review the sequence, then run: salesprompter affiliate:activate ${runId}`
|
|
@@ -15183,6 +15312,43 @@ program
|
|
|
15183
15312
|
profile,
|
|
15184
15313
|
});
|
|
15185
15314
|
});
|
|
15315
|
+
program
|
|
15316
|
+
.command("linkedin-products:collect")
|
|
15317
|
+
.alias("products:collect")
|
|
15318
|
+
.description("Collect an exact LinkedIn product-search order locally through a loopback browser relay.")
|
|
15319
|
+
.requiredOption("--query-url <url>", "Exact LinkedIn /search/results/products/ query URL")
|
|
15320
|
+
.requiredOption("--checkpoint <path>", "Private resumable checkpoint JSON path")
|
|
15321
|
+
.requiredOption("--raw-jsonl <path>", "Private append-only normalized page evidence path")
|
|
15322
|
+
.requiredOption("--out <path>", "Complete public-fields-only ranked artifact path")
|
|
15323
|
+
.requiredOption("--relay-port <number>", "Loopback relay port for GET /task and POST /page")
|
|
15324
|
+
.option("--max-pages <number>", "Optional highest page number to accept in this run")
|
|
15325
|
+
.option("--max-requests <number>", "Optional number of unique browser tasks to issue in this run")
|
|
15326
|
+
.action(async (options) => {
|
|
15327
|
+
const relayPort = z.coerce.number().int().min(1).max(65_535).parse(options.relayPort);
|
|
15328
|
+
const maxPages = options.maxPages === undefined
|
|
15329
|
+
? undefined
|
|
15330
|
+
: z.coerce.number().int().min(1).max(100_000).parse(options.maxPages);
|
|
15331
|
+
const maxRequests = options.maxRequests === undefined
|
|
15332
|
+
? undefined
|
|
15333
|
+
: z.coerce.number().int().min(1).max(100_000).parse(options.maxRequests);
|
|
15334
|
+
const idleTimeoutMs = process.env.SALESPROMPTER_LINKEDIN_PRODUCTS_RELAY_IDLE_TIMEOUT_MS
|
|
15335
|
+
? z.coerce.number().int().min(100).max(86_400_000).parse(process.env.SALESPROMPTER_LINKEDIN_PRODUCTS_RELAY_IDLE_TIMEOUT_MS)
|
|
15336
|
+
: undefined;
|
|
15337
|
+
const result = await collectLinkedInProductsViaBrowserRelay({
|
|
15338
|
+
queryUrl: String(options.queryUrl),
|
|
15339
|
+
checkpointPath: path.resolve(String(options.checkpoint)),
|
|
15340
|
+
rawJsonlPath: path.resolve(String(options.rawJsonl)),
|
|
15341
|
+
outPath: path.resolve(String(options.out)),
|
|
15342
|
+
relayPort,
|
|
15343
|
+
maxPages,
|
|
15344
|
+
maxRequests,
|
|
15345
|
+
idleTimeoutMs,
|
|
15346
|
+
onListening: ({ taskUrl, pageUrl }) => {
|
|
15347
|
+
writeProgress(`LinkedIn product relay ready: GET ${taskUrl} and POST ${pageUrl}`);
|
|
15348
|
+
}
|
|
15349
|
+
});
|
|
15350
|
+
printOutput(result);
|
|
15351
|
+
});
|
|
15186
15352
|
program
|
|
15187
15353
|
.command("linkedin-products:scrape")
|
|
15188
15354
|
.alias("market:scrape")
|