├── .gitignore ├── .env.example ├── package.json ├── answers.json ├── README.md └── linkedin-responder.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | package-lock.json 4 | 5 | .env 6 | cookies.json -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LINKEDIN_MAIL = "" 2 | LINKEDIN_PASSWORD = "" 3 | SIGNATURE = "Linkedin Responder" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "dotenv": "^16.0.2", 4 | "puppeteer": "^17.1.0" 5 | }, 6 | "devDependencies": { 7 | "nodemon": "^2.0.19" 8 | }, 9 | "name": "linkedin-responder", 10 | "version": "1.0.0", 11 | "description": "Responder for Linkedin Standard", 12 | "main": "linkedin-responder.js", 13 | "scripts": { 14 | "start": "node linkedin-responder.js", 15 | "dev": "set HEADLESS=false && nodemon linkedin-responder.js --ignore cookies.json" 16 | }, 17 | "author": "absmoca", 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /answers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "matchs": ["webinar", "coaching session"], 4 | "response": "Hi {firstname},\nThanks for your suggestion but I'm not interested." 5 | }, 6 | { 7 | "isFirstMessage": true, 8 | "response": "Hello {firstname},\nThank you for your message. Unfortunately I get a lot of mugging on linkedin.\n\nIf you want to contact me for a serious proposal contact me here:\ntest@mail.org\n06 00 00 00 00\n\nThank you !" 9 | }, 10 | { 11 | "isFirstMessage": false, 12 | "response": "I will read your message as soon as possible." 13 | } 14 | ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About The Project 2 | 3 | Linkedin responder allows you to have an automatic answering machine for your received messages. 4 | 5 | It is different from the tool offered by LinkedIn Premium, because it is completely customizable and not only an answering machine when you are on leave. 6 | 7 | ## Getting Started 8 | 9 | ### Prerequisites 10 | 11 | This is an example of how to list things you need to use the software and how to install them. 12 | 13 | 1. Installing dependencies 14 | ```sh 15 | npm install 16 | ``` 17 | 18 | 2. Rename **.env.example** to **.env** 19 | 3. Put your email and password 20 | 4. *Optional : SIGNATURE allows you to add a message at the end* 21 | 22 | ### Answers 23 | 24 | Fill in the **answers.json** file with the answers you want. 25 | This is a list with a priority order, put the highest as high as possible. 26 | 27 | - **matchs:** Allows you to detect keywords. 28 | - **IsFirstMessage:** Lets you know if this is his first message 29 | - **response:** Put your answer here 30 | - To jump on the line use *\n* because the json does not allow the multiline 31 | - To add the name of the person use *{name}* 32 | - The person's first name use *{firstname}* 33 | - And the lastname use *{lastname}* 34 | 35 | 36 | ## Usage 37 | 38 | To start the responder run the command 39 | 40 | ```sh 41 | npm run start 42 | ``` 43 | 44 | The recommended way is to use pm2 45 | 46 | ```sh 47 | npm install pm2@latest -g 48 | ``` 49 | 50 | ```sh 51 | pm2 start linkedin-responder.js --name linkedin-responder 52 | ``` 53 | ```sh 54 | pm2 save 55 | ``` 56 | 57 | ## Problems 58 | 59 | - If you have a Captcha at the opening of LinkedIn, launch in headless mode with 60 | >```sh 61 | > npm run dev 62 | >``` 63 | >and once connected you can relaunch normal way. 64 | > 65 | >If you do not have a graphical interface, connect you to your machine and copy the **cookies.json** file to the server. 66 | 67 | 68 | - If you have an error at launch (Error: Failed to launch the browser process!) 69 | > Replace 70 | >```js 71 | >const browser = await puppeteer.launch({ 72 | > headless: !!!process.env.HEADLESS, 73 | > defaultViewport: { 74 | > width: 1000, 75 | > height: 720 76 | > } 77 | >}); 78 | >``` 79 | > 80 | >```js 81 | >const browser = await puppeteer.launch({ 82 | > headless: !!!process.env.HEADLESS, 83 | > defaultViewport: { 84 | > width: 1000, 85 | > height: 720 86 | > }, 87 | > args: ['--no-sandbox'] 88 | >}); 89 | >``` 90 | 91 | 92 | ## Contributing 93 | 94 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue. 95 | 96 | 1. Fork the Project 97 | 2. Create your Feature Branch (git checkout -b feature/AmazingFeature) 98 | 3. Commit your Changes (git commit -m 'Add some AmazingFeature') 99 | 4. Push to the Branch (git push origin feature/AmazingFeature) 100 | 5. Open a Pull Request 101 | 102 | *** 103 | 104 | ## License 105 | 106 | MIT License 107 | 108 | Copyright (c) 2021 Othneil Drew 109 | 110 | Permission is hereby granted, free of charge, to any person obtaining a copy 111 | of this software and associated documentation files (the "Software"), to deal 112 | in the Software without restriction, including without limitation the rights 113 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 114 | copies of the Software, and to permit persons to whom the Software is 115 | furnished to do so, subject to the following conditions: 116 | 117 | The above copyright notice and this permission notice shall be included in all 118 | copies or substantial portions of the Software. 119 | 120 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 121 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 122 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 123 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 124 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 125 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 126 | SOFTWARE. -------------------------------------------------------------------------------- /linkedin-responder.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const fs = require('fs').promises; 3 | require('dotenv').config(); 4 | 5 | (async () => { 6 | const browser = await puppeteer.launch({ 7 | headless: !!!process.env.HEADLESS, 8 | defaultViewport: { 9 | width: 1000, 10 | height: 720 11 | } 12 | }); 13 | const page = (await browser.pages())[0]; 14 | await loadCookie(page); 15 | setInterval(async () => await saveCookie(page), 10000); 16 | 17 | if(process.env.LINKEDIN_MAIL && process.env.LINKEDIN_PASSWORD) { 18 | await authentification( 19 | page, 20 | process.env.LINKEDIN_MAIL, 21 | process.env.LINKEDIN_PASSWORD 22 | ) 23 | .then(() => waitNewMessages(page)) 24 | .catch(e => { 25 | console.log(e); 26 | browser.close(); 27 | }); 28 | } else { 29 | console.log('Please set LINKEDIN_MAIL and LINKEDIN_PASSWORD environment variables'); 30 | browser.close(); 31 | } 32 | })(); 33 | 34 | 35 | /** 36 | * Login to linkedin with mail and password or with cookies 37 | * @param {Page} page 38 | * @param {string} mail 39 | * @param {string} password 40 | * @returns {Promise} 41 | */ 42 | async function authentification(page, mail, password) { 43 | return new Promise(async (resolve, reject) => { 44 | await page.goto('https://www.linkedin.com/'); 45 | 46 | // Test if already logged 47 | try { 48 | await page.waitForSelector('input[name="session_key"]', { timeout: 5000 }); 49 | const response = await page.evaluate((mail, password) => { 50 | return new Promise(async (resolve) => { 51 | document.querySelector('input[name="session_key"]').value = mail; 52 | document.querySelector('input[name="session_password"]').value = password; 53 | document.querySelector('button[type="submit"]').click(); 54 | if(document.querySelector('.alert-content')) { 55 | const errorMessage = document.querySelector('.alert-content').innerText.trim(); 56 | resolve(errorMessage || true); 57 | } else { 58 | resolve(true); 59 | } 60 | }); 61 | }, mail, password); 62 | 63 | if(response !== true) { 64 | reject(response); 65 | return; 66 | } 67 | } catch(e) {} 68 | 69 | try { 70 | await page.waitForSelector('a.global-nav__primary-link[href*="messaging"]', { timeout: 5000 }); 71 | await page.evaluate(() => document.querySelector('a.global-nav__primary-link[href*="messaging"]').click()); 72 | await page.waitForSelector('.msg-conversation-listitem'); 73 | resolve(); 74 | } catch(e) { 75 | page.waitForSelector('#captcha-internal', { timeout: 5000 }) 76 | .then(() => { 77 | if(!!process.env.HEADLESS) { 78 | console.log('Captcha detected, resolve it and wait'); 79 | setTimeout(async () => { 80 | await page.waitForSelector('a.global-nav__primary-link[href*="messaging"]', { timeout: 5000 }); 81 | await page.evaluate(() => document.querySelector('a.global-nav__primary-link[href*="messaging"]').click()); 82 | await page.waitForSelector('.msg-conversation-listitem'); 83 | resolve(); 84 | }, 10000); 85 | } else { 86 | reject('Captcha detected, restart with npm run dev and resolve it'); 87 | } 88 | }) 89 | .catch(() => { 90 | reject('Authentification failed'); 91 | }); 92 | } 93 | }); 94 | } 95 | 96 | /** 97 | * Detect new messages and send to answerMessage 98 | * @param {Page} page 99 | */ 100 | async function waitNewMessages(page) { 101 | let newMessageReceivedTimeout = Date.now(); 102 | let inTyping = false; 103 | 104 | console.log('Waiting for new messages...'); 105 | 106 | page.evaluate(() => { 107 | document.querySelector('.msg-conversations-container__conversations-list').addEventListener('DOMSubtreeModified', () => domUpdated('true')); 108 | }); 109 | 110 | page.on('request', (request) => { 111 | if(request.url().match(/voyagerMessagingDashMessageDelivery/)) { 112 | setTimeout(async () => { 113 | if(Date.now() - newMessageReceivedTimeout > 1500 && !inTyping) { 114 | refreshMessages(page); 115 | } else { 116 | testMessage(page); 117 | } 118 | }, 1000); 119 | } 120 | }); 121 | 122 | page.exposeFunction('domUpdated', async () => newMessageReceivedTimeout = Date.now()); 123 | 124 | page.exposeFunction('refreshEnded', async () => testMessage(page)); 125 | 126 | page.exposeFunction('newMessageReceived', async(name, message, isFirstMessage) => { 127 | if(inTyping) return; 128 | inTyping = true; 129 | 130 | console.log(name, message, isFirstMessage); 131 | 132 | const response = await formatAnswer(name, message, isFirstMessage); 133 | if(response) { 134 | await answerMessage(page, response); 135 | inTyping = false; 136 | } 137 | }); 138 | 139 | function testMessage(page) { 140 | page.evaluate(() => { 141 | document.querySelectorAll('section.msg__list .msg-conversation-listitem a')[0].click(); 142 | setTimeout(() => { 143 | try { 144 | let nbProfils = document.querySelectorAll('.msg-s-message-list-content .msg-s-event-listitem__link').length; 145 | let lastProfil = document.querySelectorAll('.msg-s-message-list-content .msg-s-event-listitem__link')[nbProfils - 1]; 146 | 147 | let nbMessages = document.querySelectorAll('.msg-s-event-listitem__message-bubble').length; 148 | let lastMessage = document.querySelectorAll('.msg-s-event-listitem__message-bubble')[nbMessages - 1]; 149 | 150 | // If last message is minimum 6 caracters and if is not from me 151 | if (lastMessage.innerText.length > 10 && lastProfil.href === document.querySelector('.msg-thread__link-to-profile').href) { 152 | newMessageReceived( 153 | lastProfil.querySelector('img').title, 154 | lastMessage.innerText, 155 | nbProfils <= 2 156 | ); 157 | } 158 | } catch(e) {} 159 | }, 300); 160 | }); 161 | } 162 | } 163 | 164 | /** 165 | * Force refresh messages list because their page is buggy 166 | * @param {Page} page 167 | */ 168 | function refreshMessages(page) { 169 | page.evaluate(() => { 170 | if(document.querySelector('.msg-overlay-list-bubble--is-minimized')) { 171 | document.querySelector('.msg-overlay-bubble-header__details').click(); 172 | } 173 | setTimeout(() => { 174 | document.querySelector('.msg-overlay-bubble-header__controls button').click(); 175 | setTimeout(() => { 176 | document.querySelectorAll('.artdeco-dropdown__item')[2].click(); 177 | setTimeout(() => { 178 | document.querySelector('.msg-message-request-list-header-presenter__back-button').click(); 179 | setTimeout(() => refreshEnded(), 1000); 180 | }, 500); 181 | }, 500); 182 | }, 500); 183 | }); 184 | } 185 | 186 | /** 187 | * Create promise to wait miliseconds 188 | * @param {number} ms 189 | * @returns {Promise} 190 | */ 191 | async function waitForDelay(ms) { 192 | return new Promise(resolve => setTimeout(resolve, ms)); 193 | } 194 | 195 | /** 196 | * Write the answer 197 | * @param {Page} page 198 | * @param {string} message 199 | */ 200 | async function answerMessage(page, message) { 201 | return new Promise(async (resolve) => { 202 | await page.evaluate(() => document.querySelectorAll('section.msg__list .msg-conversation-listitem a')[0].click()); 203 | await waitForDelay(500); 204 | try { 205 | await page.click('.msg-form__contenteditable'); 206 | await page.keyboard.type(message); 207 | await waitForDelay(500); 208 | await page.click('.msg-form__send-button'); 209 | setTimeout(() => resolve(true), 1000); 210 | 211 | /* PATCHED 212 | if (process.env.UNREAD_AFTER_SEND == 'true') { 213 | await waitForDelay(1000); 214 | await page.evaluate(() => { 215 | document.querySelector('.msg-thread-actions__control').click(); 216 | setTimeout(() => { 217 | document.querySelectorAll('.msg-thread-actions__dropdown-container .msg-thread-actions__dropdown-option')[2].click(); 218 | }, 100); 219 | }); 220 | } 221 | */ 222 | } catch(e) { 223 | resolve(false); 224 | } 225 | }); 226 | } 227 | 228 | /** 229 | * Format answer according to the message and add signature 230 | * @param {string} name 231 | * @param {string} message 232 | * @param {boolean} isFirstMessage 233 | * @returns {string} 234 | */ 235 | async function formatAnswer(name, message, isFirstMessage) { 236 | const answers = JSON.parse(await fs.readFile('answers.json')); 237 | let names = name.match(/(.*)\s(.*)/); 238 | let response; 239 | 240 | for(let i = 0; i < answers.length; i++) { 241 | if(answers[i].matchs && (new RegExp('\\b' + answers[i].matchs.join('\\b|\\b') + '\\b', 'i') ).test(message) ) { 242 | if(answers[i].isFirstMessage) { 243 | if(answers[i].isFirstMessage === isFirstMessage) { 244 | response = answers[i].response; 245 | break; 246 | } 247 | } else { 248 | response = answers[i].response; 249 | break; 250 | } 251 | } else { 252 | if(answers[i].isFirstMessage === isFirstMessage) { 253 | response = answers[i].response; 254 | break; 255 | } 256 | } 257 | } 258 | 259 | response = response.replace(/\{name\}/g, name); 260 | response = response.replace(/\{firstname\}/g, names[1]); 261 | response = response.replace(/\{lastname\}/g, names[2]); 262 | 263 | if(process.env.SIGNATURE) { 264 | response += '\n\n' + process.env.SIGNATURE; 265 | } 266 | 267 | return response; 268 | } 269 | 270 | /** 271 | * Save cookies to file 272 | * @param {Page} page 273 | */ 274 | async function saveCookie(page) { 275 | try { 276 | const cookies = await page.cookies(); 277 | const cookieJson = JSON.stringify(cookies, null, 2); 278 | await fs.writeFile('cookies.json', cookieJson); 279 | } catch(e) {} 280 | } 281 | 282 | /** 283 | * Load cookies from file 284 | * @param {Page} page 285 | */ 286 | async function loadCookie(page) { 287 | try { 288 | const cookieJson = await fs.readFile('cookies.json'); 289 | const cookies = JSON.parse(cookieJson); 290 | await page.setCookie(...cookies); 291 | } catch(e) {} 292 | } --------------------------------------------------------------------------------