@ubaidbinwaris/linkedin 1.0.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/LICENSE +21 -0
- package/Readme.md +84 -0
- package/cli.js +56 -0
- package/index.js +11 -0
- package/package.json +45 -0
- package/src/cli/repl.js +68 -0
- package/src/config.js +14 -0
- package/src/login/login.js +261 -0
- package/src/session/sessionManager.js +155 -0
- package/src/utils/logger.js +30 -0
- package/src/utils/terminal.js +17 -0
- package/src/utils/time.js +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UbaidBinWaris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/Readme.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# LinkedIn Automation Bot
|
|
2
|
+
|
|
3
|
+
A robust Node.js automation tool designed to interact with LinkedIn using [Playwright](https://playwright.dev/). This bot is built with stealth features to mimic human behavior, manage sessions efficiently, and provide an interactive CLI for control.
|
|
4
|
+
|
|
5
|
+
## 🚀 Features
|
|
6
|
+
|
|
7
|
+
- **Stealth Automation**: Uses `puppeteer-extra-plugin-stealth` with Playwright to minimize detection risks.
|
|
8
|
+
- **Session Management**: Automatically saves and loads session states (cookies & local storage) to prevent frequent re-logins.
|
|
9
|
+
- **Headless & Visible Modes**: configurable execution modes for debugging or background operation.
|
|
10
|
+
- **Interactive CLI**: integrated REPL (Read-Eval-Print Loop) to control the bot instance after initialization.
|
|
11
|
+
- **Smart Validation**: robust checks to verify login status and handle checkpoints/verifications manually if needed.
|
|
12
|
+
- **Human-like Behavior**: Implements random delays, mouse movements, and dynamic user agents.
|
|
13
|
+
|
|
14
|
+
## 🛠️ Prerequisites
|
|
15
|
+
|
|
16
|
+
- **Node.js** (v14 or higher recommended)
|
|
17
|
+
- **npm** (Node Package Manager)
|
|
18
|
+
- A valid **LinkedIn Account**
|
|
19
|
+
|
|
20
|
+
## 📦 Installation
|
|
21
|
+
|
|
22
|
+
1. **Clone the repository:**
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/UbaidBinWaris/linkedin.git
|
|
25
|
+
cd linkedin
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
2. **Install dependencies:**
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
3. **Install Playwright browsers:**
|
|
34
|
+
```bash
|
|
35
|
+
npx playwright install chromium
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## ⚙️ Configuration
|
|
39
|
+
|
|
40
|
+
1. Create a `.env` file in the root directory.
|
|
41
|
+
2. Add your LinkedIn credentials:
|
|
42
|
+
|
|
43
|
+
```env
|
|
44
|
+
LINKEDIN_EMAIL=your_email@example.com
|
|
45
|
+
LINKEDIN_PASSWORD=your_password
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## ▶️ Usage
|
|
49
|
+
|
|
50
|
+
### Run in Headless Mode (Default)
|
|
51
|
+
Runs the bot in the background without a visible browser window.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm start
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Run in Visible Mode
|
|
58
|
+
Useful for debugging or if manual intervention (CAPTCHA) is required.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm start -- --visible
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### CLI Commands
|
|
65
|
+
Once the bot is running, you can interact with it through the terminal. (See `src/cli/repl.js` for available commands).
|
|
66
|
+
|
|
67
|
+
## 📂 Project Structure
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
├── src/
|
|
71
|
+
│ ├── cli/ # Command Line Interface logic
|
|
72
|
+
│ ├── login/ # Login automation & checkpoint handling
|
|
73
|
+
│ ├── session/ # Session storage & management
|
|
74
|
+
│ ├── utils/ # Helper functions (logger, timing, etc.)
|
|
75
|
+
│ └── config.js # Configuration constants
|
|
76
|
+
├── data/ # Stored session data (cookies, etc.)
|
|
77
|
+
├── logs/ # Application logs
|
|
78
|
+
├── index.js # Entry point
|
|
79
|
+
└── package.json # Dependencies & scripts
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## ⚠️ Disclaimer
|
|
83
|
+
|
|
84
|
+
This tool is for educational purposes only. Automating interactions on LinkedIn violates their User Agreement. Use at your own risk. The authors are not responsible for any account bans or restrictions.
|
package/cli.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const logger = require("./src/utils/logger");
|
|
3
|
+
require("dotenv").config();
|
|
4
|
+
|
|
5
|
+
const loginToLinkedIn = require("./src/login/login");
|
|
6
|
+
const REPL = require("./src/cli/repl");
|
|
7
|
+
|
|
8
|
+
let browserInstance = null;
|
|
9
|
+
|
|
10
|
+
// Cleanup function to be called by REPL or SIGINT
|
|
11
|
+
async function cleanup() {
|
|
12
|
+
if (browserInstance) {
|
|
13
|
+
console.log("Closing browser...");
|
|
14
|
+
try {
|
|
15
|
+
await browserInstance.close();
|
|
16
|
+
console.log("Browser closed.");
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error("Error closing browser:", err.message);
|
|
19
|
+
}
|
|
20
|
+
browserInstance = null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Handle Ctrl+C (SIGINT) for graceful shutdown fallback
|
|
25
|
+
process.on("SIGINT", async () => {
|
|
26
|
+
console.log("\nReceived stop signal.");
|
|
27
|
+
await cleanup();
|
|
28
|
+
process.exit(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
(async () => {
|
|
32
|
+
try {
|
|
33
|
+
// Check command line arguments
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
const isVisible = args.includes("--visible");
|
|
36
|
+
const isHeadless = !isVisible;
|
|
37
|
+
|
|
38
|
+
console.log(`Starting bot in ${isVisible ? "Visible" : "Headless"} Mode...`);
|
|
39
|
+
|
|
40
|
+
const { browser, context, page } = await loginToLinkedIn({ headless: isHeadless });
|
|
41
|
+
browserInstance = browser;
|
|
42
|
+
|
|
43
|
+
console.log("Login successful.");
|
|
44
|
+
|
|
45
|
+
// Start Interactive CLI
|
|
46
|
+
const repl = new REPL({
|
|
47
|
+
browser: browserInstance,
|
|
48
|
+
cleanup: cleanup
|
|
49
|
+
});
|
|
50
|
+
repl.start();
|
|
51
|
+
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error("Error occurred:", error);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
})();
|
package/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const loginToLinkedIn = require('./src/login/login');
|
|
2
|
+
const sessionManager = require('./src/session/sessionManager');
|
|
3
|
+
const config = require('./src/config');
|
|
4
|
+
const logger = require('./src/utils/logger');
|
|
5
|
+
// Exporting the main function and other utilities for library usage
|
|
6
|
+
module.exports = {
|
|
7
|
+
loginToLinkedIn,
|
|
8
|
+
sessionManager,
|
|
9
|
+
config,
|
|
10
|
+
logger
|
|
11
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ubaidbinwaris/linkedin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"linkedin": "./cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"index.js",
|
|
12
|
+
"cli.js",
|
|
13
|
+
"Readme.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
18
|
+
"start": "node cli.js"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/UbaidBinWaris/linkedin.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"linkedin",
|
|
26
|
+
"automation",
|
|
27
|
+
"bot",
|
|
28
|
+
"playwright"
|
|
29
|
+
],
|
|
30
|
+
"author": "UbaidBinWaris",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"type": "commonjs",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/UbaidBinWaris/linkedin/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/UbaidBinWaris/linkedin#readme",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"dotenv": "^17.2.4",
|
|
39
|
+
"playwright": "^1.58.2",
|
|
40
|
+
"playwright-extra": "^4.3.6",
|
|
41
|
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
42
|
+
"user-agents": "^1.1.669",
|
|
43
|
+
"winston": "^3.19.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli/repl.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
|
|
3
|
+
class REPL {
|
|
4
|
+
constructor(context = {}) {
|
|
5
|
+
this.context = context;
|
|
6
|
+
this.rl = readline.createInterface({
|
|
7
|
+
input: process.stdin,
|
|
8
|
+
output: process.stdout,
|
|
9
|
+
prompt: "linkedin-bot> ",
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
start() {
|
|
14
|
+
console.log("Interactive CLI started. Type 'help' for commands.");
|
|
15
|
+
this.rl.prompt();
|
|
16
|
+
|
|
17
|
+
this.rl.on("line", async (line) => {
|
|
18
|
+
const input = line.trim();
|
|
19
|
+
|
|
20
|
+
switch (input) {
|
|
21
|
+
case "help":
|
|
22
|
+
console.log(`
|
|
23
|
+
Available commands:
|
|
24
|
+
status - Show bot status
|
|
25
|
+
exit - Stop the bot and exit
|
|
26
|
+
quit - Alias for exit
|
|
27
|
+
help - Show this help message
|
|
28
|
+
`);
|
|
29
|
+
break;
|
|
30
|
+
|
|
31
|
+
case "status":
|
|
32
|
+
if (this.context.browser && this.context.browser.isConnected()) {
|
|
33
|
+
console.log("Status: 🟢 Connected");
|
|
34
|
+
const pages = this.context.browser.contexts()[0]?.pages().length || 0;
|
|
35
|
+
console.log(`Open pages: ${pages}`);
|
|
36
|
+
} else {
|
|
37
|
+
console.log("Status: 🔴 Disconnected");
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
|
|
41
|
+
case "exit":
|
|
42
|
+
case "quit":
|
|
43
|
+
console.log("Exiting...");
|
|
44
|
+
this.rl.close();
|
|
45
|
+
if (this.context.cleanup) {
|
|
46
|
+
await this.context.cleanup();
|
|
47
|
+
}
|
|
48
|
+
process.exit(0);
|
|
49
|
+
break;
|
|
50
|
+
|
|
51
|
+
case "":
|
|
52
|
+
break;
|
|
53
|
+
|
|
54
|
+
default:
|
|
55
|
+
console.log(`Unknown command: '${input}'. Type 'help' for available commands.`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
this.rl.prompt();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.rl.on("close", () => {
|
|
62
|
+
console.log("\nCLI session ended.");
|
|
63
|
+
process.exit(0);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = REPL;
|
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: 30 * 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,261 @@
|
|
|
1
|
+
const logger = require("../utils/logger");
|
|
2
|
+
// Use playwright-extra with the stealth plugin
|
|
3
|
+
const { chromium } = require("playwright-extra");
|
|
4
|
+
const stealth = require("puppeteer-extra-plugin-stealth")();
|
|
5
|
+
chromium.use(stealth);
|
|
6
|
+
|
|
7
|
+
const { waitForUserResume } = require("../utils/terminal");
|
|
8
|
+
const { sessionExists, saveSession, loadSession } = require("../session/sessionManager");
|
|
9
|
+
const { randomDelay } = require("../utils/time");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validates that necessary environment variables are set.
|
|
13
|
+
* @throws {Error} If credentials are missing.
|
|
14
|
+
*/
|
|
15
|
+
function validateCredentials() {
|
|
16
|
+
if (!process.env.LINKEDIN_EMAIL || !process.env.LINKEDIN_PASSWORD) {
|
|
17
|
+
throw new Error("Missing LINKEDIN_EMAIL or LINKEDIN_PASSWORD in environment variables.");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detects if a checkpoint/verification is triggered.
|
|
23
|
+
* @param {import('playwright').Page} page
|
|
24
|
+
* @returns {Promise<boolean>}
|
|
25
|
+
*/
|
|
26
|
+
async function detectCheckpoint(page) {
|
|
27
|
+
try {
|
|
28
|
+
const url = page.url();
|
|
29
|
+
if (
|
|
30
|
+
url.includes("checkpoint") ||
|
|
31
|
+
url.includes("challenge") ||
|
|
32
|
+
url.includes("verification") ||
|
|
33
|
+
url.includes("consumer-login/error")
|
|
34
|
+
) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Also check for specific checkpoint elements if URL check isn't enough
|
|
39
|
+
// This is a non-blocking check with a short timeout
|
|
40
|
+
try {
|
|
41
|
+
await page.waitForSelector("h1:has-text('Security Verification')", { timeout: 1000 });
|
|
42
|
+
return true;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
// Element not found, likely no checkpoint
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
logger.error(`Error during checkpoint detection: ${error.message}`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generates random viewport dimensions.
|
|
56
|
+
*/
|
|
57
|
+
function getRandomViewport() {
|
|
58
|
+
const width = 1280 + Math.floor(Math.random() * 640); // 1280 - 1920
|
|
59
|
+
const height = 720 + Math.floor(Math.random() * 360); // 720 - 1080
|
|
60
|
+
return { width, height };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generates a random User-Agent string (simplified for now, ideally use a library).
|
|
65
|
+
*/
|
|
66
|
+
function getRandomUserAgent() {
|
|
67
|
+
const versions = ["120.0.0.0", "121.0.0.0", "122.0.0.0"];
|
|
68
|
+
const version = versions[Math.floor(Math.random() * versions.length)];
|
|
69
|
+
return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Main login function.
|
|
74
|
+
* @param {Object} options - Launch options for the browser.
|
|
75
|
+
* @param {boolean} [options.headless=false] - Whether to run in headless mode.
|
|
76
|
+
* @param {number} [options.slowMo=50] - Slow motion delay in ms.
|
|
77
|
+
* @param {string} [options.proxy] - Optional proxy server URL.
|
|
78
|
+
*/
|
|
79
|
+
async function loginToLinkedIn(options = {}) {
|
|
80
|
+
logger.info("Starting LinkedIn login process with stealth mode...");
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
validateCredentials();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
logger.error(error.message);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const launchOptions = {
|
|
90
|
+
headless: options.headless !== undefined ? options.headless : false,
|
|
91
|
+
slowMo: options.slowMo || 50,
|
|
92
|
+
args: [
|
|
93
|
+
"--no-sandbox",
|
|
94
|
+
"--disable-setuid-sandbox",
|
|
95
|
+
"--disable-blink-features=AutomationControlled" // Extra stealth
|
|
96
|
+
],
|
|
97
|
+
...options,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
logger.info(`Launching browser...`);
|
|
101
|
+
const browser = await chromium.launch(launchOptions);
|
|
102
|
+
|
|
103
|
+
let context;
|
|
104
|
+
let page;
|
|
105
|
+
|
|
106
|
+
const contextOptions = {
|
|
107
|
+
userAgent: getRandomUserAgent(),
|
|
108
|
+
viewport: getRandomViewport(),
|
|
109
|
+
locale: 'en-US',
|
|
110
|
+
timezoneId: 'America/New_York', // Align with proxy if used, otherwise standard
|
|
111
|
+
permissions: ['geolocation'],
|
|
112
|
+
ignoreHTTPSErrors: true,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// -----------------------------
|
|
117
|
+
// STEP 1: Try Using Saved Session
|
|
118
|
+
// -----------------------------
|
|
119
|
+
logger.info("Checking for saved session...");
|
|
120
|
+
context = await loadSession(browser, contextOptions);
|
|
121
|
+
|
|
122
|
+
if (context) {
|
|
123
|
+
logger.info("Session stored context created.");
|
|
124
|
+
} else {
|
|
125
|
+
logger.info("No valid session found. Starting fresh context.");
|
|
126
|
+
context = await browser.newContext(contextOptions);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
page = await context.newPage();
|
|
130
|
+
|
|
131
|
+
// Set a default timeout for all actions
|
|
132
|
+
page.setDefaultTimeout(30000);
|
|
133
|
+
|
|
134
|
+
logger.info("Navigating to LinkedIn feed...");
|
|
135
|
+
await page.goto("https://www.linkedin.com/feed/", {
|
|
136
|
+
waitUntil: "domcontentloaded",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Human-like pause
|
|
140
|
+
await randomDelay(2000, 4000);
|
|
141
|
+
|
|
142
|
+
// Check for checkpoint immediately after navigation
|
|
143
|
+
if (await detectCheckpoint(page)) {
|
|
144
|
+
logger.warn("Checkpoint detected immediately. Manual verification required.");
|
|
145
|
+
await waitForUserResume("Complete verification in the opened browser, then press ENTER here to continue...");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { VALIDATION_SELECTORS } = require("../config");
|
|
149
|
+
|
|
150
|
+
// Verify Session Validity
|
|
151
|
+
try {
|
|
152
|
+
// Check for any of the validation selectors
|
|
153
|
+
const isLoggedIn = await Promise.race([
|
|
154
|
+
Promise.any(VALIDATION_SELECTORS.map(selector =>
|
|
155
|
+
page.waitForSelector(selector, { timeout: 15000 }).then(() => true)
|
|
156
|
+
)),
|
|
157
|
+
page.waitForSelector('.login-form, #username, input[name="session_key"]', { timeout: 5000 }).then(() => false)
|
|
158
|
+
]).catch(() => false);
|
|
159
|
+
|
|
160
|
+
if (isLoggedIn) {
|
|
161
|
+
logger.info("Session is valid. Login successful.");
|
|
162
|
+
return { browser, context, page };
|
|
163
|
+
} else {
|
|
164
|
+
logger.info("Session invalid or redirected to login page.");
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
logger.info("Could not verify session state. Proceeding to credential login.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// CRITICAL FIX: If session was invalid, we MUST close the current context
|
|
171
|
+
// and start fresh. Otherwise, retained cookies might cause redirect loops
|
|
172
|
+
// (e.g. we go to /login, but LinkedIn sees cookies and redirects to /feed,
|
|
173
|
+
// so we can't find the email input).
|
|
174
|
+
logger.info("Closing invalid/expired session context...");
|
|
175
|
+
await context.close();
|
|
176
|
+
|
|
177
|
+
logger.info("Starting fresh context for credential login...");
|
|
178
|
+
context = await browser.newContext(contextOptions);
|
|
179
|
+
page = await context.newPage();
|
|
180
|
+
page.setDefaultTimeout(30000);
|
|
181
|
+
|
|
182
|
+
// -----------------------------
|
|
183
|
+
// STEP 2: Credential Login
|
|
184
|
+
// -----------------------------
|
|
185
|
+
logger.info("Proceeding to credential login...");
|
|
186
|
+
|
|
187
|
+
if (!page.url().includes("login") && !page.url().includes("uas/request-password-reset")) {
|
|
188
|
+
await page.goto("https://www.linkedin.com/login", { waitUntil: 'domcontentloaded' });
|
|
189
|
+
await randomDelay(1000, 2000);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const email = process.env.LINKEDIN_EMAIL;
|
|
193
|
+
const password = process.env.LINKEDIN_PASSWORD;
|
|
194
|
+
|
|
195
|
+
logger.info("Entering credentials...");
|
|
196
|
+
|
|
197
|
+
// Simulate human typing
|
|
198
|
+
await page.click('input[name="session_key"]');
|
|
199
|
+
await randomDelay(500, 1000);
|
|
200
|
+
await page.type('input[name="session_key"]', email, { delay: 100 }); // Type with delay
|
|
201
|
+
|
|
202
|
+
await randomDelay(1000, 2000);
|
|
203
|
+
|
|
204
|
+
await page.click('input[name="session_password"]');
|
|
205
|
+
await page.type('input[name="session_password"]', password, { delay: 100 });
|
|
206
|
+
|
|
207
|
+
await randomDelay(1000, 2000);
|
|
208
|
+
|
|
209
|
+
logger.info("Submitting login form...");
|
|
210
|
+
await Promise.all([
|
|
211
|
+
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
|
|
212
|
+
page.click('button[type="submit"]')
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
// Check for checkpoint again
|
|
216
|
+
if (await detectCheckpoint(page)) {
|
|
217
|
+
logger.warn("Checkpoint detected after login attempt. Manual verification required.");
|
|
218
|
+
await waitForUserResume("Complete verification in the opened browser, then press ENTER here to continue...");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// -----------------------------
|
|
222
|
+
// Post-Login Verification
|
|
223
|
+
// -----------------------------
|
|
224
|
+
logger.info("Verifying login success...");
|
|
225
|
+
try {
|
|
226
|
+
await page.waitForURL("**/feed**", { timeout: 20000 });
|
|
227
|
+
|
|
228
|
+
// Wait for at least one validation selector
|
|
229
|
+
await Promise.any(VALIDATION_SELECTORS.map(selector =>
|
|
230
|
+
page.waitForSelector(selector, { timeout: 15000 })
|
|
231
|
+
));
|
|
232
|
+
|
|
233
|
+
logger.info("Login confirmed ✅");
|
|
234
|
+
|
|
235
|
+
// Save session state
|
|
236
|
+
await saveSession(context);
|
|
237
|
+
logger.info("Session state saved 💾");
|
|
238
|
+
|
|
239
|
+
return { browser, context, page };
|
|
240
|
+
|
|
241
|
+
} catch (err) {
|
|
242
|
+
logger.error("Login failed or timed out waiting for feed.");
|
|
243
|
+
const screenshotPath = `error_login_${Date.now()}.png`;
|
|
244
|
+
try {
|
|
245
|
+
await page.screenshot({ path: screenshotPath });
|
|
246
|
+
logger.info(`Screenshot saved to ${screenshotPath}`);
|
|
247
|
+
} catch (opts) {
|
|
248
|
+
console.error("Failed to take error screenshot");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw new Error("Login failed: Could not reach feed page.");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.error(`Critical error in loginToLinkedIn: ${error.message}`);
|
|
256
|
+
if (browser) await browser.close();
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = loginToLinkedIn;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const logger = require("../utils/logger");
|
|
5
|
+
|
|
6
|
+
const SESSION_PATH = path.join(__dirname, "../../data/session.json");
|
|
7
|
+
const ALGORITHM = "aes-256-cbc";
|
|
8
|
+
// Use a fixed key if not provided in env (for development convenience, but ideally should be in env)
|
|
9
|
+
// Ensure the key is 32 bytes.
|
|
10
|
+
const SECRET_KEY = process.env.SESSION_SECRET || "default_insecure_secret_key_32_bytes_long!!";
|
|
11
|
+
|
|
12
|
+
// Ensure key is exactly 32 bytes for aes-256-cbc
|
|
13
|
+
const key = crypto.createHash("sha256").update(String(SECRET_KEY)).digest();
|
|
14
|
+
|
|
15
|
+
function encrypt(text) {
|
|
16
|
+
const iv = crypto.randomBytes(16);
|
|
17
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
18
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
19
|
+
encrypted += cipher.final('hex');
|
|
20
|
+
return iv.toString('hex') + ':' + encrypted;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function decrypt(text) {
|
|
24
|
+
try {
|
|
25
|
+
const textParts = text.split(':');
|
|
26
|
+
const iv = Buffer.from(textParts.shift(), 'hex');
|
|
27
|
+
const encryptedText = textParts.join(':');
|
|
28
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
29
|
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
30
|
+
decrypted += decipher.final('utf8');
|
|
31
|
+
return decrypted;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// If decryption fails, it might be a plain JSON file or invalid key
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sessionExists() {
|
|
39
|
+
return fs.existsSync(SESSION_PATH);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getSessionPath() {
|
|
43
|
+
return SESSION_PATH;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { SESSION_MAX_AGE } = require("../config");
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Saves the browser context storage state to an encrypted file with a timestamp.
|
|
50
|
+
* @param {import('playwright').BrowserContext} context
|
|
51
|
+
*/
|
|
52
|
+
async function saveSession(context) {
|
|
53
|
+
try {
|
|
54
|
+
const state = await context.storageState();
|
|
55
|
+
|
|
56
|
+
// Wrap state with metadata
|
|
57
|
+
const sessionData = {
|
|
58
|
+
timestamp: Date.now(),
|
|
59
|
+
state: state
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const jsonString = JSON.stringify(sessionData);
|
|
63
|
+
const encryptedData = encrypt(jsonString);
|
|
64
|
+
|
|
65
|
+
fs.mkdirSync(path.dirname(SESSION_PATH), { recursive: true });
|
|
66
|
+
fs.writeFileSync(SESSION_PATH, encryptedData, "utf-8");
|
|
67
|
+
logger.info("Session saved successfully (Encrypted & Timestamped) 🔒");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.error(`Failed to save session: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Loads the session from the encrypted file and creates a new context.
|
|
75
|
+
* Checks for session expiry.
|
|
76
|
+
* @param {import('playwright').Browser} browser
|
|
77
|
+
* @param {Object} options - Context options
|
|
78
|
+
* @returns {Promise<import('playwright').BrowserContext>}
|
|
79
|
+
*/
|
|
80
|
+
async function loadSession(browser, options = {}) {
|
|
81
|
+
if (!sessionExists()) {
|
|
82
|
+
logger.info("No session file found.");
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const fileContent = fs.readFileSync(SESSION_PATH, "utf-8");
|
|
88
|
+
let sessionData;
|
|
89
|
+
let state;
|
|
90
|
+
|
|
91
|
+
// Try verifying if it is already JSON (legacy/plain)
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(fileContent);
|
|
94
|
+
|
|
95
|
+
// Check if it matches the new structure { timestamp, state }
|
|
96
|
+
if (parsed.timestamp && parsed.state) {
|
|
97
|
+
sessionData = parsed; // It was plain JSON but with new structure (unlikely but possible)
|
|
98
|
+
} else {
|
|
99
|
+
// It's the old structure (just storageState)
|
|
100
|
+
state = parsed;
|
|
101
|
+
logger.info("Loaded plain JSON session (Legacy).");
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// Not JSON, try decrypting
|
|
105
|
+
const decrypted = decrypt(fileContent);
|
|
106
|
+
if (decrypted) {
|
|
107
|
+
try {
|
|
108
|
+
const parsedDecrypted = JSON.parse(decrypted);
|
|
109
|
+
// Check if it matches the new structure { timestamp, state }
|
|
110
|
+
if (parsedDecrypted.timestamp && parsedDecrypted.state) {
|
|
111
|
+
sessionData = parsedDecrypted;
|
|
112
|
+
} else {
|
|
113
|
+
// It's the old encrypted structure (just storageState)
|
|
114
|
+
state = parsedDecrypted;
|
|
115
|
+
logger.info("Loaded encrypted session (Legacy structure).");
|
|
116
|
+
}
|
|
117
|
+
} catch (parseError) {
|
|
118
|
+
logger.error("Failed to parse decrypted session.");
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
logger.error("Failed to decrypt session file. It might be corrupt or key mismatch.");
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Process new structure if found
|
|
128
|
+
if (sessionData) {
|
|
129
|
+
const age = Date.now() - sessionData.timestamp;
|
|
130
|
+
if (age > SESSION_MAX_AGE) {
|
|
131
|
+
logger.warn(`Session expired. Age: ${age}ms > Max: ${SESSION_MAX_AGE}ms. Rejecting session.`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
state = sessionData.state;
|
|
135
|
+
logger.info(`Loaded active session (Age: ${Math.round(age / 1000 / 60)} mins) 🔓.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const context = await browser.newContext({
|
|
139
|
+
storageState: state,
|
|
140
|
+
...options
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return context;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger.error(`Error loading session: ${error.message}`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
sessionExists,
|
|
152
|
+
getSessionPath,
|
|
153
|
+
saveSession,
|
|
154
|
+
loadSession
|
|
155
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const { createLogger, format, transports } = require("winston");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
|
|
5
|
+
const logDir = path.join(__dirname, "../../logs");
|
|
6
|
+
|
|
7
|
+
if (!fs.existsSync(logDir)) {
|
|
8
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const logger = createLogger({
|
|
12
|
+
level: "info",
|
|
13
|
+
format: format.combine(
|
|
14
|
+
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
|
15
|
+
format.printf(({ timestamp, level, message }) => {
|
|
16
|
+
return `${timestamp} [${level.toUpperCase()}] ${message}`;
|
|
17
|
+
})
|
|
18
|
+
),
|
|
19
|
+
transports: [
|
|
20
|
+
new transports.File({
|
|
21
|
+
filename: path.join(logDir, "error.log"),
|
|
22
|
+
level: "error",
|
|
23
|
+
}),
|
|
24
|
+
new transports.File({
|
|
25
|
+
filename: path.join(logDir, "combined.log"),
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
module.exports = logger;
|
|
@@ -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 };
|