linkedin.js
1 #!/usr/bin/env node 2 3 /** 4 * LinkedIn Outreach Module 5 * Automates LinkedIn messages using Playwright with headed browser 6 */ 7 8 import { join, dirname } from 'path'; 9 import { fileURLToPath } from 'url'; 10 import Logger from '../utils/logger.js'; 11 import { 12 launchStealthBrowser, 13 createPersistentContext, 14 saveProfile, 15 restoreStorage, 16 getNextProfile, 17 } from '../utils/stealth-browser.js'; 18 import { 19 showFloatingMessage, 20 hideFloatingMessage, 21 waitForUser, 22 } from '../utils/browser-notifications.js'; 23 import { isOutreachRetriable, computeRetryAt } from '../utils/error-categories.js'; 24 import { getContactsDataWithFallback, setContactsJson } from '../utils/contacts-storage.js'; 25 import { run, getOne, getAll } from '../utils/db.js'; 26 import '../utils/load-env.js'; 27 28 const __filename = fileURLToPath(import.meta.url); 29 const __dirname = dirname(__filename); 30 const projectRoot = join(__dirname, '../..'); 31 32 const logger = new Logger('LinkedInOutreach'); 33 34 /** 35 * Check if LinkedIn redirected to login page 36 * LinkedIn redirects to /login or /authwall when not logged in 37 */ 38 function isLoginRequired(page, targetUrl) { 39 const currentUrl = page.url(); 40 // Check if we're on a login page (not the target profile) 41 return ( 42 currentUrl.includes('/login') || 43 currentUrl.includes('/authwall') || 44 !currentUrl.includes(new URL(targetUrl).pathname) 45 ); 46 } 47 48 /** 49 * Extract our LinkedIn username (publicIdentifier) 50 */ 51 async function getOurLinkedInUsername(page) { 52 try { 53 const codeElements = await page.locator('code').all(); 54 55 for (const codeElement of codeElements) { 56 const text = await codeElement.textContent(); 57 try { 58 const json = JSON.parse(text); 59 if (json.publicIdentifier) { 60 return json.publicIdentifier; 61 } 62 } catch { 63 // Not JSON or doesn't have publicIdentifier, continue 64 } 65 } 66 67 logger.warn('Could not find publicIdentifier in any CODE elements'); 68 return null; 69 } catch (error) { 70 logger.error('Error extracting LinkedIn username', { error: error.message }); 71 return null; 72 } 73 } 74 75 /** 76 * Get contact's name from their LinkedIn profile 77 */ 78 async function getContactName(page) { 79 try { 80 const h1 = await page.locator('h1').first(); 81 const name = await h1.textContent(); 82 return name?.trim() || null; 83 } catch (error) { 84 logger.error('Error extracting contact name', { error: error.message }); 85 return null; 86 } 87 } 88 89 /** 90 * Update contact name in sites.contacts_json 91 */ 92 function updateContactName(siteId, contactUri, name) { 93 try { 94 const contacts = getContactsDataWithFallback(siteId, null); 95 96 if (!contacts) { 97 logger.warn('No contacts_json found for site', { siteId }); 98 return; 99 } 100 101 // Find the LinkedIn contact and update the label 102 if (contacts.social_profiles) { 103 const linkedInContact = contacts.social_profiles.find(p => p.uri === contactUri); 104 if (linkedInContact && name) { 105 // Prepend name with comma and space 106 if (!linkedInContact.label.includes(',')) { 107 linkedInContact.label = `${linkedInContact.label}, ${name}`; 108 } 109 } 110 } 111 112 setContactsJson(siteId, JSON.stringify(contacts)); 113 114 logger.info('Updated contact name in contacts_json', { siteId, name }); 115 } catch (error) { 116 logger.error('Error updating contact name', { error: error.message }); 117 } 118 } 119 120 /** 121 * Inline equivalent of markOutreachResult() for use while error-categories.js is still 122 * being migrated to PostgreSQL. Mirrors the same retry/terminal logic. 123 * @param {number} messageId 124 * @param {string} errorMessage 125 */ 126 async function markOutreachResultAsync(messageId, errorMessage) { 127 if (isOutreachRetriable(errorMessage)) { 128 const retryAt = computeRetryAt(errorMessage); 129 await run( 130 `UPDATE messages 131 SET delivery_status = 'retry_later', error_message = $1, retry_at = $2 132 WHERE id = $3`, 133 [errorMessage || 'Unknown error', retryAt, messageId] 134 ); 135 } else { 136 await run( 137 `UPDATE messages 138 SET delivery_status = 'failed', error_message = $1 139 WHERE id = $2`, 140 [errorMessage || 'Unknown error', messageId] 141 ); 142 } 143 } 144 145 /** 146 * Send LinkedIn message 147 */ 148 export async function sendLinkedIn(outreachId) { 149 let browser; 150 let page; 151 152 try { 153 // Get outreach data 154 const outreach = await getOne( 155 `SELECT o.*, o.message_body AS proposal_text, s.domain 156 FROM messages o 157 JOIN sites s ON o.site_id = s.id 158 WHERE o.id = $1 AND o.direction = 'outbound'`, 159 [outreachId] 160 ); 161 162 if (!outreach) { 163 throw new Error(`Outreach #${outreachId} not found`); 164 } 165 166 if (outreach.contact_method !== 'linkedin') { 167 throw new Error(`Outreach #${outreachId} is for ${outreach.contact_method}, not LinkedIn`); 168 } 169 170 logger.info('Starting LinkedIn outreach', { 171 outreachId, 172 domain: outreach.domain, 173 uri: outreach.contact_uri, 174 }); 175 176 // Get next profile using LRU rotation 177 const profileName = getNextProfile('linkedin'); 178 logger.info('Using LinkedIn profile', { profileName }); 179 180 // Launch with stealth plugin (headed for social media) 181 browser = await launchStealthBrowser({ 182 headless: false, 183 stealthLevel: 'aggressive', 184 }); 185 186 // Create persistent context (loads saved cookies) 187 const { page: newPage, profileLoaded } = await createPersistentContext( 188 browser, 189 'linkedin', 190 profileName, 191 { viewport: null } 192 ); 193 page = newPage; 194 195 if (profileLoaded) { 196 logger.info('Profile loaded - checking login status', { profileName }); 197 } else { 198 logger.info('New profile - manual login will be required', { profileName }); 199 } 200 201 // Navigate to LinkedIn profile 202 await page.goto(outreach.contact_uri, { waitUntil: 'networkidle' }); 203 204 // Restore localStorage/sessionStorage after navigation 205 await restoreStorage(page, 'linkedin', profileName); 206 207 // Check if login is required (URL will redirect to login page) 208 if (isLoginRequired(page, outreach.contact_uri)) { 209 logger.info('LinkedIn login required (redirected to login page), waiting for user...'); 210 211 await waitForUser( 212 page, 213 'LinkedIn login (page will redirect back to profile after login)', 214 () => !isLoginRequired(page, outreach.contact_uri), 215 2000 216 ); 217 218 logger.info('Login complete - redirected back to profile'); 219 220 // Save profile after successful login 221 await saveProfile(page, 'linkedin', profileName); 222 logger.info('Saved LinkedIn profile after login', { profileName }); 223 } 224 225 // Get our username 226 const ourUsername = await getOurLinkedInUsername(page); 227 if (ourUsername) { 228 logger.info('Detected LinkedIn account', { username: ourUsername }); 229 230 // Update outreach record with our account 231 await run('UPDATE messages SET our_account = $1 WHERE id = $2', [ourUsername, outreachId]); 232 233 // Save username to profile metadata 234 await saveProfile(page, 'linkedin', profileName, { username: ourUsername }); 235 } 236 237 // Get contact's name 238 const contactName = await getContactName(page); 239 if (contactName) { 240 logger.info('Contact name', { name: contactName }); 241 updateContactName(outreach.site_id, outreach.contact_uri, contactName); 242 } 243 244 // Check for Connect button (not yet connected) 245 const connectButton = page.locator('button[jf-ext-button-ct="connect"]').first(); 246 const hasConnectButton = await connectButton.isVisible({ timeout: 2000 }).catch(() => false); 247 248 if (hasConnectButton) { 249 logger.info('Found Connect button, clicking...'); 250 await connectButton.click(); 251 252 // Wait for message input area to appear 253 await page.waitForTimeout(1000); 254 } 255 256 // Check for Message button (already connected) 257 const messageButton = page.locator('button[jf-ext-button-ct="message"]').first(); 258 const hasMessageButton = await messageButton.isVisible({ timeout: 2000 }).catch(() => false); 259 260 if (hasMessageButton) { 261 logger.info('Found Message button, clicking...'); 262 await messageButton.click(); 263 264 // Wait for message compose area 265 await page.waitForTimeout(1000); 266 } 267 268 // Find message input field (LinkedIn typically uses a contenteditable div or textarea) 269 // This selector may need adjustment based on LinkedIn's current UI 270 const messageInput = page 271 .locator('[role="textbox"], textarea, [contenteditable="true"]') 272 .last(); 273 274 // Paste the proposal text 275 await messageInput.click(); 276 await messageInput.fill(outreach.proposal_text); 277 278 logger.info('Proposal text pasted, waiting for user review...'); 279 280 // Show floating message for user review 281 await showFloatingMessage(page, '⏳ Review the message and click Send when ready', { 282 backgroundColor: '#0A66C2', // LinkedIn blue 283 }); 284 285 // Wait for user to send (we'll wait for the page to indicate the message was sent) 286 // User needs to click the Send button manually 287 // We detect send completion by checking if the message input is cleared 288 289 logger.info('Waiting for user to send message...'); 290 291 try { 292 // Wait for message to be sent (detect by checking if input field is cleared or hidden) 293 // LinkedIn clears the input after sending 294 /* eslint-disable no-undef -- document is available in browser context */ 295 await page.waitForFunction( 296 () => { 297 const textboxes = document.querySelectorAll( 298 '[role="textbox"], textarea, [contenteditable="true"]' 299 ); 300 // Check if message area is cleared or hidden 301 for (const box of textboxes) { 302 if (box.textContent && box.textContent.trim().length > 0) { 303 return false; // Still has content, not sent yet 304 } 305 } 306 return true; // All inputs cleared, message sent 307 }, 308 { timeout: 300000 } // 5 minutes max 309 ); 310 /* eslint-enable no-undef */ 311 logger.info('Message sent detected (input cleared)'); 312 } catch { 313 // Timeout - assume user sent it manually 314 logger.warn('Timeout waiting for send confirmation, assuming message was sent'); 315 } 316 317 await hideFloatingMessage(page); 318 319 // Update status to sent 320 await run( 321 'UPDATE messages SET delivery_status = $1, delivered_at = CURRENT_TIMESTAMP WHERE id = $2', 322 ['sent', outreachId] 323 ); 324 325 logger.success('LinkedIn outreach completed', { outreachId }); 326 } catch (error) { 327 logger.error('LinkedIn outreach failed', { 328 outreachId, 329 error: error.message, 330 }); 331 332 // retry_later for transient errors, failed for terminal 333 await markOutreachResultAsync(outreachId, error.message); 334 335 throw error; 336 } finally { 337 if (page) await page.close().catch(() => {}); 338 if (browser) await browser.close().catch(() => {}); 339 } 340 } 341 342 /** 343 * Bulk LinkedIn outreach 344 */ 345 export async function bulkLinkedIn(limit = 10) { 346 const pending = await getAll( 347 `SELECT id FROM messages 348 WHERE contact_method = 'linkedin' 349 AND direction = 'outbound' 350 AND approval_status = 'approved' 351 AND delivery_status IS NULL 352 LIMIT $1`, 353 [limit] 354 ); 355 356 logger.info('Starting bulk LinkedIn outreach', { 357 count: pending.length, 358 limit, 359 }); 360 361 for (const outreach of pending) { 362 try { 363 await sendLinkedIn(outreach.id); 364 } catch (error) { 365 logger.error('Failed to send LinkedIn message', { 366 id: outreach.id, 367 error: error.message, 368 }); 369 // Continue with next outreach 370 } 371 } 372 373 logger.success('Bulk LinkedIn outreach completed'); 374 } 375 376 // CLI support 377 if (import.meta.url === `file://${process.argv[1]}`) { 378 const command = process.argv[2]; 379 const arg = process.argv[3]; 380 381 if (command === 'send' && arg) { 382 sendLinkedIn(parseInt(arg, 10)).catch(error => { 383 logger.error('LinkedIn outreach failed', { error: error.message }); 384 process.exit(1); 385 }); 386 } else if (command === 'bulk') { 387 const limit = arg ? parseInt(arg, 10) : 10; 388 bulkLinkedIn(limit).catch(error => { 389 logger.error('Bulk LinkedIn outreach failed', { error: error.message }); 390 process.exit(1); 391 }); 392 } else { 393 console.log('Usage:'); 394 console.log(' node linkedin.js send <outreach_id>'); 395 console.log(' node linkedin.js bulk [limit]'); 396 process.exit(1); 397 } 398 }