salesprompter-cli 0.1.67 → 0.1.69
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 +172 -11
- package/dist/linkedin-product-details.js +1203 -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 \
|
|
@@ -138,6 +139,19 @@ salesprompter products:collect \
|
|
|
138
139
|
|
|
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.
|
|
140
141
|
|
|
142
|
+
Enrich that proven order with public product-page details, without changing its ranks or uploading it:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
salesprompter products:collect-details \
|
|
146
|
+
--catalog ./data/products.complete.json \
|
|
147
|
+
--checkpoint ./data/products.details.checkpoint.json \
|
|
148
|
+
--raw-jsonl ./data/products.details.raw.jsonl \
|
|
149
|
+
--out ./data/products.with-details.json \
|
|
150
|
+
--relay-port 43118
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The detail worker uses the same local `GET /task` lease protocol and posts only allowlisted product fields to `POST /detail`. Public URLs must be HTTPS; product images are capped at 2,048 characters, other URLs at 4,096, descriptions at 10,000, other strings at 200, roles/features at 100 entries, and customer companies at 50. The final artifact is created only after every rank has either a complete detail page or explicit unavailable-page proof.
|
|
154
|
+
|
|
141
155
|
Download stored workspace leads without starting a new Sales Navigator scrape:
|
|
142
156
|
|
|
143
157
|
```bash
|
|
@@ -158,7 +172,9 @@ the app's CLI imports view.
|
|
|
158
172
|
|
|
159
173
|
- Use your own authorized data access and workspace credentials.
|
|
160
174
|
- Respect provider terms and customer data boundaries.
|
|
161
|
-
- `affiliate:
|
|
175
|
+
- `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.
|
|
176
|
+
- 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.
|
|
177
|
+
- Affiliate outreach uses direct email enrichment first, then Phantombuster Email Finder for unresolved people before creating a draft Instantly campaign.
|
|
162
178
|
- Affiliate preparation uses exactly three emails with three observable variants per step; `affiliate:regenerate-sequence` rebuilds and syncs them through the Salesprompter app.
|
|
163
179
|
- `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`.
|
|
164
180
|
- `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 { collectLinkedInProductDetailsViaBrowserRelay } from "./linkedin-product-details.js";
|
|
31
32
|
import { collectLinkedInProductsViaBrowserRelay } from "./linkedin-product-search.js";
|
|
32
33
|
import { crawlLinkedInProductCategory } from "./linkedin-products.js";
|
|
33
34
|
import { claimLinkedInSessionCookieForCli, claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient, recordLinkedInSessionCookieAudit, resolveConfiguredEnvValue } from "./linkedin-session.js";
|
|
@@ -144,6 +145,18 @@ const AffiliateCampaignLaunchResponseSchema = z.object({
|
|
|
144
145
|
extraction: z
|
|
145
146
|
.object({
|
|
146
147
|
provider: z.string().min(1),
|
|
148
|
+
runId: z.string().min(1).optional(),
|
|
149
|
+
resultCount: z.number().int().nonnegative().optional(),
|
|
150
|
+
deduplication: z
|
|
151
|
+
.object({
|
|
152
|
+
collectedCount: z.number().int().nonnegative(),
|
|
153
|
+
duplicateCount: z.number().int().nonnegative(),
|
|
154
|
+
withinBatchDuplicateCount: z.number().int().nonnegative(),
|
|
155
|
+
workspaceDuplicateCount: z.number().int().nonnegative(),
|
|
156
|
+
excludedSeedProfileCount: z.number().int().nonnegative(),
|
|
157
|
+
netNewCount: z.number().int().nonnegative(),
|
|
158
|
+
})
|
|
159
|
+
.optional(),
|
|
147
160
|
monitorId: z.string().min(1).optional(),
|
|
148
161
|
agentId: z.string().min(1).optional(),
|
|
149
162
|
containerId: z.string().min(1).optional()
|
|
@@ -161,6 +174,21 @@ const AffiliateCampaignLaunchResponseSchema = z.object({
|
|
|
161
174
|
previewUrl: z.string().nullable().optional(),
|
|
162
175
|
next: z.string().optional()
|
|
163
176
|
});
|
|
177
|
+
const AffiliateAudienceVerificationSchema = z.object({
|
|
178
|
+
status: z.literal("ok"),
|
|
179
|
+
audience: z.object({
|
|
180
|
+
runId: z.string().uuid(),
|
|
181
|
+
status: z.enum(["launching", "running", "finished", "failed"]),
|
|
182
|
+
productDomain: z.string().min(1),
|
|
183
|
+
sourceType: z.string().min(1),
|
|
184
|
+
linkedInUrl: z.string().url(),
|
|
185
|
+
maxResults: z.number().int().positive(),
|
|
186
|
+
resultCount: z.number().int().nonnegative(),
|
|
187
|
+
provider: z.string().min(1).nullable(),
|
|
188
|
+
updatedAt: z.string().min(1),
|
|
189
|
+
}),
|
|
190
|
+
previewUrl: z.string().min(1),
|
|
191
|
+
});
|
|
164
192
|
const AffiliateCampaignLocalResultSchema = z
|
|
165
193
|
.object({
|
|
166
194
|
profileUrl: z.string().min(1),
|
|
@@ -599,7 +627,7 @@ const cliPacks = [
|
|
|
599
627
|
slug: "research",
|
|
600
628
|
title: "Research",
|
|
601
629
|
summary: "Scrape markets and enrich companies before outreach.",
|
|
602
|
-
commands: ["products:collect", "market:scrape", "companies:enrich"],
|
|
630
|
+
commands: ["products:collect", "products:collect-details", "market:scrape", "companies:enrich"],
|
|
603
631
|
installStatus: "included"
|
|
604
632
|
},
|
|
605
633
|
{
|
|
@@ -650,6 +678,7 @@ const helpAliasByCommandName = new Map([
|
|
|
650
678
|
["linkedin-companies:scrape-local", "companies:scrape-linkedin"],
|
|
651
679
|
["dealroom-companies:scrape-local", "companies:scrape-dealroom"],
|
|
652
680
|
["linkedin-products:collect", "products:collect"],
|
|
681
|
+
["linkedin-products:collect-details", "products:collect-details"],
|
|
653
682
|
["linkedin-products:scrape", "market:scrape"],
|
|
654
683
|
["salesnav:from-product-category", "leads:discover"],
|
|
655
684
|
["salesnav:people:collect", "leads:collect"],
|
|
@@ -701,6 +730,7 @@ const helpVisibleCommandNames = new Set([
|
|
|
701
730
|
"linkedin-companies:scrape-local",
|
|
702
731
|
"dealroom-companies:scrape-local",
|
|
703
732
|
"linkedin-products:collect",
|
|
733
|
+
"linkedin-products:collect-details",
|
|
704
734
|
"linkedin-products:scrape",
|
|
705
735
|
"salesnav:from-product-category",
|
|
706
736
|
"salesnav:people:collect",
|
|
@@ -6700,6 +6730,14 @@ async function launchAffiliateCampaignViaApp(session, payload) {
|
|
|
6700
6730
|
}), AffiliateCampaignLaunchResponseSchema);
|
|
6701
6731
|
return value;
|
|
6702
6732
|
}
|
|
6733
|
+
async function verifyAffiliateCampaignViaApp(session, audienceRunId) {
|
|
6734
|
+
const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-campaigns/${encodeURIComponent(audienceRunId)}`, {
|
|
6735
|
+
headers: {
|
|
6736
|
+
Authorization: `Bearer ${currentSession.accessToken}`,
|
|
6737
|
+
},
|
|
6738
|
+
}), AffiliateAudienceVerificationSchema);
|
|
6739
|
+
return value;
|
|
6740
|
+
}
|
|
6703
6741
|
async function prepareAffiliateOutreachViaApp(session, payload) {
|
|
6704
6742
|
const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-outreach`, {
|
|
6705
6743
|
method: "POST",
|
|
@@ -7935,7 +7973,7 @@ async function fetchLocalSalesNavigatorRequest(parsedRequest, retryOptions) {
|
|
|
7935
7973
|
retryDelayMs += await waitWithFullJitter(retryOptions.retryBaseDelayMs, retryOptions.retryMaxDelayMs, attempt);
|
|
7936
7974
|
continue;
|
|
7937
7975
|
}
|
|
7938
|
-
throw new
|
|
7976
|
+
throw new LocalSalesNavigatorHttpError(response.status, text, `Sales Navigator request failed with HTTP ${response.status}: ${preview}`);
|
|
7939
7977
|
}
|
|
7940
7978
|
try {
|
|
7941
7979
|
return { body: JSON.parse(text), retryCount, retryDelayMs };
|
|
@@ -7945,7 +7983,8 @@ async function fetchLocalSalesNavigatorRequest(parsedRequest, retryOptions) {
|
|
|
7945
7983
|
}
|
|
7946
7984
|
}
|
|
7947
7985
|
catch (error) {
|
|
7948
|
-
if (error instanceof LocalSalesNavigatorRateLimitError
|
|
7986
|
+
if (error instanceof LocalSalesNavigatorRateLimitError ||
|
|
7987
|
+
error instanceof LocalSalesNavigatorHttpError) {
|
|
7949
7988
|
throw error;
|
|
7950
7989
|
}
|
|
7951
7990
|
if (attempt < retryOptions.maxRetries) {
|
|
@@ -14776,6 +14815,10 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14776
14815
|
.max(accessibleResultLimit)
|
|
14777
14816
|
.parse(options.maxResults);
|
|
14778
14817
|
const session = await requireAuthSession();
|
|
14818
|
+
const excludedProfileUrls = z
|
|
14819
|
+
.array(z.string().url())
|
|
14820
|
+
.max(100)
|
|
14821
|
+
.parse(options.seedProfile ?? []);
|
|
14779
14822
|
const isSalesNavigatorPeopleSearch = new URL(linkedInUrl).pathname.includes("/sales/search/people");
|
|
14780
14823
|
let localResults;
|
|
14781
14824
|
let localCollection;
|
|
@@ -14789,7 +14832,7 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14789
14832
|
if (options.curlFile && browserRelayPort != null) {
|
|
14790
14833
|
throw new Error("Use either --curl-file or --browser-relay-port, not both.");
|
|
14791
14834
|
}
|
|
14792
|
-
|
|
14835
|
+
let parsedRequest = options.curlFile
|
|
14793
14836
|
? parseSalesNavigatorCurlRequest(await readFile(path.resolve(String(options.curlFile)), "utf8"))
|
|
14794
14837
|
: browserRelayPort != null
|
|
14795
14838
|
? {
|
|
@@ -14805,7 +14848,7 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14805
14848
|
: await createLocalAccountSearchBrowserRelay(browserRelayPort);
|
|
14806
14849
|
let collected;
|
|
14807
14850
|
try {
|
|
14808
|
-
|
|
14851
|
+
const collect = async () => await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
|
|
14809
14852
|
requestedProfiles: maxResults,
|
|
14810
14853
|
pageSize,
|
|
14811
14854
|
pageDelayMinMs,
|
|
@@ -14819,6 +14862,37 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14819
14862
|
? (request) => browserRelay.request(request)
|
|
14820
14863
|
: undefined,
|
|
14821
14864
|
});
|
|
14865
|
+
try {
|
|
14866
|
+
collected = await collect();
|
|
14867
|
+
}
|
|
14868
|
+
catch (error) {
|
|
14869
|
+
const rejectedSession = error instanceof LocalSalesNavigatorHttpError &&
|
|
14870
|
+
(error.status === 401 || error.status === 403);
|
|
14871
|
+
if (!rejectedSession ||
|
|
14872
|
+
options.curlFile ||
|
|
14873
|
+
browserRelay ||
|
|
14874
|
+
shouldDisableLinkedInDirectLookupAutodiscovery()) {
|
|
14875
|
+
throw error;
|
|
14876
|
+
}
|
|
14877
|
+
const extensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
|
|
14878
|
+
if (!extensionConfig)
|
|
14879
|
+
throw error;
|
|
14880
|
+
const extensionRequest = buildSalesNavigatorApiRequestFromSearchUrl(linkedInUrl, extensionConfig, pageSize);
|
|
14881
|
+
if (extensionRequest.headers.cookie === parsedRequest.headers.cookie &&
|
|
14882
|
+
extensionRequest.headers["csrf-token"] === parsedRequest.headers["csrf-token"]) {
|
|
14883
|
+
throw error;
|
|
14884
|
+
}
|
|
14885
|
+
writeProgress("Stored Sales Navigator session was rejected; retrying with the latest extension-synced session.");
|
|
14886
|
+
parsedRequest = extensionRequest;
|
|
14887
|
+
collected = await collect();
|
|
14888
|
+
}
|
|
14889
|
+
}
|
|
14890
|
+
catch (error) {
|
|
14891
|
+
if (error instanceof LocalSalesNavigatorHttpError &&
|
|
14892
|
+
(error.status === 401 || error.status === 403)) {
|
|
14893
|
+
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.");
|
|
14894
|
+
}
|
|
14895
|
+
throw error;
|
|
14822
14896
|
}
|
|
14823
14897
|
finally {
|
|
14824
14898
|
await browserRelay?.close();
|
|
@@ -14837,11 +14911,26 @@ async function runAffiliateLaunchCommand(options) {
|
|
|
14837
14911
|
dryRun: Boolean(options.dryRun),
|
|
14838
14912
|
localResults,
|
|
14839
14913
|
localCollection,
|
|
14914
|
+
...(excludedProfileUrls.length > 0 ? { excludedProfileUrls } : {}),
|
|
14840
14915
|
});
|
|
14916
|
+
const runId = payload.extraction?.runId;
|
|
14917
|
+
const verification = !options.dryRun && runId && z.string().uuid().safeParse(runId).success
|
|
14918
|
+
? await verifyAffiliateCampaignViaApp(session, runId)
|
|
14919
|
+
: null;
|
|
14920
|
+
if (verification) {
|
|
14921
|
+
const expectedResultCount = payload.extraction?.resultCount;
|
|
14922
|
+
if (verification.audience.status !== "finished") {
|
|
14923
|
+
throw new Error(`Affiliate audience ${runId} was persisted but canonical status is ${verification.audience.status}, not finished.`);
|
|
14924
|
+
}
|
|
14925
|
+
if (expectedResultCount != null &&
|
|
14926
|
+
verification.audience.resultCount !== expectedResultCount) {
|
|
14927
|
+
throw new Error(`Affiliate audience ${runId} readback mismatch: created ${expectedResultCount}, stored ${verification.audience.resultCount}.`);
|
|
14928
|
+
}
|
|
14929
|
+
}
|
|
14841
14930
|
if (options.out) {
|
|
14842
|
-
await writeJsonFile(options.out, payload);
|
|
14931
|
+
await writeJsonFile(options.out, { ...payload, verification });
|
|
14843
14932
|
}
|
|
14844
|
-
return { session, payload };
|
|
14933
|
+
return { session, payload, verification };
|
|
14845
14934
|
}
|
|
14846
14935
|
function addAffiliateAudienceOptions(command) {
|
|
14847
14936
|
return command
|
|
@@ -14850,6 +14939,7 @@ function addAffiliateAudienceOptions(command) {
|
|
|
14850
14939
|
.option("--max-results <number>", "Maximum audience results (people searches: 2500; Connections of: 1000)")
|
|
14851
14940
|
.option("--curl-file <path>", "Optional copied Sales Navigator Lead Search curl request")
|
|
14852
14941
|
.option("--browser-relay-port <number>", "Use the signed-in browser through a loopback relay")
|
|
14942
|
+
.option("--seed-profile <url>", "Exclude a known customer or seed profile; repeat for multiple profiles", collectStringOptionValue, [])
|
|
14853
14943
|
.option("--page-size <number>", "Direct Sales Navigator page size", "100")
|
|
14854
14944
|
.option("--page-delay-min-ms <number>", "Minimum delay between direct pages", "5000")
|
|
14855
14945
|
.option("--page-delay-max-ms <number>", "Maximum delay between direct pages", "8000")
|
|
@@ -14915,12 +15005,13 @@ addAffiliateAudienceOptions(program
|
|
|
14915
15005
|
.command("affiliate:launch")
|
|
14916
15006
|
.description("Build an affiliate audience from an affiliate link and LinkedIn search URL."))
|
|
14917
15007
|
.action(async (options) => {
|
|
14918
|
-
const { payload } = await runAffiliateLaunchCommand(options);
|
|
14919
|
-
printOutput(payload);
|
|
15008
|
+
const { payload, verification } = await runAffiliateLaunchCommand(options);
|
|
15009
|
+
printOutput({ ...payload, verification });
|
|
14920
15010
|
});
|
|
14921
15011
|
addAffiliateAudienceOptions(program
|
|
14922
15012
|
.command("affiliate:run")
|
|
14923
|
-
.
|
|
15013
|
+
.alias("affiliate:grow")
|
|
15014
|
+
.description("Collect, deduplicate, verify, and prepare a paused Instantly campaign end to end."))
|
|
14924
15015
|
.option("--campaign-name <name>", "Optional Instantly campaign name")
|
|
14925
15016
|
.option("--language <language>", "Sequence language", "English")
|
|
14926
15017
|
.option("--steps <number>", "Exactly 3 sequence emails (fixed)", "3")
|
|
@@ -14938,6 +15029,13 @@ addAffiliateAudienceOptions(program
|
|
|
14938
15029
|
if (options.dryRun) {
|
|
14939
15030
|
printOutput({
|
|
14940
15031
|
...launched.payload,
|
|
15032
|
+
verification: null,
|
|
15033
|
+
journey: {
|
|
15034
|
+
collection: "validated",
|
|
15035
|
+
persistence: "not_requested",
|
|
15036
|
+
outreach: "not_requested",
|
|
15037
|
+
activation: "not_requested",
|
|
15038
|
+
},
|
|
14941
15039
|
outreach: {
|
|
14942
15040
|
status: "dry-run",
|
|
14943
15041
|
provider: "instantly",
|
|
@@ -14952,7 +15050,28 @@ addAffiliateAudienceOptions(program
|
|
|
14952
15050
|
? launched.payload.extraction.runId
|
|
14953
15051
|
: null;
|
|
14954
15052
|
if (!runId) {
|
|
14955
|
-
|
|
15053
|
+
const deduplication = launched.payload.extraction?.deduplication;
|
|
15054
|
+
if (deduplication?.netNewCount === 0) {
|
|
15055
|
+
printOutput({
|
|
15056
|
+
status: "ok",
|
|
15057
|
+
audience: launched.payload,
|
|
15058
|
+
verification: null,
|
|
15059
|
+
journey: {
|
|
15060
|
+
collection: "complete",
|
|
15061
|
+
deduplication: "complete",
|
|
15062
|
+
persistence: "not_needed",
|
|
15063
|
+
outreach: "not_created",
|
|
15064
|
+
activation: "not_requested",
|
|
15065
|
+
},
|
|
15066
|
+
outreach: { status: "not_created", provider: "instantly" },
|
|
15067
|
+
next: "Change the Sales Navigator cohort; every collected profile was already known or excluded.",
|
|
15068
|
+
});
|
|
15069
|
+
return;
|
|
15070
|
+
}
|
|
15071
|
+
throw new Error("The audience provider did not return a durable run id. End-to-end outreach requires a finished Sales Navigator people audience.");
|
|
15072
|
+
}
|
|
15073
|
+
if (!launched.verification) {
|
|
15074
|
+
throw new Error(`Affiliate audience ${runId} was created but could not be independently verified. Outreach was not prepared.`);
|
|
14956
15075
|
}
|
|
14957
15076
|
const outreach = await prepareAffiliateOutreachViaApp(launched.session, {
|
|
14958
15077
|
audienceRunId: z.string().uuid().parse(runId),
|
|
@@ -14964,6 +15083,16 @@ addAffiliateAudienceOptions(program
|
|
|
14964
15083
|
printOutput({
|
|
14965
15084
|
status: "ok",
|
|
14966
15085
|
audience: launched.payload,
|
|
15086
|
+
verification: launched.verification,
|
|
15087
|
+
journey: {
|
|
15088
|
+
collection: "complete",
|
|
15089
|
+
deduplication: launched.payload.extraction?.deduplication
|
|
15090
|
+
? "complete"
|
|
15091
|
+
: "not_reported",
|
|
15092
|
+
persistence: "verified",
|
|
15093
|
+
outreach: finalOutreach.status,
|
|
15094
|
+
activation: finalOutreach.status === "active" ? "complete" : "not_requested",
|
|
15095
|
+
},
|
|
14967
15096
|
outreach: finalOutreach,
|
|
14968
15097
|
next: finalOutreach.status === "ready"
|
|
14969
15098
|
? `Review the sequence, then run: salesprompter affiliate:activate ${runId}`
|
|
@@ -15223,6 +15352,38 @@ program
|
|
|
15223
15352
|
});
|
|
15224
15353
|
printOutput(result);
|
|
15225
15354
|
});
|
|
15355
|
+
program
|
|
15356
|
+
.command("linkedin-products:collect-details")
|
|
15357
|
+
.alias("products:collect-details")
|
|
15358
|
+
.description("Enrich a proven LinkedIn product ranking locally through a resumable loopback browser relay.")
|
|
15359
|
+
.requiredOption("--catalog <path>", "Complete products:collect ranking artifact path")
|
|
15360
|
+
.requiredOption("--checkpoint <path>", "Private resumable product-detail checkpoint JSON path")
|
|
15361
|
+
.requiredOption("--raw-jsonl <path>", "Private append-only normalized product-detail evidence path")
|
|
15362
|
+
.requiredOption("--out <path>", "Complete public-fields-only enriched artifact path")
|
|
15363
|
+
.requiredOption("--relay-port <number>", "Loopback relay port for GET /task and POST /detail")
|
|
15364
|
+
.option("--max-requests <number>", "Optional number of unique product-detail tasks to issue in this run")
|
|
15365
|
+
.action(async (options) => {
|
|
15366
|
+
const relayPort = z.coerce.number().int().min(1).max(65_535).parse(options.relayPort);
|
|
15367
|
+
const maxRequests = options.maxRequests === undefined
|
|
15368
|
+
? undefined
|
|
15369
|
+
: z.coerce.number().int().min(1).max(100_000).parse(options.maxRequests);
|
|
15370
|
+
const idleTimeoutMs = process.env.SALESPROMPTER_LINKEDIN_PRODUCT_DETAILS_RELAY_IDLE_TIMEOUT_MS
|
|
15371
|
+
? z.coerce.number().int().min(100).max(86_400_000).parse(process.env.SALESPROMPTER_LINKEDIN_PRODUCT_DETAILS_RELAY_IDLE_TIMEOUT_MS)
|
|
15372
|
+
: undefined;
|
|
15373
|
+
const result = await collectLinkedInProductDetailsViaBrowserRelay({
|
|
15374
|
+
catalogPath: path.resolve(String(options.catalog)),
|
|
15375
|
+
checkpointPath: path.resolve(String(options.checkpoint)),
|
|
15376
|
+
rawJsonlPath: path.resolve(String(options.rawJsonl)),
|
|
15377
|
+
outPath: path.resolve(String(options.out)),
|
|
15378
|
+
relayPort,
|
|
15379
|
+
maxRequests,
|
|
15380
|
+
idleTimeoutMs,
|
|
15381
|
+
onListening: ({ taskUrl, detailUrl }) => {
|
|
15382
|
+
writeProgress(`LinkedIn product detail relay ready: GET ${taskUrl} and POST ${detailUrl}`);
|
|
15383
|
+
}
|
|
15384
|
+
});
|
|
15385
|
+
printOutput(result);
|
|
15386
|
+
});
|
|
15226
15387
|
program
|
|
15227
15388
|
.command("linkedin-products:scrape")
|
|
15228
15389
|
.alias("market:scrape")
|