/ src / outreach / linkedin.js
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  }