import { firefox } from 'playwright'; import { existsSync, mkdirSync } from 'fs'; import * as dotenv from 'dotenv'; import pino from 'pino'; import { Page } from 'playwright'; import { Locator } from 'playwright'; dotenv.config(); import path from 'path'; const logger = pino({ transport: { target: "pino-pretty", options: { colorize: true, }, }, }); const STORAGE_PATH = 'linkedin_state.json'; export interface Connection { name: string; // Person's name profileUrl: string; // Base profile URL without query params mutualConnections: number; // Number of mutual connections location?: string; // Location if available headline?: string; // Professional headline if available } const main = async () => { logger.info('Starting LinkedIn bot with Firefox on Linux...'); const browser = await firefox.launch({ headless: true, firefoxUserPrefs: { 'dom.webdriver.enabled': false, 'media.peerconnection.enabled': false, 'useAutomationExtension': false, }, }); const context = existsSync(STORAGE_PATH) ? await browser.newContext({ storageState: STORAGE_PATH, userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:117.0) Gecko/20100101 Firefox/117.0', viewport: { width: 1280, height: 720 }, }) : await browser.newContext({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:117.0) Gecko/20100101 Firefox/117.0', viewport: { width: 1280, height: 720 }, }); const page = await context.newPage(); if (!existsSync(STORAGE_PATH)) { const username = process.env.LINKEDIN_EMAIL; const password = process.env.LINKEDIN_PASSWORD; if (!username || !password) { logger.error('Missing LinkedIn credentials in .env file'); process.exit(1); } logger.info('Navigating to LinkedIn login page...'); await page.goto('https://www.linkedin.com/login'); await page.screenshot({ path: '01-login-page.png' }); // Close possible popups try { const closeSelectors = [ 'button[aria-label="Dismiss"]', 'button[aria-label="Close"]', 'button[data-test-modal-close-button]', '.artdeco-modal__dismiss', ]; for (const selector of closeSelectors) { const btn = await page.$(selector); if (btn) { await btn.click(); await page.waitForTimeout(1000); break; } } } catch (err) { logger.warn('Popup check failed:', err); } // Login input const usernameSelector = 'input[name="session_key"]'; await page.waitForSelector(usernameSelector, { state: 'visible', timeout: 15000 }); await page.click(usernameSelector); await page.waitForTimeout(2000); await page.click(usernameSelector); await page.type(usernameSelector, username, { delay: 100 }); const passwordSelector = 'input[name="session_password"]'; await page.waitForSelector(passwordSelector, { state: 'visible', timeout: 15000 }); await page.click(passwordSelector); await page.waitForTimeout(2000); await page.click(passwordSelector); await page.type(passwordSelector, password, { delay: 100 }); await page.screenshot({ path: '02-filled-credentials.png' }); const submitSelector = 'button[type="submit"]'; await page.waitForSelector(submitSelector, { state: 'visible', timeout: 15000 }); await page.click(submitSelector); await page.screenshot({ path: '03-after-submit.png' }); // Save session state immediately await context.storageState({ path: STORAGE_PATH }); logger.info('Waiting for feed page...'); await page.waitForURL('**/feed*', { timeout: 30000, waitUntil: 'networkidle' }); await page.screenshot({ path: '04-feed.png' }); } else { logger.info('Using saved session.'); await page.goto('https://www.linkedin.com/feed/'); await page.screenshot({ path: 'feed-page-reuse-session.png' }); } const page_conn_test = await context.newPage(); await openConnectionsAndScreenshot(page_conn_test); const page_connections_secondary = await context.newPage(); const connections = await scrapeConnections(page_connections_secondary); logger.info(`Total connections found: ${connections.length}`); // Sort by mutual connections (ascending) and take the first 25 const sortedConnections = connections .sort((a, b) => a.mutualConnections - b.mutualConnections) .slice(0, 25); logger.info(`Selected top 25 connections with least mutual connections:`); sortedConnections.forEach((conn, idx) => { logger.info(`${idx + 1}. ${conn.name} (${conn.mutualConnections} mutual connections)`); }); const page_connection = await context.newPage(); await connectToAllProfiles(page_connection, sortedConnections); logger.info('Waiting 3 seconds before finishing...'); await page.waitForTimeout(3000); await browser.close(); logger.info('Browser closed, done.'); }; main(); export async function openConnectionsAndScreenshot(page: Page) { logger.info('Opening LinkedIn connections page...'); await page.goto('https://www.linkedin.com/search/results/people/?network=%5B%22S%22%5D&origin=MEMBER_PROFILE_CANNED_SEARCH&page=2', { waitUntil: 'domcontentloaded', // faster and avoids hanging timeout: 30000, // reduce from 60s to 30s }); // Let LinkedIn JS load dynamic content await page.waitForTimeout(5000); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const screenshotPath = `linkedin_connections_${timestamp}.png`; await page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`šŸ“ø Screenshot saved to ${screenshotPath}`); } export async function scrapeConnections(page: Page): Promise { logger.info('šŸ“ø Opening LinkedIn connections page...'); // Create pagination directory if it doesn't exist if (!existsSync('pagination')) { mkdirSync('pagination'); } const allConnections: Connection[] = []; let currentPage = 1; while (true) { const url = `https://www.linkedin.com/search/results/people/?network=%5B%22S%22%5D&origin=FACETED_SEARCH&page=${currentPage}`; logger.info(`🌐 Navigating to page ${currentPage}: ${url}`); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000, }); await page.waitForTimeout(3000); // Screenshot const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const screenshotPath = `pagination/page_${currentPage}_${timestamp}.png`; await page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`šŸ“ø Saved screenshot to ${screenshotPath}`); // Wait for search results to load try { await page.waitForSelector('.linked-area', { timeout: 10000 }); } catch (err) { logger.warn('No .linked-area found on page, might be end of results'); break; } // Scrape profile cards - using the correct selector based on the HTML structure const cards = await page.$$('.linked-area'); const pageConnections: Connection[] = []; logger.info(`Found ${cards.length} connection cards on page ${currentPage}`); for (let i = 0; i < cards.length; i++) { try { const card = cards[i]; logger.info(`Processing card ${i + 1}...`); // Check if this profile has a "Message" button (indicating already connected) const messageButtonSelectors = [ 'button[aria-label*="Message"]', 'button:has-text("Message")', '.artdeco-button:has-text("Message")' ]; let hasMessageButton = false; for (const selector of messageButtonSelectors) { const messageButton = await card.$(selector); if (messageButton) { hasMessageButton = true; const ariaLabel = await messageButton.getAttribute('aria-label'); logger.info(`Card ${i + 1}: Found message button with aria-label: "${ariaLabel}"`); break; } } if (hasMessageButton) { logger.info(`Card ${i + 1}: Skipping profile - already connected (has Message button)`); continue; } // Find the profile link - it's an anchor tag with href containing "/in/" const profileLink = await card.$('a[href*="/in/"]'); if (!profileLink) { logger.info(`Card ${i + 1}: No profile link found`); continue; } const rawUrl = await profileLink.getAttribute('href'); if (!rawUrl) { logger.info(`Card ${i + 1}: No href attribute found`); continue; } const cleanUrl = rawUrl.split('?')[0]; logger.info(`Card ${i + 1}: Profile URL = ${cleanUrl}`); // Extract name - try multiple selectors to find the name let name = 'Unknown'; // Try different name selectors based on LinkedIn's structure const nameSelectors = [ 'span[dir="ltr"] span[aria-hidden="true"]', 'a[href*="/in/"] span[aria-hidden="true"]', 'a[data-test-app-aware-link] span[aria-hidden="true"]', '.artdeco-entity-lockup__title a span[aria-hidden="true"]', 'span.ZGqwDIzKYyWZGPNHFVsMdJIrNpzbSChPdgBEBE span[aria-hidden="true"]' ]; for (const selector of nameSelectors) { const nameElement = await card.$(selector); if (nameElement) { const extractedName = (await nameElement.textContent())?.trim(); if (extractedName && extractedName !== '') { name = extractedName; logger.info(`Card ${i + 1}: Found name with selector "${selector}": ${name}`); break; } } } if (name === 'Unknown') { // Try to find any link text within profile links const allProfileLinks = await card.$$('a[href*="/in/"]'); for (const link of allProfileLinks) { const linkText = (await link.textContent())?.trim(); if (linkText && linkText.length > 0 && !linkText.includes('View') && !linkText.includes('mutual')) { name = linkText; logger.info(`Card ${i + 1}: Found name from link text: ${name}`); break; } } } logger.info(`Card ${i + 1}: Name = ${name}`); // Extract location - look for text that appears to be location const locationElements = await card.$$('div.t-14.t-normal'); let location = ''; for (const locEl of locationElements) { const text = (await locEl.textContent())?.trim() || ''; // Location usually contains state abbreviations or city names if (text && (text.includes(',') || text.match(/\b[A-Z]{2}\b/))) { location = text; break; } } logger.info(`Card ${i + 1}: Location = ${location || 'Not found'}`); // Extract headline - usually the first t-14 t-black t-normal div const headlineElement = await card.$('div.t-14.t-black.t-normal'); const headline = headlineElement ? (await headlineElement.textContent())?.trim() || '' : ''; logger.info(`Card ${i + 1}: Headline = ${headline || 'Not found'}`); // Extract mutual connections count let mutualConnections = 0; try { const mutualElement = await card.$('a[href*="facetConnectionOf"] strong'); if (mutualElement) { const mutualText = (await mutualElement.textContent())?.trim() || ''; const match = mutualText.match(/(\d+)/); if (match) { mutualConnections = parseInt(match[1], 10); } logger.info(`Card ${i + 1}: Mutual connections text = "${mutualText}", parsed = ${mutualConnections}`); } else { logger.info(`Card ${i + 1}: No mutual connections element found`); } } catch (err) { logger.info(`Card ${i + 1}: Error extracting mutual connections: ${err}`); } if (name !== 'Unknown' && cleanUrl) { const connection: Connection = { name, profileUrl: cleanUrl, mutualConnections, location: location || undefined, headline: headline || undefined }; logger.info(`Card ${i + 1}: Final connection object:`, connection); pageConnections.push(connection); } else { logger.info(`Card ${i + 1}: Skipped - name: "${name}", URL: "${cleanUrl}"`); // Still add it even if name is Unknown since URLs are valid if (cleanUrl) { const connection: Connection = { name: name === 'Unknown' ? `Profile_${i + 1}` : name, profileUrl: cleanUrl, mutualConnections, location: location || undefined, headline: headline || undefined }; logger.info(`Card ${i + 1}: Adding with fallback name:`, connection); pageConnections.push(connection); } } } catch (err) { logger.warn(`Error processing card ${i + 1}:`, err); continue; } } logger.info(`šŸ“„ Page ${currentPage}: Collected ${pageConnections.length} profiles (after filtering out already connected).`); // Always add connections from this page (even if 0) allConnections.push(...pageConnections); // Only stop if there are no profile cards at all (true end of results) if (cards.length === 0) { logger.info('No profile cards found on this page, reached end of results'); break; } currentPage++; // Add delay before next page to be respectful await page.waitForTimeout(2000); // Stop after reasonable number of pages to avoid infinite loops if (currentPage > 10) { logger.warn('Reached maximum page limit (10), stopping pagination'); break; } } logger.info(`Total connections scraped: ${allConnections.length} (after filtering out already connected profiles)`); return allConnections; } export async function connectToProfile(page: Page, connection: Connection): Promise { logger.info(`Attempting to connect to ${connection.name} at ${connection.profileUrl}`); try { // Navigate to the profile await page.goto(connection.profileUrl, { waitUntil: 'domcontentloaded', timeout: 30000, }); // Wait for page to load await page.waitForTimeout(3000); // Scroll to ensure buttons are in viewport await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 3)); await page.waitForTimeout(1000); // Look for the Connect button const connectButtonSelectors = [ 'button[aria-label*="Invite"][aria-label*="to connect"]', 'button:has-text("Connect")', '.artdeco-button--primary:has-text("Connect")', 'button.artdeco-button--primary[aria-label*="connect"]', 'button.artdeco-button:has-text("Connect")' ]; let connectButton = null; for (const selector of connectButtonSelectors) { try { connectButton = await page.$(selector); if (connectButton) { // Scroll the element into view await connectButton.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); const isVisible = await connectButton.isVisible(); if (isVisible) { logger.info(`Found connect button with selector: ${selector}`); break; } else { logger.info(`Found connect button with selector: ${selector} but not visible, trying next`); connectButton = null; } } } catch (err) { // Continue to next selector continue; } } if (!connectButton) { logger.warn(`No connect button found for ${connection.name}`); return false; } // Check if button is visible and enabled const isVisible = await connectButton.isVisible(); const isEnabled = await connectButton.isEnabled(); if (!isVisible || !isEnabled) { logger.warn(`Connect button not clickable for ${connection.name} (visible: ${isVisible}, enabled: ${isEnabled})`); return false; } // Click the connect button logger.info(`Clicking connect button for ${connection.name}`); await connectButton.click(); // Wait for potential modal to appear await page.waitForTimeout(1500); // Check for the invitation modal try { const modalSelector = '[data-test-modal][role="dialog"]'; const modal = await page.$(modalSelector); if (modal) { logger.info(`Invitation modal appeared for ${connection.name}`); // Look for "Send without a note" button const sendWithoutNoteSelectors = [ 'button[aria-label="Send without a note"]', 'button:has-text("Send without a note")', '.artdeco-button--primary:has-text("Send without a note")' ]; let sendButton = null; for (const selector of sendWithoutNoteSelectors) { try { sendButton = await modal.$(selector); if (sendButton) { logger.info(`Found "Send without a note" button with selector: ${selector}`); break; } } catch (err) { continue; } } if (sendButton) { const isButtonVisible = await sendButton.isVisible(); const isButtonEnabled = await sendButton.isEnabled(); if (isButtonVisible && isButtonEnabled) { logger.info(`Clicking "Send without a note" for ${connection.name}`); await sendButton.click(); // Wait for modal to disappear await page.waitForTimeout(2000); // Verify the modal is gone (success indicator) const modalStillExists = await page.$(modalSelector); if (!modalStillExists) { logger.info(`Successfully sent connection request to ${connection.name}`); return true; } else { logger.warn(`Modal still exists after clicking send for ${connection.name}`); return false; } } else { logger.warn(`Send button not clickable for ${connection.name} (visible: ${isButtonVisible}, enabled: ${isButtonEnabled})`); return false; } } else { logger.warn(`Could not find "Send without a note" button for ${connection.name}`); return false; } } else { // No modal appeared, connection might have been sent directly logger.info(`No modal appeared for ${connection.name}, connection may have been sent directly`); // Wait a bit and check if connect button changed or disappeared await page.waitForTimeout(1000); const updatedConnectButton = await page.$(connectButtonSelectors[0]); if (!updatedConnectButton) { logger.info(`Connect button disappeared for ${connection.name}, likely successful`); return true; } else { // Check if button text changed to something like "Pending" or "Sent" const buttonText = await updatedConnectButton.textContent(); if (buttonText && (buttonText.includes('Pending') || buttonText.includes('Sent'))) { logger.info(`Connect button changed to "${buttonText}" for ${connection.name}, successful`); return true; } else { logger.warn(`Connect button still present with text "${buttonText}" for ${connection.name}`); return false; } } } } catch (err) { logger.error(`Error handling modal for ${connection.name}:`, err); return false; } } catch (err) { logger.error(`Error connecting to ${connection.name}:`, err); return false; } } export async function connectToAllProfiles(page: Page, connections: Connection[]): Promise { logger.info(`Starting connection process for ${connections.length} profiles`); let successCount = 0; let failureCount = 0; let errorCount = 0; const failedConnections: Connection[] = []; const errorConnections: { connection: Connection; error: string }[] = []; for (let i = 0; i < connections.length; i++) { const connection = connections[i]; logger.info(`\n=== Processing ${i + 1}/${connections.length}: ${connection.name} (${connection.mutualConnections} mutual connections) ===`); try { const success = await connectToProfile(page, connection); if (success) { successCount++; logger.info(`āœ… SUCCESS: Connected to ${connection.name} (${successCount} total successes)`); } else { failureCount++; failedConnections.push(connection); logger.warn(`āŒ FAILED: Could not connect to ${connection.name} (${failureCount} total failures)`); } // Add delay between connection attempts to be respectful const delayMs = Math.floor(Math.random() * 3000) + 2000; // 2-5 second random delay logger.info(`ā³ Waiting ${delayMs}ms before next connection attempt...`); await page.waitForTimeout(delayMs); } catch (error) { errorCount++; const errorMessage = error instanceof Error ? error.message : String(error); errorConnections.push({ connection, error: errorMessage }); logger.error(`šŸ’„ ERROR processing ${connection.name}: ${errorMessage}`); // Longer delay after errors to recover logger.info(`ā³ Waiting 5 seconds after error before continuing...`); await page.waitForTimeout(5000); } // Progress update every 10 connections if ((i + 1) % 10 === 0) { logger.info(`\nšŸ“Š PROGRESS UPDATE (${i + 1}/${connections.length}):`); logger.info(` āœ… Successes: ${successCount}`); logger.info(` āŒ Failures: ${failureCount}`); logger.info(` šŸ’„ Errors: ${errorCount}`); logger.info(` šŸ“ˆ Success Rate: ${((successCount / (i + 1)) * 100).toFixed(1)}%`); } } // Final summary logger.info(`\nšŸ FINAL RESULTS:`); logger.info(` Total Processed: ${connections.length}`); logger.info(` āœ… Successful Connections: ${successCount}`); logger.info(` āŒ Failed Connections: ${failureCount}`); logger.info(` šŸ’„ Errors: ${errorCount}`); logger.info(` šŸ“ˆ Overall Success Rate: ${((successCount / connections.length) * 100).toFixed(1)}%`); // Log failed connections for review if (failedConnections.length > 0) { logger.warn(`\nāŒ FAILED CONNECTIONS (${failedConnections.length}):`); failedConnections.forEach((conn, idx) => { logger.warn(` ${idx + 1}. ${conn.name} - ${conn.profileUrl} (${conn.mutualConnections} mutual)`); }); } // Log error connections for debugging if (errorConnections.length > 0) { logger.error(`\nšŸ’„ ERROR CONNECTIONS (${errorConnections.length}):`); errorConnections.forEach((item, idx) => { logger.error(` ${idx + 1}. ${item.connection.name} - Error: ${item.error}`); }); } logger.info(`\nšŸŽ‰ Connection process completed!`); }