indiecrm-cli 0.1.0 → 0.2.1
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 +447 -0
- package/CODE_OF_CONDUCT.md +35 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +14 -2
- package/RESEARCH.md +346 -0
- package/SECURITY.md +35 -0
- package/dist/affiliate-copy.js +49 -0
- package/dist/auth.js +453 -0
- package/dist/bigquery.js +199 -0
- package/dist/chrome-browser.js +231 -0
- package/dist/cli.js +19652 -0
- package/dist/company-identity-review.js +78 -0
- package/dist/company-leads.js +422 -0
- package/dist/company-recovery.js +84 -0
- package/dist/deel-outreach.js +469 -0
- package/dist/deel-salesnav.js +368 -0
- package/dist/direct-path.js +326 -0
- package/dist/domain.js +53 -0
- package/dist/domainfinder.js +764 -0
- package/dist/engine.js +216 -0
- package/dist/historical-queries.js +189 -0
- package/dist/hunter-emailfinder.js +252 -0
- package/dist/icp-templates.js +171 -0
- package/dist/indiecrm/commands.js +108 -0
- package/dist/indiecrm-cli.js +2 -104
- package/dist/instantly.js +136 -0
- package/dist/io.js +21 -0
- package/dist/leadlists-funnel.js +148 -0
- package/dist/linkedin-companies.js +562 -0
- package/dist/linkedin-product-details.js +1203 -0
- package/dist/linkedin-product-search.js +1081 -0
- package/dist/linkedin-products.js +786 -0
- package/dist/linkedin-session-contracts.js +3 -0
- package/dist/linkedin-session.js +846 -0
- package/dist/providers.js +1 -0
- package/dist/research-browser-preference.js +37 -0
- package/dist/sales-navigator.js +1231 -0
- package/dist/salesnav-backfill.js +710 -0
- package/dist/sample-data.js +34 -0
- package/dist/session-recovery.js +62 -0
- package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
- package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
- package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
- package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
- package/package.json +73 -14
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
const ORIGIN = "https://www.linkedin.com";
|
|
6
|
+
const LOGIN_HELP = "Run indiecrm browser:connect, sign in to LinkedIn Sales Navigator in the dedicated Chrome window, then rerun with the same --out-dir. Saved work is preserved.";
|
|
7
|
+
export class ChromeResearchError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = "ChromeResearchError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class ChromeResearchInterruptedError extends Error {
|
|
16
|
+
signal;
|
|
17
|
+
constructor(signal) {
|
|
18
|
+
super("Research interrupted. Completed searches remain saved; rerun with the same --out-dir to resume.");
|
|
19
|
+
this.signal = signal;
|
|
20
|
+
this.name = "ChromeResearchInterruptedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function validateChromeResearchUrl(value) {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
if (url.origin !== ORIGIN || url.username || url.password || url.hash ||
|
|
26
|
+
!/^\/sales-api\/(?:salesApiAccountSearch|salesApiLeadSearch|salesApiCompanies)(?:\/\d+)?$/.test(url.pathname)) {
|
|
27
|
+
throw new Error("Chrome research only permits LinkedIn Sales Navigator company and people GET requests.");
|
|
28
|
+
}
|
|
29
|
+
// Preserve native Rest.li encoding; URLSearchParams can corrupt its syntax.
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
/** Only these native request headers enter memory; cookies/storage are never read or exported. */
|
|
33
|
+
export function researchHeaders(headers) {
|
|
34
|
+
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
35
|
+
if (!lower["csrf-token"] || !lower["x-li-identity"])
|
|
36
|
+
return null;
|
|
37
|
+
return { "csrf-token": lower["csrf-token"], "x-li-identity": lower["x-li-identity"], accept: "*/*", "x-restli-protocol-version": "2.0.0" };
|
|
38
|
+
}
|
|
39
|
+
export function chromeProfileDirectory() {
|
|
40
|
+
return path.resolve(process.env.SALESPROMPTER_CONFIG_DIR?.trim() || path.join(os.homedir(), ".config", "salesprompter"), "chrome-research");
|
|
41
|
+
}
|
|
42
|
+
/** Launch only a CLI-owned profile and private OS pipe; no debugging port or default-profile attachment. */
|
|
43
|
+
export async function launchResearchChrome(visible = false) {
|
|
44
|
+
const profile = chromeProfileDirectory();
|
|
45
|
+
try {
|
|
46
|
+
if ((await lstat(profile)).isSymbolicLink())
|
|
47
|
+
throw new Error("The research Chrome profile must not be a symlink.");
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== "ENOENT")
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
await mkdir(profile, { recursive: true, mode: 0o700 });
|
|
54
|
+
await chmod(profile, 0o700);
|
|
55
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
56
|
+
try {
|
|
57
|
+
return await puppeteer.launch({ channel: "chrome", userDataDir: profile, pipe: true, headless: !visible,
|
|
58
|
+
timeout: 30000, protocolTimeout: 45000, defaultViewport: null,
|
|
59
|
+
// Let the command unwind its checkpoint/lock finally blocks before exiting.
|
|
60
|
+
handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Launch errors can contain paths/flags. Never forward raw browser stderr or credential-bearing URLs.
|
|
65
|
+
throw new ChromeResearchError("Could not start the dedicated research Chrome. Install/update Google Chrome and close any other indiecrm browser:connect or Chrome research run. Your everyday Chrome can stay open.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function parseChromeResearchResult(result) {
|
|
69
|
+
if (result.status === 429 || result.status === 999)
|
|
70
|
+
throw new ChromeResearchError(`LinkedIn returned HTTP ${result.status}. Research stopped; wait for cooldown before resuming the same checkpoint. No session switching or retry was attempted.`, result.status);
|
|
71
|
+
if (result.redirected || result.status === 401 || result.status === 403)
|
|
72
|
+
throw new ChromeResearchError(LOGIN_HELP, result.status);
|
|
73
|
+
if (result.status !== 200)
|
|
74
|
+
throw new ChromeResearchError(`LinkedIn research failed with HTTP ${result.status}. Saved work is preserved.`, result.status);
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(result.text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new ChromeResearchError(`LinkedIn did not return research JSON. ${LOGIN_HELP}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Lazy and serial: reopening a completed checkpoint never starts Chrome. */
|
|
83
|
+
export function createChromeResearchBrowser(options = {}, launch = launchResearchChrome) {
|
|
84
|
+
let browser;
|
|
85
|
+
let page;
|
|
86
|
+
let headers = null;
|
|
87
|
+
let identity;
|
|
88
|
+
let fatal;
|
|
89
|
+
let initialization;
|
|
90
|
+
let closed = false;
|
|
91
|
+
let busy = false;
|
|
92
|
+
let browserClosing;
|
|
93
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
94
|
+
const signalHandlers = signals.map(signal => () => {
|
|
95
|
+
if (fatal instanceof ChromeResearchInterruptedError)
|
|
96
|
+
return;
|
|
97
|
+
fatal = new ChromeResearchInterruptedError(signal);
|
|
98
|
+
closed = true;
|
|
99
|
+
void closeOwnedBrowser().catch(() => { });
|
|
100
|
+
});
|
|
101
|
+
function closeOwnedBrowser() {
|
|
102
|
+
if (browser)
|
|
103
|
+
browserClosing ??= browser.close();
|
|
104
|
+
return browserClosing ?? Promise.resolve();
|
|
105
|
+
}
|
|
106
|
+
const waitMs = options.loginWaitMs ?? 30000;
|
|
107
|
+
if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > 3600000)
|
|
108
|
+
throw new Error("Chrome login wait must be between 0 and 3600 seconds.");
|
|
109
|
+
async function initialize() {
|
|
110
|
+
signals.forEach((signal, index) => process.on(signal, signalHandlers[index]));
|
|
111
|
+
browser = await launch(options.visible);
|
|
112
|
+
if (closed) {
|
|
113
|
+
await closeOwnedBrowser();
|
|
114
|
+
throw fatal ?? new ChromeResearchError("Research Chrome connection was cancelled.");
|
|
115
|
+
}
|
|
116
|
+
page = await browser.newPage();
|
|
117
|
+
page.on("response", (response) => {
|
|
118
|
+
if (fatal)
|
|
119
|
+
return;
|
|
120
|
+
const url = new URL(response.url());
|
|
121
|
+
if (url.origin !== ORIGIN)
|
|
122
|
+
return;
|
|
123
|
+
if (response.status() === 429 || response.status() === 999) {
|
|
124
|
+
fatal = new ChromeResearchError(`LinkedIn returned HTTP ${response.status()}. Stop and wait for cooldown before resuming.`, response.status());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!url.pathname.startsWith("/sales-api/"))
|
|
128
|
+
return;
|
|
129
|
+
if (response.status() !== 200)
|
|
130
|
+
return;
|
|
131
|
+
const next = researchHeaders(response.request().headers());
|
|
132
|
+
if (!next)
|
|
133
|
+
return;
|
|
134
|
+
if (identity && identity !== next["x-li-identity"]) {
|
|
135
|
+
fatal = new ChromeResearchError("LinkedIn identity changed during research. Stopped without switching accounts.");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
identity = next["x-li-identity"];
|
|
139
|
+
headers = next;
|
|
140
|
+
});
|
|
141
|
+
options.onProgress?.(options.visible
|
|
142
|
+
? "Sign in to Sales Navigator in the dedicated research Chrome window. The CLI will detect the connection automatically; no extension or browser worker is required."
|
|
143
|
+
: "Connecting to the saved research Chrome profile (no external browser worker).");
|
|
144
|
+
try {
|
|
145
|
+
await page.goto(`${ORIGIN}/sales/search/people`, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
if (closed)
|
|
149
|
+
throw fatal ?? new ChromeResearchError("Research Chrome connection was cancelled.");
|
|
150
|
+
if (!browser.connected)
|
|
151
|
+
throw new ChromeResearchError("Research Chrome closed before connecting.");
|
|
152
|
+
}
|
|
153
|
+
const deadline = Date.now() + waitMs;
|
|
154
|
+
let noticeAt = Date.now() + 30000;
|
|
155
|
+
while (!headers) {
|
|
156
|
+
if (closed)
|
|
157
|
+
throw fatal ?? new ChromeResearchError("Research Chrome connection was cancelled.");
|
|
158
|
+
if (fatal)
|
|
159
|
+
throw fatal;
|
|
160
|
+
if (!browser.connected || page.isClosed())
|
|
161
|
+
throw new ChromeResearchError("Research Chrome was closed before sign-in completed.");
|
|
162
|
+
const location = new URL(page.url());
|
|
163
|
+
const needsLogin = location.origin !== ORIGIN || /\/(?:login|checkpoint|authwall|uas)\b/.test(location.pathname);
|
|
164
|
+
if ((!options.visible && needsLogin) || Date.now() >= deadline)
|
|
165
|
+
throw new ChromeResearchError(LOGIN_HELP);
|
|
166
|
+
if (Date.now() >= noticeAt) {
|
|
167
|
+
options.onProgress?.("Waiting for Sales Navigator sign-in. Complete any sign-in or security check yourself in the research Chrome window.");
|
|
168
|
+
noticeAt += 30000;
|
|
169
|
+
}
|
|
170
|
+
await sleep(250);
|
|
171
|
+
}
|
|
172
|
+
if (fatal)
|
|
173
|
+
throw fatal;
|
|
174
|
+
options.onProgress?.("Research Chrome connected. Requests now run directly in the CLI.");
|
|
175
|
+
}
|
|
176
|
+
async function ready() {
|
|
177
|
+
if (closed)
|
|
178
|
+
throw fatal ?? new Error("Research Chrome is closed.");
|
|
179
|
+
initialization ??= initialize();
|
|
180
|
+
await initialization;
|
|
181
|
+
if (fatal)
|
|
182
|
+
throw fatal;
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
// Compatibility with the existing request adapter, without opening any listener.
|
|
186
|
+
port: 0,
|
|
187
|
+
async connect() { await ready(); return { browser: "chrome", connected: true, workerRequired: false, profile: chromeProfileDirectory() }; },
|
|
188
|
+
async request(request) {
|
|
189
|
+
validateChromeResearchUrl(request.url);
|
|
190
|
+
if (busy)
|
|
191
|
+
throw new Error("Chrome research is single-worker; concurrent requests are not allowed.");
|
|
192
|
+
busy = true;
|
|
193
|
+
try {
|
|
194
|
+
await ready();
|
|
195
|
+
if (new URL(page.url()).origin !== ORIGIN)
|
|
196
|
+
throw new ChromeResearchError(LOGIN_HELP);
|
|
197
|
+
// Serialize URL and allowlisted native headers as data, never concatenate raw input into executable code.
|
|
198
|
+
// Credentials remain browser-managed. Reject redirects to login/challenge pages instead of parsing HTML.
|
|
199
|
+
const result = await page.evaluate(`(async () => {
|
|
200
|
+
const response = await fetch(${JSON.stringify(request.url)}, {
|
|
201
|
+
method: 'GET', credentials: 'include', redirect: 'error',
|
|
202
|
+
headers: ${JSON.stringify(headers)}, signal: AbortSignal.timeout(30000)
|
|
203
|
+
});
|
|
204
|
+
const text = await response.text();
|
|
205
|
+
if (text.length > 50 * 1024 * 1024) throw new Error('Research response exceeded 50 MB');
|
|
206
|
+
return {status: response.status, text, redirected: response.redirected, retryAfter: response.headers.get('retry-after')};
|
|
207
|
+
})()`).catch(() => { throw fatal ?? new ChromeResearchError(`Chrome research request could not complete. ${LOGIN_HELP}`); });
|
|
208
|
+
const body = parseChromeResearchResult(result);
|
|
209
|
+
if (fatal)
|
|
210
|
+
throw fatal;
|
|
211
|
+
return { body, retryCount: 0, retryDelayMs: 0 };
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
busy = false;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
async close() {
|
|
218
|
+
closed = true;
|
|
219
|
+
try {
|
|
220
|
+
await closeOwnedBrowser();
|
|
221
|
+
await initialization?.catch(() => { });
|
|
222
|
+
await closeOwnedBrowser();
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
signals.forEach((signal, index) => process.off(signal, signalHandlers[index]));
|
|
226
|
+
headers = null;
|
|
227
|
+
identity = undefined;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|