@ubaidbinwaris/linkedin-login 2.1.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/Readme.md +156 -0
- package/index.js +13 -0
- package/package.json +39 -0
- package/src/auth/actions.js +52 -0
- package/src/auth/checkpoint.js +85 -0
- package/src/browser/launcher.js +62 -0
- package/src/config.js +14 -0
- package/src/login/login.js +155 -0
- package/src/session/SessionLock.js +52 -0
- package/src/session/sessionManager.js +208 -0
- package/src/utils/logger.js +51 -0
- package/src/utils/terminal.js +17 -0
- package/src/utils/time.js +12 -0
package/Readme.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @ubaidbinwaris/linkedin - Enterprise Automation Service
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
A professional, deterministic backend service library for managing multiple LinkedIn accounts with strict concurrency control, session isolation, and enterprise-grade security.
|
|
8
|
+
|
|
9
|
+
> **v1.1.7 Update**: Introduces "Smart Mobile Verification" with Visible Browser Fallback.
|
|
10
|
+
|
|
11
|
+
## 🚀 Key Features
|
|
12
|
+
|
|
13
|
+
* **🛡️ Multi-User Concurrency**: Built-in `SessionLock` prevents race conditions. Impossible to double-login the same user.
|
|
14
|
+
* **🔒 Enterprise Security**:
|
|
15
|
+
* Sessions stored as `SHA-256` hashed filenames (GDPR/Privacy friendly).
|
|
16
|
+
* Data encrypted with `AES-256-CBC` before storage.
|
|
17
|
+
* **🧠 Smart Validation**:
|
|
18
|
+
* Caches validation checks for 10 minutes to minimize ban risk from excessive reloading.
|
|
19
|
+
* Automatically refreshes stale sessions.
|
|
20
|
+
* **📱 Mobile & Fallback Support**:
|
|
21
|
+
* **Phase 1**: Detects "Open LinkedIn App" prompt and waits 2 minutes for user approval.
|
|
22
|
+
* **Phase 2**: If mobile fails, automatically launches a **Visible Browser** for manual intervention.
|
|
23
|
+
|
|
24
|
+
## 🏗️ Architecture
|
|
25
|
+
|
|
26
|
+
The library follows a strict **Fail-Fast** or **Resolution** flow. It does not use "stealth" plugins, relying instead on standard browser behavior and human intervention protocols.
|
|
27
|
+
|
|
28
|
+
```mermaid
|
|
29
|
+
sequenceDiagram
|
|
30
|
+
participant API as API/Worker
|
|
31
|
+
participant Pkg as LinkedIn Package
|
|
32
|
+
participant Browser as Playwright
|
|
33
|
+
participant Store as Session Store
|
|
34
|
+
|
|
35
|
+
API->>Pkg: loginToLinkedIn(email, pass)
|
|
36
|
+
Pkg->>Pkg: Acquire Lock (SessionLock)
|
|
37
|
+
alt is Locked
|
|
38
|
+
Pkg-->>API: Throw BUSY Error
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Pkg->>Browser: Launch (Headless)
|
|
42
|
+
Pkg->>Store: Load Context
|
|
43
|
+
|
|
44
|
+
alt Session Valid & Recent
|
|
45
|
+
Pkg-->>API: Return Page (Skip Feed)
|
|
46
|
+
else Session Stale/Invalid
|
|
47
|
+
Pkg->>Browser: Goto Feed
|
|
48
|
+
|
|
49
|
+
alt Login Required
|
|
50
|
+
Pkg->>Browser: Fill Credentials
|
|
51
|
+
Pkg->>Browser: Submit
|
|
52
|
+
|
|
53
|
+
opt Checkpoint Detected
|
|
54
|
+
Pkg->>Browser: Check Mobile Prompt
|
|
55
|
+
alt Mobile Prompt Found
|
|
56
|
+
Pkg->>Pkg: Wait 2 Mins for Feed URL
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
alt Mobile Failed
|
|
60
|
+
Pkg->>Browser: Close Headless
|
|
61
|
+
Pkg->>Browser: Launch VISIBLE Browser
|
|
62
|
+
Pkg->>Browser: Re-Fill Credentials
|
|
63
|
+
Pkg->>Pkg: Wait for Manual Use
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Pkg->>Store: Save Session (Encrypted)
|
|
69
|
+
Pkg-->>API: Return Page
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 📦 Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install @ubaidbinwaris/linkedin
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 💻 Usage
|
|
80
|
+
|
|
81
|
+
### 1. Basic Implementation
|
|
82
|
+
The simplest way to use the package. Locks and session management are handled automatically.
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
const { loginToLinkedIn } = require('@ubaidbinwaris/linkedin');
|
|
86
|
+
|
|
87
|
+
(async () => {
|
|
88
|
+
try {
|
|
89
|
+
const { browser, page } = await loginToLinkedIn({
|
|
90
|
+
headless: true // Will auto-switch to false if fallback needed
|
|
91
|
+
}, {
|
|
92
|
+
username: 'alice@example.com',
|
|
93
|
+
password: 'secure_password'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
console.log("✅ Logged in successfully!");
|
|
97
|
+
|
|
98
|
+
// ... Perform scraping/automation tasks ...
|
|
99
|
+
|
|
100
|
+
await browser.close();
|
|
101
|
+
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.message === 'CHECKPOINT_DETECTED') {
|
|
104
|
+
console.error("❌ Critical: Account requires manual ID verification.");
|
|
105
|
+
} else if (err.message.includes('BUSY')) {
|
|
106
|
+
console.error("⚠️ User is already running a task.");
|
|
107
|
+
} else {
|
|
108
|
+
console.error("Error:", err.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 2. Custom Storage (Database Integration)
|
|
115
|
+
By default, sessions are saved to `./sessions`. Override this to use Redis, MongoDB, or PostgreSQL.
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
const { setSessionStorage } = require('@ubaidbinwaris/linkedin');
|
|
119
|
+
|
|
120
|
+
setSessionStorage({
|
|
121
|
+
read: async (email) => {
|
|
122
|
+
// Return encrypted JSON string from your DB
|
|
123
|
+
const result = await db.query('SELECT session_data FROM users WHERE email = $1', [email]);
|
|
124
|
+
return result.rows[0]?.session_data;
|
|
125
|
+
},
|
|
126
|
+
write: async (email, data) => {
|
|
127
|
+
// Save encrypted JSON string to your DB
|
|
128
|
+
await db.query('UPDATE users SET session_data = $1 WHERE email = $2', [data, email]);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 3. Custom Logger
|
|
134
|
+
Pipe internal logs to your own system (e.g., Winston, UI Stream).
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
const { setLogger } = require('@ubaidbinwaris/linkedin');
|
|
138
|
+
|
|
139
|
+
setLogger({
|
|
140
|
+
info: (msg) => console.log(`[LI-INFO] ${msg}`),
|
|
141
|
+
warn: (msg) => console.warn(`[LI-WARN] ${msg}`),
|
|
142
|
+
error: (msg) => console.error(`[LI-ERR] ${msg}`)
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## 🚨 Error Reference
|
|
147
|
+
|
|
148
|
+
| Error Message | Meaning | Handling Action |
|
|
149
|
+
| :--- | :--- | :--- |
|
|
150
|
+
| `CHECKPOINT_DETECTED` | Security challenge (ID upload/Captcha) could not be resolved. | Notify admin. Manual Login required. |
|
|
151
|
+
| `CHECKPOINT_DETECTED_M` | Manual Fallback (Visible Browser) timed out. | User didn't interact in time. Retry. |
|
|
152
|
+
| `BUSY: ...` | A task is already running for this email. | Queue the request or reject it. |
|
|
153
|
+
| `LOGIN_FAILED` | Credentials accepted, but session could not be verified. | Check proxy/network. |
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
ISC
|
package/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Main entry point
|
|
2
|
+
const loginToLinkedIn = require('./src/login/login');
|
|
3
|
+
const { setSessionDir, setSessionStorage } = require('./src/session/sessionManager');
|
|
4
|
+
const { setLogger } = require('./src/utils/logger');
|
|
5
|
+
const config = require('./src/config');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
loginToLinkedIn,
|
|
9
|
+
setSessionDir,
|
|
10
|
+
setSessionStorage,
|
|
11
|
+
setLogger,
|
|
12
|
+
config
|
|
13
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ubaidbinwaris/linkedin-login",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"index.js",
|
|
9
|
+
"cli.js",
|
|
10
|
+
"Readme.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
15
|
+
"start": "node cli.js"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/UbaidBinWaris/linkedin.git"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"linkedin",
|
|
23
|
+
"automation",
|
|
24
|
+
"bot",
|
|
25
|
+
"playwright"
|
|
26
|
+
],
|
|
27
|
+
"author": "UbaidBinWaris",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"type": "commonjs",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/UbaidBinWaris/linkedin/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/UbaidBinWaris/linkedin#readme",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"dotenv": "^17.2.4",
|
|
36
|
+
"playwright": "^1.58.2",
|
|
37
|
+
"winston": "^3.19.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const logger = require("../utils/logger");
|
|
2
|
+
const { randomDelay } = require("../utils/time");
|
|
3
|
+
|
|
4
|
+
const LINKEDIN_LOGIN = "https://www.linkedin.com/login";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Performs credential-based login.
|
|
8
|
+
*/
|
|
9
|
+
async function performCredentialLogin(page, email, password) {
|
|
10
|
+
logger.info("Proceeding to credential login...");
|
|
11
|
+
|
|
12
|
+
if (!page.url().includes("login") && !page.url().includes("uas/request-password-reset")) {
|
|
13
|
+
await page.goto(LINKEDIN_LOGIN, { waitUntil: 'domcontentloaded' });
|
|
14
|
+
await randomDelay(1000, 2000);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
logger.info("Entering credentials...");
|
|
18
|
+
|
|
19
|
+
await page.click('input[name="session_key"]');
|
|
20
|
+
await randomDelay(500, 1000);
|
|
21
|
+
await page.type('input[name="session_key"]', email, { delay: 100 });
|
|
22
|
+
|
|
23
|
+
await randomDelay(1000, 2000);
|
|
24
|
+
|
|
25
|
+
await page.click('input[name="session_password"]');
|
|
26
|
+
await page.type('input[name="session_password"]', password, { delay: 100 });
|
|
27
|
+
|
|
28
|
+
await randomDelay(1000, 2000);
|
|
29
|
+
|
|
30
|
+
logger.info("Submitting login form...");
|
|
31
|
+
await Promise.all([
|
|
32
|
+
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
|
|
33
|
+
page.click('button[type="submit"]')
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { VALIDATION_SELECTORS } = require("../config");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Checks if the user is currently logged in (on feed).
|
|
41
|
+
*/
|
|
42
|
+
async function isLoggedIn(page) {
|
|
43
|
+
try {
|
|
44
|
+
const selector = VALIDATION_SELECTORS.join(", ");
|
|
45
|
+
await page.waitForSelector(selector, { timeout: 10000 });
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { performCredentialLogin, isLoggedIn };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const logger = require("../utils/logger");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detects if a checkpoint/verification is triggered.
|
|
5
|
+
* Returns true if intervention is needed.
|
|
6
|
+
*/
|
|
7
|
+
async function detectCheckpoint(page) {
|
|
8
|
+
try {
|
|
9
|
+
const url = page.url();
|
|
10
|
+
if (
|
|
11
|
+
url.includes("checkpoint") ||
|
|
12
|
+
url.includes("challenge") ||
|
|
13
|
+
url.includes("verification") ||
|
|
14
|
+
url.includes("consumer-login/error")
|
|
15
|
+
) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
await page.waitForSelector("h1:has-text('Security Verification')", { timeout: 1000 });
|
|
21
|
+
return true;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
// Element not found
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return false;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
logger.error(`Error during checkpoint detection: ${error.message}`);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Handles mobile verification/app approval prompts.
|
|
35
|
+
* Polls for success (feed navigation) for a set duration.
|
|
36
|
+
* @param {Page} page
|
|
37
|
+
* @returns {Promise<boolean>} true if resolved, false if timed out/failed
|
|
38
|
+
*/
|
|
39
|
+
async function handleMobileVerification(page) {
|
|
40
|
+
try {
|
|
41
|
+
const isMobileVerif = await page.evaluate(() => {
|
|
42
|
+
const text = document.body.innerText;
|
|
43
|
+
// User checks: "Check your LinkedIn app", "Open your LinkedIn app", "Approve the sign-in"
|
|
44
|
+
return text.includes("Check your LinkedIn app") ||
|
|
45
|
+
text.includes("Open your LinkedIn app") ||
|
|
46
|
+
text.includes("Tap Yes on the prompt") ||
|
|
47
|
+
text.includes("verification request to your device") ||
|
|
48
|
+
text.includes("Approve the sign-in");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!isMobileVerif) return false;
|
|
52
|
+
|
|
53
|
+
logger.warn("ACTION REQUIRED: Check your LinkedIn app on your phone! Waiting 2 minutes...");
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Poll for feed URL for 120 seconds
|
|
57
|
+
// Note: If navigation happens (user approves), this might throw "Execution context was destroyed"
|
|
58
|
+
// We handle that in the catch block by checking the URL.
|
|
59
|
+
await page.waitForFunction(() => {
|
|
60
|
+
return window.location.href.includes("/feed") ||
|
|
61
|
+
document.querySelector('.global-nav__search');
|
|
62
|
+
}, { timeout: 120000 });
|
|
63
|
+
|
|
64
|
+
logger.info("Mobile verification successful! Resuming...");
|
|
65
|
+
return true; // Resolved
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Check if we actually succeeded via navigation
|
|
68
|
+
try {
|
|
69
|
+
if (page.url().includes("/feed")) {
|
|
70
|
+
logger.info("Mobile verification successful (detected via navigation)! Resuming...");
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
} catch(e) {}
|
|
74
|
+
|
|
75
|
+
logger.warn(`Mobile verification wait ended: ${err.message}`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
} catch (e) {
|
|
80
|
+
logger.warn(`Mobile verification check failed: ${e.message}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { detectCheckpoint, handleMobileVerification };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { chromium } = require("playwright");
|
|
2
|
+
const logger = require("../utils/logger");
|
|
3
|
+
const { loadSession } = require("../session/sessionManager");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates and launches a browser instance.
|
|
7
|
+
* @param {Object} options - Launch options
|
|
8
|
+
*/
|
|
9
|
+
async function createBrowser(options = {}) {
|
|
10
|
+
const launchOptions = {
|
|
11
|
+
headless: options.headless !== undefined ? options.headless : true,
|
|
12
|
+
args: [
|
|
13
|
+
"--no-sandbox",
|
|
14
|
+
"--disable-setuid-sandbox",
|
|
15
|
+
],
|
|
16
|
+
...options
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
logger.info("Launching browser (Standard Playwright)...");
|
|
20
|
+
return await chromium.launch(launchOptions);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates or loads a browser context.
|
|
25
|
+
* @param {import('playwright').Browser} browser
|
|
26
|
+
* @param {string} email - for session loading
|
|
27
|
+
*/
|
|
28
|
+
async function createContext(browser, email) {
|
|
29
|
+
const contextOptions = {
|
|
30
|
+
userAgent: getRandomUserAgent(),
|
|
31
|
+
viewport: getRandomViewport(),
|
|
32
|
+
locale: 'en-US',
|
|
33
|
+
timezoneId: 'America/New_York',
|
|
34
|
+
permissions: ['geolocation'],
|
|
35
|
+
ignoreHTTPSErrors: true,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
logger.info(`Checking for saved session for ${email}...`);
|
|
39
|
+
const storedContext = await loadSession(browser, contextOptions, email);
|
|
40
|
+
|
|
41
|
+
if (storedContext) {
|
|
42
|
+
logger.info("Session stored context loaded.");
|
|
43
|
+
return storedContext;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.info("No valid session found. Creating new context.");
|
|
47
|
+
return await browser.newContext(contextOptions);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getRandomViewport() {
|
|
51
|
+
const width = 1280 + Math.floor(Math.random() * 640);
|
|
52
|
+
const height = 720 + Math.floor(Math.random() * 360);
|
|
53
|
+
return { width, height };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getRandomUserAgent() {
|
|
57
|
+
const versions = ["120.0.0.0", "121.0.0.0", "122.0.0.0"];
|
|
58
|
+
const version = versions[Math.floor(Math.random() * versions.length)];
|
|
59
|
+
return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { createBrowser, createContext };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
// Session expiry time: 30 days
|
|
5
|
+
SESSION_MAX_AGE: 90 * ONE_DAY_MS,
|
|
6
|
+
|
|
7
|
+
// Selectors to verify if the session is valid (logged in)
|
|
8
|
+
VALIDATION_SELECTORS: [
|
|
9
|
+
'.global-nav__search',
|
|
10
|
+
'input[placeholder="Search"]',
|
|
11
|
+
'#global-nav-typeahead',
|
|
12
|
+
'.feed-shared-update-v2' // Feed post container
|
|
13
|
+
]
|
|
14
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const logger = require("../utils/logger");
|
|
2
|
+
const { saveSession, SessionLock } = require("../session/sessionManager");
|
|
3
|
+
const { createBrowser, createContext } = require("../browser/launcher");
|
|
4
|
+
const { detectCheckpoint, handleMobileVerification } = require("../auth/checkpoint");
|
|
5
|
+
const { performCredentialLogin, isLoggedIn } = require("../auth/actions");
|
|
6
|
+
const { randomDelay } = require("../utils/time");
|
|
7
|
+
|
|
8
|
+
const LINKEDIN_FEED = "https://www.linkedin.com/feed/";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic Login Flow wrapped in SessionLock
|
|
12
|
+
* 1. Acquire Lock (queues if busy)
|
|
13
|
+
* 2. Launch Browser
|
|
14
|
+
* 3. Load Session -> Check needsValidation
|
|
15
|
+
* 4. Login/Verify
|
|
16
|
+
* 5. Save Session (with timestamp)
|
|
17
|
+
*/
|
|
18
|
+
async function loginToLinkedIn(options = {}, credentials = null) {
|
|
19
|
+
const email = credentials?.username || process.env.LINKEDIN_EMAIL;
|
|
20
|
+
const password = credentials?.password || process.env.LINKEDIN_PASSWORD;
|
|
21
|
+
|
|
22
|
+
if (!email || !password) {
|
|
23
|
+
throw new Error("Missing LinkedIn credentials.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Wrap entire process in lock
|
|
27
|
+
return SessionLock.withLoginLock(email, async () => {
|
|
28
|
+
const browser = await createBrowser(options);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// ----------------------------
|
|
32
|
+
// STEP 1: Load Session & Context
|
|
33
|
+
// ----------------------------
|
|
34
|
+
const context = await createContext(browser, email);
|
|
35
|
+
const page = await context.newPage();
|
|
36
|
+
page.setDefaultTimeout(30000);
|
|
37
|
+
|
|
38
|
+
// Check validation cache
|
|
39
|
+
// needsValidation is attached to context in sectionManager
|
|
40
|
+
if (context.needsValidation === false) {
|
|
41
|
+
logger.info(`[${email}] Session validation cached. Skipping feed check.`);
|
|
42
|
+
await page.goto(LINKEDIN_FEED, { waitUntil: "domcontentloaded" });
|
|
43
|
+
return { browser, context, page };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ----------------------------
|
|
47
|
+
// STEP 2: Verify Session (if needed)
|
|
48
|
+
// ----------------------------
|
|
49
|
+
logger.info(`[${email}] Verifying session...`);
|
|
50
|
+
await page.goto(LINKEDIN_FEED, { waitUntil: "domcontentloaded" });
|
|
51
|
+
await randomDelay(1000, 2000);
|
|
52
|
+
|
|
53
|
+
if (await isLoggedIn(page)) {
|
|
54
|
+
logger.info(`[${email}] Session valid ✅`);
|
|
55
|
+
// Update validation timestamp
|
|
56
|
+
await saveSession(context, email, true);
|
|
57
|
+
return { browser, context, page };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
logger.info(`[${email}] Session invalid. Clearing cookies and attempting credential login...`);
|
|
61
|
+
|
|
62
|
+
// Clear old/corrupt cookies to ensure fresh login
|
|
63
|
+
await context.clearCookies();
|
|
64
|
+
|
|
65
|
+
// ----------------------------
|
|
66
|
+
// STEP 3: Credential Login
|
|
67
|
+
// ----------------------------
|
|
68
|
+
await performCredentialLogin(page, email, password);
|
|
69
|
+
|
|
70
|
+
// ----------------------------
|
|
71
|
+
// STEP 4: Verify & Fail Fast
|
|
72
|
+
// ----------------------------
|
|
73
|
+
if (await detectCheckpoint(page)) {
|
|
74
|
+
// Attempt to handle mobile verification (2 min wait)
|
|
75
|
+
// If it returns true, it means we are now on the feed (resolved)
|
|
76
|
+
if (await handleMobileVerification(page)) {
|
|
77
|
+
// success, assume logged in
|
|
78
|
+
// Wait a moment for page to settle
|
|
79
|
+
await page.waitForTimeout(3000);
|
|
80
|
+
|
|
81
|
+
// Double check if we are really on feed
|
|
82
|
+
if (page.url().includes("/feed") || await isLoggedIn(page)) {
|
|
83
|
+
logger.info(`[${email}] Mobile Verification Successful ✅`);
|
|
84
|
+
await saveSession(context, email, true);
|
|
85
|
+
return { browser, context, page };
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
// Failed mobile verification.
|
|
89
|
+
// User Request: "otherwise after some delay using browser opens the browser and fill the form"
|
|
90
|
+
|
|
91
|
+
if (options.headless) {
|
|
92
|
+
logger.info("[Fallback] Mobile verification failed. Switching to VISIBLE browser for manual intervention...");
|
|
93
|
+
await browser.close();
|
|
94
|
+
|
|
95
|
+
// RE-LAUNCH in Visible Mode
|
|
96
|
+
const visibleBrowser = await createBrowser({ ...options, headless: false });
|
|
97
|
+
const visibleContext = await createContext(visibleBrowser, email);
|
|
98
|
+
const visiblePage = await visibleContext.newPage();
|
|
99
|
+
visiblePage.setDefaultTimeout(60000); // More time for manual interaction
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
logger.info("[Fallback] Filling credentials in visible browser...");
|
|
103
|
+
await performCredentialLogin(visiblePage, email, password);
|
|
104
|
+
|
|
105
|
+
// Now wait for success (Feed)
|
|
106
|
+
logger.info("[Fallback] Waiting for user to complete login manually...");
|
|
107
|
+
|
|
108
|
+
// Wait for URL OR Selector
|
|
109
|
+
// We loop to check both condition
|
|
110
|
+
try {
|
|
111
|
+
await visiblePage.waitForFunction(() => {
|
|
112
|
+
return window.location.href.includes("/feed") ||
|
|
113
|
+
document.querySelector(".global-nav__search");
|
|
114
|
+
}, { timeout: 120000 });
|
|
115
|
+
} catch(e) {
|
|
116
|
+
logger.warn(`[Fallback] Wait finished with error: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Double check status
|
|
120
|
+
if (visiblePage.url().includes("/feed") || await isLoggedIn(visiblePage)) {
|
|
121
|
+
logger.info(`[${email}] Manual Fallback Successful ✅`);
|
|
122
|
+
await saveSession(visibleContext, email, true);
|
|
123
|
+
return { browser: visibleBrowser, context: visibleContext, page: visiblePage };
|
|
124
|
+
}
|
|
125
|
+
} catch (fallbackErr) {
|
|
126
|
+
logger.error(`[Fallback] Manual intervention failed or timed out: ${fallbackErr.message}`);
|
|
127
|
+
await visibleBrowser.close();
|
|
128
|
+
throw new Error("CHECKPOINT_DETECTED_M"); // M for manual failed
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const screenshotPath = `checkpoint_${email}_${Date.now()}.png`;
|
|
133
|
+
await page.screenshot({ path: screenshotPath });
|
|
134
|
+
logger.warn(`[${email}] Checkpoint detected! Screenshot saved: ${screenshotPath}`);
|
|
135
|
+
throw new Error("CHECKPOINT_DETECTED");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (await isLoggedIn(page)) {
|
|
140
|
+
logger.info(`[${email}] Login successful ✅`);
|
|
141
|
+
await saveSession(context, email, true); // Save with validation = true
|
|
142
|
+
return { browser, context, page };
|
|
143
|
+
} else {
|
|
144
|
+
throw new Error("LOGIN_FAILED: Could not verify session after login attempt.");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logger.error(`[${email}] Login process failed: ${error.message}`);
|
|
149
|
+
await browser.close();
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = loginToLinkedIn;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const logger = require("../utils/logger");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory lock map to prevent simultaneous login attempts for the same user.
|
|
5
|
+
* Map<email, Promise<any>>
|
|
6
|
+
*/
|
|
7
|
+
const activeLogins = new Map();
|
|
8
|
+
|
|
9
|
+
const SessionLock = {
|
|
10
|
+
/**
|
|
11
|
+
* Executes a function with an exclusive lock for the given email.
|
|
12
|
+
* If a lock exists, it waits for it to release (or chains onto it?
|
|
13
|
+
* Actually user requested: "if activeLogins.has(email) { return activeLogins.get(email); }"
|
|
14
|
+
* This means if a login is in progress, return that SAME promise. Join the existing attempt.)
|
|
15
|
+
*
|
|
16
|
+
* @param {string} email
|
|
17
|
+
* @param {Function} fn - Async function to execute
|
|
18
|
+
* @returns {Promise<any>}
|
|
19
|
+
*/
|
|
20
|
+
async withLoginLock(email, fn) {
|
|
21
|
+
if (activeLogins.has(email)) {
|
|
22
|
+
logger.info(`[SessionLock] ${email} is already logging in. Joining existing request...`);
|
|
23
|
+
return activeLogins.get(email);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
logger.info(`[SessionLock] Acquiring lock for ${email}...`);
|
|
27
|
+
|
|
28
|
+
// Create a promise that wraps the execution
|
|
29
|
+
const promise = (async () => {
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} finally {
|
|
33
|
+
activeLogins.delete(email);
|
|
34
|
+
logger.info(`[SessionLock] Releasing lock for ${email}.`);
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
activeLogins.set(email, promise);
|
|
39
|
+
return promise;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks if a user is currently locked.
|
|
44
|
+
* @param {string} email
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
isLocked(email) {
|
|
48
|
+
return activeLogins.has(email);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
module.exports = SessionLock;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const logger = require("../utils/logger");
|
|
5
|
+
const SessionLock = require("./SessionLock"); // Keeping specific import, though widely used via login
|
|
6
|
+
|
|
7
|
+
let sessionDir = path.join(process.cwd(), "data", "linkedin");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sets the directory where session files are stored.
|
|
11
|
+
* @param {string} dirPath - Absolute path to the session directory.
|
|
12
|
+
*/
|
|
13
|
+
function setSessionDir(dirPath) {
|
|
14
|
+
sessionDir = dirPath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ALGORITHM = "aes-256-cbc";
|
|
18
|
+
const SECRET_KEY = process.env.SESSION_SECRET || "default_insecure_secret_key_32_bytes_long!!";
|
|
19
|
+
const key = crypto.createHash("sha256").update(String(SECRET_KEY)).digest();
|
|
20
|
+
|
|
21
|
+
function encrypt(text) {
|
|
22
|
+
const iv = crypto.randomBytes(16);
|
|
23
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
24
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
25
|
+
encrypted += cipher.final('hex');
|
|
26
|
+
return iv.toString('hex') + ':' + encrypted;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function decrypt(text) {
|
|
30
|
+
try {
|
|
31
|
+
const textParts = text.split(':');
|
|
32
|
+
const iv = Buffer.from(textParts.shift(), 'hex');
|
|
33
|
+
const encryptedText = textParts.join(':');
|
|
34
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
35
|
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
36
|
+
decrypted += decipher.final('utf8');
|
|
37
|
+
return decrypted;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates a SHA-256 hash of the email to use as filename.
|
|
45
|
+
* Privacy + filesystem safety.
|
|
46
|
+
* @param {string} email
|
|
47
|
+
* @returns {string} hex hash
|
|
48
|
+
*/
|
|
49
|
+
function getSessionHash(email) {
|
|
50
|
+
if (!email) return "default";
|
|
51
|
+
return crypto.createHash("sha256").update(email).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getSessionPath(email) {
|
|
55
|
+
const filename = `${getSessionHash(email)}.json`;
|
|
56
|
+
return path.join(sessionDir, filename);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sessionExists(email) {
|
|
60
|
+
return fs.existsSync(getSessionPath(email));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { SESSION_MAX_AGE } = require("../config"); // Assuming this exists, typically 7 days?
|
|
64
|
+
|
|
65
|
+
// Validation Cache Duration (10 minutes)
|
|
66
|
+
const VALIDATION_CACHE_MS = 10 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
const defaultStorage = {
|
|
69
|
+
async read(email) {
|
|
70
|
+
const filePath = getSessionPath(email);
|
|
71
|
+
if (!fs.existsSync(filePath)) return null;
|
|
72
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async write(email, data) {
|
|
76
|
+
const filePath = getSessionPath(email);
|
|
77
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
78
|
+
fs.writeFileSync(filePath, data, "utf-8");
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let currentStorage = defaultStorage;
|
|
83
|
+
|
|
84
|
+
function setSessionStorage(adapter) {
|
|
85
|
+
if (adapter && typeof adapter.read === 'function' && typeof adapter.write === 'function') {
|
|
86
|
+
currentStorage = adapter;
|
|
87
|
+
} else {
|
|
88
|
+
logger.warn("Invalid storage adapter provided. Using default file storage.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Saves the browser context state.
|
|
94
|
+
* @param {import('playwright').BrowserContext} context
|
|
95
|
+
* @param {string} email
|
|
96
|
+
* @param {boolean} [isValidated] - If true, updates lastValidatedAt
|
|
97
|
+
*/
|
|
98
|
+
async function saveSession(context, email, isValidated = false) {
|
|
99
|
+
try {
|
|
100
|
+
const state = await context.storageState();
|
|
101
|
+
|
|
102
|
+
// Load existing metadata if possible to preserve creation time?
|
|
103
|
+
// For now, simpler to just overwrite, but we might lose 'createdAt' if we had it.
|
|
104
|
+
// Let's just write new.
|
|
105
|
+
|
|
106
|
+
const sessionData = {
|
|
107
|
+
timestamp: Date.now(), // Last saved
|
|
108
|
+
lastValidatedAt: isValidated ? Date.now() : undefined,
|
|
109
|
+
state: state
|
|
110
|
+
// Future: proxy binding here
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const jsonString = JSON.stringify(sessionData);
|
|
114
|
+
const encryptedData = encrypt(jsonString);
|
|
115
|
+
|
|
116
|
+
await currentStorage.write(email || "default", encryptedData);
|
|
117
|
+
|
|
118
|
+
logger.info(`Session saved for ${email} (Validated: ${isValidated}) 🔒`);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.error(`Failed to save session: ${error.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Loads the session.
|
|
126
|
+
* @returns {Promise<{ context: import('playwright').BrowserContext, needsValidation: boolean }>}
|
|
127
|
+
*/
|
|
128
|
+
async function loadSession(browser, options = {}, email) {
|
|
129
|
+
const user = email || "default";
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const fileContent = await currentStorage.read(user);
|
|
133
|
+
|
|
134
|
+
if (!fileContent) {
|
|
135
|
+
logger.info(`No session found for ${user}.`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let sessionData = null;
|
|
140
|
+
let state = null;
|
|
141
|
+
|
|
142
|
+
// Decrypt
|
|
143
|
+
const decrypted = decrypt(fileContent);
|
|
144
|
+
if (decrypted) {
|
|
145
|
+
try {
|
|
146
|
+
sessionData = JSON.parse(decrypted);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
logger.error("Failed to parse session JSON.");
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// Fallback for legacy plain JSON?
|
|
153
|
+
try {
|
|
154
|
+
sessionData = JSON.parse(fileContent);
|
|
155
|
+
} catch(e) {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!sessionData || !sessionData.state) {
|
|
159
|
+
logger.warn("Invalid or corrupt session data.");
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check Age (Total expiry)
|
|
164
|
+
const age = Date.now() - sessionData.timestamp;
|
|
165
|
+
if (SESSION_MAX_AGE && age > SESSION_MAX_AGE) {
|
|
166
|
+
logger.warn(`Session expired (Age: ${age}ms).`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
state = sessionData.state;
|
|
171
|
+
|
|
172
|
+
// Check Validation Freshness
|
|
173
|
+
let needsValidation = true;
|
|
174
|
+
if (sessionData.lastValidatedAt) {
|
|
175
|
+
const valAge = Date.now() - sessionData.lastValidatedAt;
|
|
176
|
+
if (valAge < VALIDATION_CACHE_MS) {
|
|
177
|
+
needsValidation = false;
|
|
178
|
+
logger.info(`Session validation cached (Age: ${Math.round(valAge/1000)}s). Skipping checks.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const context = await browser.newContext({
|
|
183
|
+
storageState: state,
|
|
184
|
+
...options
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Attach metadata to context for caller to check?
|
|
188
|
+
// Or return object? Returning object is a breaking change for internal API but we are in v2.
|
|
189
|
+
// Let's attach to context object directly as a property for convenience
|
|
190
|
+
context.needsValidation = needsValidation;
|
|
191
|
+
|
|
192
|
+
return context;
|
|
193
|
+
|
|
194
|
+
} catch (error) {
|
|
195
|
+
logger.error(`Error loading session: ${error.message}`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
setSessionDir,
|
|
202
|
+
setSessionStorage,
|
|
203
|
+
sessionExists,
|
|
204
|
+
getSessionPath,
|
|
205
|
+
saveSession,
|
|
206
|
+
loadSession,
|
|
207
|
+
SessionLock
|
|
208
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { createLogger, format, transports } = require("winston");
|
|
2
|
+
const winston = require("winston");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
|
|
6
|
+
const logDir = path.join(__dirname, "../../logs");
|
|
7
|
+
|
|
8
|
+
if (!fs.existsSync(logDir)) {
|
|
9
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Create default logger
|
|
13
|
+
const defaultLogger = winston.createLogger({
|
|
14
|
+
level: "info",
|
|
15
|
+
format: winston.format.combine(
|
|
16
|
+
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
|
17
|
+
winston.format.printf(({ timestamp, level, message }) => {
|
|
18
|
+
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
|
19
|
+
})
|
|
20
|
+
),
|
|
21
|
+
transports: [
|
|
22
|
+
new winston.transports.File({ filename: path.join(logDir, "error.log"), level: "error" }),
|
|
23
|
+
new winston.transports.File({ filename: path.join(logDir, "combined.log") }),
|
|
24
|
+
new winston.transports.Console() // Output to console
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
let currentLogger = defaultLogger;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sets a custom logger instance.
|
|
32
|
+
* @param {Object} customLogger - Logger object with info(), error(), warn(), debug() methods.
|
|
33
|
+
*/
|
|
34
|
+
function setLogger(customLogger) {
|
|
35
|
+
if (customLogger && typeof customLogger.info === 'function') {
|
|
36
|
+
currentLogger = customLogger;
|
|
37
|
+
} else {
|
|
38
|
+
console.warn("Invalid logger provided. Using default.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Proxy object to forward calls to the current logger
|
|
43
|
+
const loggerProxy = {
|
|
44
|
+
info: (msg) => currentLogger.info(msg),
|
|
45
|
+
error: (msg) => currentLogger.error(msg),
|
|
46
|
+
warn: (msg) => currentLogger.warn(msg),
|
|
47
|
+
debug: (msg) => currentLogger.debug(msg),
|
|
48
|
+
setLogger // Export configuration method
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
module.exports = loggerProxy;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
|
|
3
|
+
function waitForUserResume(message = "Press ENTER to continue...") {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const rl = readline.createInterface({
|
|
6
|
+
input: process.stdin,
|
|
7
|
+
output: process.stdout,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
rl.question(message, () => {
|
|
11
|
+
rl.close();
|
|
12
|
+
resolve();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { waitForUserResume };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a promise that resolves after a random delay between min and max milliseconds.
|
|
3
|
+
* @param {number} min - Minimum delay in ms.
|
|
4
|
+
* @param {number} max - Maximum delay in ms.
|
|
5
|
+
* @returns {Promise<void>}
|
|
6
|
+
*/
|
|
7
|
+
function randomDelay(min = 1000, max = 3000) {
|
|
8
|
+
const delay = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { randomDelay };
|