Created
May 17, 2025 16:56
-
-
Save MarwanShehata/1d902246ad3afab2e3bd5073f4a6af5f to your computer and use it in GitHub Desktop.
LinkedIn Easy Apply
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name LinkedIn Easy Apply Bot (Enhanced) | |
// @namespace http://tampermonkey.net/ | |
// @version 0.1 | |
// @description Automates applying to "Easy Apply" jobs on LinkedIn with UI and persistence. | |
// @author Your Name & Original Author | |
// @match *://www.linkedin.com/jobs/search/* | |
// @match *://www.linkedin.com/jobs/collections/* | |
// @match *://www.linkedin.com/jobs/view/* | |
// @grant GM_setValue | |
// @grant GM_getValue | |
// @grant GM_addStyle | |
// @run-at document-idle | |
// ==/UserScript== | |
;(function () { | |
'use strict' | |
// --- Default Settings --- | |
const defaultSettings = { | |
jobTitles: | |
'frontend, front-end, react, nextjs, next.js, javascript, typescript, vue, angular, web-developer, web developer, web dev', | |
experienceLevel: '1', // LinkedIn codes: 1 (Internship), 2 (Entry level), 3 (Associate), 4 (Mid-Senior level), 5 (Director), 6 (Executive) | |
radius: '100', // miles | |
location: 'Egypt', | |
geoId: '102571732', // Geo ID for Cairo, Egypt. Find others by inspecting network requests or searching LinkedIn manually. | |
addNote: true, | |
noteTemplate: | |
'I am very interested in the {{jobTitle}} position. My skills in {{skills}} and my background as a recent Computer Science graduate with cloud certifications align well with your requirements. I am eager to contribute to your team.', | |
skillsToHighlight: | |
'AWS services, Azure fundamentals, Terraform, Docker, Kubernetes, CI/CD pipelines, Cloud security', | |
maxApplications: 20, // Safety limit per session | |
autoStart: false, | |
// Delays (in milliseconds) | |
scrollDelay: 2500, | |
actionDelay: 3500, // Delay between major actions (e.g., clicking apply, next step) | |
pageLoadDelay: 5000, // After navigation or major page change | |
nextPageDelay: 6000, | |
// Optional keyword filtering for job titles/descriptions on search results page | |
positiveKeywords: 'frontend, front-end, react, nextjs, next.js, javascript, typescript, vue, angular, web-developer, web developer, web dev', // Comma-separated, e.g., "remote, junior" | |
negativeKeywords: 'senior, lead, principal, C#, native, .NET, Python, Django, PHP, Laravel, mobile, iOS, Android', // Comma-separated | |
// Selectors - these are highly likely to change and need verification | |
searchResultItemSelector: | |
'.jobs-search-results__list-item, .scaffold-layout__list-container .jobs-search-results-list__list-item', //Combined for different views | |
jobTitleInSearchSelector: | |
'.job-card-list__title, .job-card-list__title-line a', | |
companyInSearchSelector: | |
'.job-card-container__primary-description, .job-card-container__company-name', | |
easyApplyButtonInSearchSelector: '.job-card-container__apply-method span', // Check text content for "Easy Apply" | |
easyApplyModalButtonSelector: 'button.jobs-apply-button', // The main "Easy Apply" button once a job is selected | |
submitApplicationButtonSelector: 'button[aria-label="Submit application"]', | |
nextButtonInModalSelector: 'button[aria-label="Continue to next step"]', | |
reviewButtonInModalSelector: 'button[aria-label="Review application"]', // Some forms have a review step | |
followCompanyCheckboxSelector: | |
'input[type="checkbox"][id*="follow-company"]', // To uncheck "Follow company" | |
discardButtonSelector: | |
'button[aria-label="Dismiss"], button.artdeco-modal__dismiss', // For closing modals | |
doneButtonSelector: 'button[data-control-name="done"]', // After successful application | |
// Question handling selectors (examples, will need refinement) | |
experienceQuestionSelector: | |
'.jobs-easy-apply-form-section__grouping label:contains("How many years of work experience")', // This is pseudo-selector, jQuery syntax | |
sponsorshipQuestionSelector: | |
'.jobs-easy-apply-form-section__grouping label:contains("sponsorship")', | |
radioInputSelector: 'input[type="radio"]', | |
textInputSelector: 'input[type="text"], textarea', | |
dropdownSelector: 'select' | |
} | |
// --- Job Stats --- | |
let jobStats = { | |
jobsScannedOnPage: 0, | |
easyApplyFound: 0, | |
applicationsAttempted: 0, | |
applicationsSubmitted: 0, | |
pagesProcessed: 0 | |
} | |
// --- State --- | |
let processedJobIds = new Set() // Store LinkedIn Job IDs (e.g., from data-job-id attribute) | |
let isProcessing = false | |
let stopRequested = false | |
let config = {} // Will hold loaded settings | |
let currentJobTitleForNote = 'this role' | |
// --- Tampermonkey's GM Functions --- | |
function loadSettings() { | |
const loaded = {} | |
for (const key in defaultSettings) { | |
loaded[key] = GM_getValue(key, defaultSettings[key]) | |
} | |
// Parse arrays from strings | |
loaded.jobTitlesArray = loaded.jobTitles | |
.split(',') | |
.map((s) => s.trim()) | |
.filter((s) => s) | |
loaded.skillsToHighlightArray = loaded.skillsToHighlight | |
.split(',') | |
.map((s) => s.trim()) | |
.filter((s) => s) | |
loaded.positiveKeywordsArray = loaded.positiveKeywords | |
.split(',') | |
.map((s) => s.trim().toLowerCase()) | |
.filter((s) => s) | |
loaded.negativeKeywordsArray = loaded.negativeKeywords | |
.split(',') | |
.map((s) => s.trim().toLowerCase()) | |
.filter((s) => s) | |
return loaded | |
} | |
function saveSettings(newSettings) { | |
for (const key in newSettings) { | |
if (key in defaultSettings) { | |
// Only save keys that are part of our settings | |
GM_setValue(key, newSettings[key]) | |
} | |
} | |
config = loadSettings() // Reload config after saving | |
showNotification('Settings saved!', 'success') | |
} | |
function loadProcessedJobIds() { | |
const savedIds = GM_getValue('processedJobIds_linkedin', '[]') | |
try { | |
processedJobIds = new Set(JSON.parse(savedIds)) | |
} catch (e) { | |
console.error('Error loading processed LinkedIn job IDs:', e) | |
processedJobIds = new Set() | |
} | |
} | |
function saveProcessedJobIds() { | |
GM_setValue( | |
'processedJobIds_linkedin', | |
JSON.stringify([...processedJobIds]) | |
) | |
} | |
function loadStats() { | |
const savedStats = GM_getValue('jobStats_linkedin', null) | |
if (savedStats) { | |
try { | |
jobStats = JSON.parse(savedStats) | |
} catch (e) { | |
console.error('Error loading stats', e) | |
} | |
} | |
} | |
function saveStats() { | |
GM_setValue('jobStats_linkedin', JSON.stringify(jobStats)) | |
} | |
// --- Utility Functions --- | |
async function delay(ms) { | |
return new Promise((resolve) => setTimeout(resolve, ms)) | |
} | |
async function waitForElement(selector, timeout = 10000, root = document) { | |
const start = Date.now() | |
while (Date.now() - start < timeout) { | |
const element = root.querySelector(selector) | |
if ( | |
element && | |
(element.offsetWidth > 0 || | |
element.offsetHeight > 0 || | |
element.getClientRects().length > 0) | |
) | |
return element // Check for visibility | |
if (stopRequested) return null | |
await delay(200) | |
} | |
console.log(`Element not found or not visible after timeout: ${selector}`) | |
return null | |
} | |
function matchesKeywords(text, positiveKeywords, negativeKeywords) { | |
if (!text) return false | |
const lowerText = text.toLowerCase() | |
if ( | |
negativeKeywords.length > 0 && | |
negativeKeywords.some((keyword) => lowerText.includes(keyword)) | |
) { | |
return false | |
} | |
if ( | |
positiveKeywords.length > 0 && | |
!positiveKeywords.some((keyword) => lowerText.includes(keyword)) | |
) { | |
return false | |
} | |
return true | |
} | |
// --- Core Bot Logic --- | |
async function navigateToJobsPage() { | |
const keywordsParam = config.jobTitlesArray | |
.map((t) => encodeURIComponent(t)) | |
.join('%20OR%20') | |
const experienceLevels = config.experienceLevel | |
.split(',') | |
.map((e) => `f_E=${e.trim()}`) | |
.join('&') // Supports multiple experience levels | |
const url = `https://www.linkedin.com/jobs/search/?keywords=${keywordsParam}&location=${encodeURIComponent( | |
config.location | |
)}&geoId=${config.geoId}&distance=${ | |
config.radius | |
}&${experienceLevels}&f_AL=true` // f_AL=true for Easy Apply | |
console.log(`Navigating to: ${url}`) | |
showNotification(`Navigating to search results...`, 'info') | |
window.location.href = url | |
await delay(config.pageLoadDelay) // Wait for page to potentially load | |
} | |
async function scrollToBottomLoop() { | |
let SCROLL_ATTEMPTS = 0 | |
let MAX_SCROLL_ATTEMPTS = 15 // Try to scroll 15 times max | |
let lastHeight = 0 | |
let currentHeight = document.body.scrollHeight | |
while (SCROLL_ATTEMPTS < MAX_SCROLL_ATTEMPTS) { | |
if (stopRequested) return | |
lastHeight = currentHeight | |
window.scrollTo(0, document.body.scrollHeight) | |
await delay(config.scrollDelay) | |
currentHeight = document.body.scrollHeight | |
const loadingIndicator = document.querySelector( | |
'.jobs-search-two-pane__show-more-button--visible, .infinite-scroller__show-more-button--visible' | |
) | |
if (lastHeight === currentHeight && !loadingIndicator) { | |
console.log('Scroll to bottom complete or no more new content.') | |
break // No more content is loading | |
} | |
if (loadingIndicator) { | |
console.log("Clicking 'see more jobs' button...") | |
loadingIndicator.click() | |
await delay(config.scrollDelay) | |
} | |
SCROLL_ATTEMPTS++ | |
console.log(`Scrolling... Attempt ${SCROLL_ATTEMPTS}`) | |
} | |
console.log('Finished scrolling attempts.') | |
} | |
async function processJobsOnSearchPage() { | |
if ( | |
stopRequested || | |
jobStats.applicationsSubmitted >= config.maxApplications | |
) | |
return | |
console.log('Preparing page by scrolling to load all jobs...') | |
showNotification('Scrolling to load all jobs...', 'info') | |
await scrollToBottomLoop() | |
jobStats.pagesProcessed++ | |
updateUI() | |
const jobListings = document.querySelectorAll( | |
config.searchResultItemSelector | |
) | |
console.log(`Found ${jobListings.length} job listings on the page.`) | |
showNotification( | |
`Found ${jobListings.length} job listings. Processing...`, | |
'info' | |
) | |
for (const jobElement of jobListings) { | |
if ( | |
stopRequested || | |
jobStats.applicationsSubmitted >= config.maxApplications | |
) | |
break | |
jobStats.jobsScannedOnPage++ | |
const jobTitleElement = jobElement.querySelector( | |
config.jobTitleInSearchSelector | |
) | |
const jobTitle = jobTitleElement | |
? jobTitleElement.textContent.trim() | |
: 'N/A' | |
currentJobTitleForNote = jobTitle // Update for cover letter | |
// Extract Job ID (more reliable than URL for processed checking) | |
// LinkedIn job cards often have a data-entity-urn or similar that contains the job ID. | |
// Example: data-entity-urn="urn:li:jobPosting:3800000000" | |
let jobId = jobElement.getAttribute('data-job-id') // If available directly | |
if (!jobId) { | |
const jobPostingUrn = jobElement.getAttribute('data-entity-urn') | |
if (jobPostingUrn && jobPostingUrn.includes(':jobPosting:')) { | |
jobId = jobPostingUrn.split(':').pop() | |
} | |
} | |
// Fallback to href if no ID found | |
if (!jobId && jobTitleElement && jobTitleElement.href) { | |
const urlParams = new URLSearchParams( | |
new URL(jobTitleElement.href).search | |
) | |
jobId = urlParams.get('currentJobId') || urlParams.get('referenceId') // Common params for job IDs | |
if (!jobId) { | |
// try to get from path | |
const pathSegments = new URL(jobTitleElement.href).pathname.split('/') | |
const viewSegmentIndex = pathSegments.indexOf('view') | |
if ( | |
viewSegmentIndex !== -1 && | |
pathSegments.length > viewSegmentIndex + 1 | |
) { | |
jobId = pathSegments[viewSegmentIndex + 1] | |
} | |
} | |
} | |
if (jobId && processedJobIds.has(jobId)) { | |
console.log( | |
`Skipping already processed job: ${jobTitle} (ID: ${jobId})` | |
) | |
continue | |
} | |
// Optional keyword filtering | |
if ( | |
!matchesKeywords( | |
jobTitle, | |
config.positiveKeywordsArray, | |
config.negativeKeywordsArray | |
) | |
) { | |
console.log(`Skipping job due to keyword filter: ${jobTitle}`) | |
continue | |
} | |
// Check for "Easy Apply" - sometimes it's visible on the card, sometimes only after clicking. | |
// The f_AL=true in search URL should pre-filter, but double check. | |
const easyApplySpan = jobElement.querySelector( | |
config.easyApplyButtonInSearchSelector | |
) | |
const isEasyApplyJob = | |
easyApplySpan && easyApplySpan.textContent.includes('Easy Apply') | |
if (!isEasyApplyJob) { | |
console.log(`Skipping non-Easy Apply job: ${jobTitle}`) | |
// Even if not marked on card, f_AL should mean it is. If not, clicking it will reveal. | |
// For now, we trust f_AL and proceed, the check inside applyToJob is more crucial. | |
} | |
jobStats.easyApplyFound++ | |
updateUI() | |
// Click the job listing to open it in the detail pane | |
console.log(`Clicking job: ${jobTitle}`) | |
jobElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) | |
await delay(1000) // Wait for scroll | |
jobElement.click() | |
await delay(config.actionDelay) // Wait for details pane to load | |
if (jobId) processedJobIds.add(jobId) // Mark as processed *before* attempting apply | |
saveProcessedJobIds() | |
await applyToSelectedJob(jobTitle) | |
if (jobStats.applicationsSubmitted >= config.maxApplications) { | |
showNotification( | |
`Max applications limit (${config.maxApplications}) reached.`, | |
'warning' | |
) | |
stop() | |
break | |
} | |
await delay(config.actionDelay) // Delay before processing next job on the page | |
} | |
if ( | |
!stopRequested && | |
jobStats.applicationsSubmitted < config.maxApplications | |
) { | |
await tryNextPage() | |
} else if (stopRequested) { | |
console.log('Processing stopped by user or limit.') | |
} | |
} | |
async function applyToSelectedJob(jobTitleForLog) { | |
if (stopRequested) return | |
jobStats.applicationsAttempted++ | |
updateUI() | |
console.log(`Attempting to apply to: ${jobTitleForLog}`) | |
showNotification(`Attempting to apply to: ${jobTitleForLog}`, 'info') | |
const easyApplyButton = await waitForElement( | |
config.easyApplyModalButtonSelector, | |
10000, | |
document.querySelector('.jobs-search__job-details--container') || document | |
) // Look within detail pane first | |
if (!easyApplyButton || !easyApplyButton.innerText.includes('Easy Apply')) { | |
// Double check text | |
console.log( | |
'Easy Apply button not found or not an Easy Apply job after selection.' | |
) | |
// Try to close any modal that might have opened if it wasn't an EA job | |
const closeBtn = document.querySelector( | |
'button[aria-label="Dismiss"], button.artdeco-modal__dismiss' | |
) | |
if (closeBtn) closeBtn.click() | |
return | |
} | |
easyApplyButton.click() | |
await delay(config.actionDelay) | |
// --- Application Modal Logic --- | |
let inApplicationModal = true | |
let applicationSteps = 0 | |
const MAX_APP_STEPS = 10 // Safety break for multi-step forms | |
while ( | |
inApplicationModal && | |
applicationSteps < MAX_APP_STEPS && | |
!stopRequested | |
) { | |
applicationSteps++ | |
await delay(config.actionDelay / 2) // Shorter delay for steps within modal | |
// Uncheck "Follow company" if present and checkbox exists | |
const followCheckbox = document.querySelector( | |
config.followCompanyCheckboxSelector | |
) | |
if (followCheckbox && followCheckbox.checked) { | |
console.log("Unchecking 'Follow company'") | |
followCheckbox.click() | |
await delay(500) | |
} | |
// Fill known fields (Note, simple questions) | |
await fillApplicationFormFields(jobTitleForLog) | |
// Look for Next, Review, or Submit button | |
const nextButton = document.querySelector( | |
config.nextButtonInModalSelector | |
) | |
const reviewButton = document.querySelector( | |
config.reviewButtonInModalSelector | |
) | |
const submitButton = document.querySelector( | |
config.submitApplicationButtonSelector | |
) | |
if (submitButton) { | |
console.log('Submit button found. Submitting application...') | |
submitButton.click() | |
await delay(config.actionDelay) // Wait for submission processing | |
// Check for "Done" button or modal close to confirm success | |
const doneButton = await waitForElement(config.doneButtonSelector, 5000) | |
if (doneButton) { | |
doneButton.click() | |
jobStats.applicationsSubmitted++ | |
console.log( | |
`Application #${jobStats.applicationsSubmitted} submitted successfully for: ${jobTitleForLog}` | |
) | |
showNotification( | |
`Application submitted for: ${jobTitleForLog}`, | |
'success' | |
) | |
} else { | |
// If no "Done" button, assume modal closed or submission was fine | |
console.log( | |
`Application likely submitted (no explicit 'Done' button found) for: ${jobTitleForLog}` | |
) | |
jobStats.applicationsSubmitted++ // Optimistic count | |
showNotification( | |
`Application submitted (check LinkedIn) for: ${jobTitleForLog}`, | |
'success' | |
) | |
} | |
inApplicationModal = false // Exit loop | |
} else if (reviewButton) { | |
console.log('Review button found. Clicking to review...') | |
reviewButton.click() | |
// Loop continues, will look for submit on next iteration | |
} else if (nextButton) { | |
console.log('Next button found. Clicking to next step...') | |
nextButton.click() | |
// Loop continues | |
} else { | |
console.log( | |
'No identifiable action button (Next, Review, Submit) found in modal. Closing.' | |
) | |
const discardBtn = document.querySelector(config.discardButtonSelector) | |
if (discardBtn) discardBtn.click() | |
inApplicationModal = false // Exit loop | |
} | |
updateUI() | |
} | |
if (applicationSteps >= MAX_APP_STEPS) { | |
console.warn('Exceeded maximum application steps. Closing modal.') | |
const discardBtn = document.querySelector(config.discardButtonSelector) | |
if (discardBtn) discardBtn.click() | |
} | |
await delay(config.actionDelay) // Wait before next action | |
} | |
async function fillApplicationFormFields(jobTitle) { | |
// Add Note if configured | |
if (config.addNote && config.noteTemplate) { | |
const textAreas = document.querySelectorAll('textarea') // General selector | |
textAreas.forEach((area) => { | |
// Heuristic: if placeholder mentions message, cover letter, note, or additional information | |
if ( | |
area.placeholder.toLowerCase().includes('message') || | |
area.placeholder.toLowerCase().includes('note') || | |
area.placeholder.toLowerCase().includes('cover letter') || | |
area.placeholder.toLowerCase().includes('additional information') | |
) { | |
// Randomly select 2-3 skills to mention | |
const shuffledSkills = [...config.skillsToHighlightArray].sort( | |
() => 0.5 - Math.random() | |
) | |
const selectedSkills = shuffledSkills | |
.slice(0, 2 + Math.floor(Math.random() * 2)) | |
.join(', ') | |
const personalizedNote = config.noteTemplate | |
.replace( | |
'{{jobTitle}}', | |
jobTitle || currentJobTitleForNote || 'this position' | |
) | |
.replace('{{skills}}', selectedSkills || 'my relevant skills') | |
console.log( | |
'Filling text area with note:', | |
personalizedNote.substring(0, 50) + '...' | |
) | |
area.value = personalizedNote | |
area.dispatchEvent(new Event('input', { bubbles: true })) | |
} | |
}) | |
} | |
// Basic question handling (very simplistic, needs specific selectors for robust handling) | |
const questions = document.querySelectorAll( | |
'.jobs-easy-apply-form-section__grouping, .fb-form-element-wrapper' | |
) // Common wrappers for questions | |
questions.forEach((qContainer) => { | |
const labelElement = qContainer.querySelector( | |
'label, .t-14, .fb-form-element-label' | |
) // Common label selectors | |
if (!labelElement) return | |
const questionText = labelElement.textContent.toLowerCase() | |
// Example: Years of experience (select lowest non-zero option or first) | |
if ( | |
questionText.includes('years of experience') || | |
questionText.includes('experience do you have') | |
) { | |
const radioOptions = Array.from( | |
qContainer.querySelectorAll(config.radioInputSelector) | |
) | |
if (radioOptions.length > 0) { | |
let optionToSelect = radioOptions[0] // Default to first | |
// Try to find a low number like "0", "1", "Less than 1 year" | |
const lowExperienceOption = radioOptions.find((r) => { | |
const rLabel = document.querySelector(`label[for="${r.id}"]`) | |
if (rLabel) { | |
const labelText = rLabel.textContent.toLowerCase() | |
return ( | |
labelText.includes('0') || | |
labelText.includes('1 year') || | |
labelText.includes('less than 1') | |
) | |
} | |
return false | |
}) | |
if (lowExperienceOption) optionToSelect = lowExperienceOption | |
else if (radioOptions.length > 1) optionToSelect = radioOptions[1] // Often "0-1" or similar is second | |
console.log( | |
`Answering experience question, selecting: ${ | |
optionToSelect.nextElementSibling | |
? optionToSelect.nextElementSibling.textContent | |
: optionToSelect.id | |
}` | |
) | |
optionToSelect.click() | |
} else { | |
// Try dropdown | |
const select = qContainer.querySelector(config.dropdownSelector) | |
if (select && select.options.length > 1) { | |
select.selectedIndex = 1 // Select first non-placeholder option | |
console.log( | |
`Answering experience question (dropdown), selecting: ${select.options[1].text}` | |
) | |
select.dispatchEvent(new Event('change', { bubbles: true })) | |
} | |
} | |
} | |
// Example: Sponsorship (select "No") | |
else if ( | |
questionText.includes('sponsorship') || | |
questionText.includes('visa') | |
) { | |
const radioOptions = Array.from( | |
qContainer.querySelectorAll(config.radioInputSelector) | |
) | |
const noOption = radioOptions.find((r) => { | |
const rLabel = document.querySelector(`label[for="${r.id}"]`) | |
return ( | |
rLabel && | |
(rLabel.textContent.toLowerCase().includes('no') || | |
rLabel.textContent.toLowerCase().includes('not require')) | |
) | |
}) | |
if (noOption) { | |
console.log("Answering sponsorship question with 'No'") | |
noOption.click() | |
} | |
} | |
// Add more question handlers here based on common LinkedIn questions and their selectors | |
}) | |
} | |
async function tryNextPage() { | |
if ( | |
stopRequested || | |
jobStats.applicationsSubmitted >= config.maxApplications | |
) | |
return | |
const nextButton = document.querySelector( | |
'button[aria-label="Next"], button[aria-label="Load more results"]' | |
) // Common next page buttons | |
if (nextButton && !nextButton.disabled) { | |
console.log('Moving to next page of results...') | |
showNotification('Moving to next page...', 'info') | |
nextButton.click() | |
await delay(config.nextPageDelay) | |
await processJobsOnSearchPage() // Recursively process next page | |
} else { | |
console.log("No more pages or 'Next' button disabled/not found.") | |
showNotification("All pages processed or no 'Next' button found.", 'info') | |
stop() // No more pages, stop the process | |
} | |
} | |
// --- UI Functions --- | |
function createUI() { | |
GM_addStyle(` | |
.lia-panel { | |
position: fixed; top: 100px; right: 10px; width: 350px; background: #fff; | |
border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; | |
padding: 15px; font-size: 14px; | |
} | |
.lia-panel h1 { font-size: 18px; margin: 0 0 15px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px;} | |
.lia-status { display: flex; align-items: center; margin-bottom: 15px; padding: 10px; background: #f9f9f9; border-radius: 4px; } | |
.lia-indicator { width: 12px; height: 12px; border-radius: 50%; margin-right: 8px; } | |
.lia-indicator-active { background: #28a745; } | |
.lia-indicator-inactive { background: #dc3545; } | |
.lia-buttons button { padding: 8px 12px; margin-right: 8px; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: background 0.2s; } | |
.lia-buttons button:hover { opacity: 0.9; } | |
#lia-startButton { background: #28a745; color: white; } | |
#lia-stopButton { background: #dc3545; color: white; } | |
#lia-settingsButton { background: #007bff; color: white; } | |
.lia-stats { background: #f9f9f9; border-radius: 4px; padding: 10px; margin-top: 15px; } | |
.lia-stats-title { font-weight: 600; margin-bottom: 8px; color: #555; } | |
.lia-stat-item { display: flex; justify-content: space-between; margin-bottom: 5px; font-size: 13px; } | |
.lia-stat-value { font-weight: 600; color: #333; } | |
.lia-settings-panel { display: none; margin-top: 15px; border-top: 1px solid #eee; padding-top: 15px; } | |
.lia-settings-panel label { display: block; margin-bottom: 3px; font-weight: 500; color: #555; font-size: 13px; } | |
.lia-settings-panel input[type="text"], .lia-settings-panel input[type="number"], .lia-settings-panel textarea { | |
width: calc(100% - 12px); padding: 6px; margin-bottom: 10px; border-radius: 4px; border: 1px solid #ccc; font-size: 13px; | |
} | |
.lia-settings-panel textarea { min-height: 60px; } | |
.lia-settings-panel .lia-checkbox-group { display: flex; align-items: center; margin-bottom: 10px; } | |
.lia-settings-panel .lia-checkbox-group input { margin-right: 5px; } | |
.lia-notification { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: 5px; color: white; z-index: 10000; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); } | |
.lia-notification-success { background-color: #28a745; } | |
.lia-notification-error { background-color: #dc3545; } | |
.lia-notification-warning { background-color: #ffc107; color: #333; } | |
.lia-notification-info { background-color: #17a2b8; } | |
`) | |
const panel = document.createElement('div') | |
panel.className = 'lia-panel' | |
panel.innerHTML = ` | |
<h1>LinkedIn Easy Apply Bot</h1> | |
<div class="lia-status"> | |
<div id="lia-statusIndicator" class="lia-indicator lia-indicator-inactive"></div> | |
<div id="lia-statusText">Workflow is not running</div> | |
</div> | |
<div class="lia-buttons"> | |
<button id="lia-startButton">Start</button> | |
<button id="lia-stopButton" disabled>Stop</button> | |
<button id="lia-settingsButton">Settings</button> | |
</div> | |
<div class="lia-stats"> | |
<div class="lia-stats-title">Statistics</div> | |
<div class="lia-stat-item">Scanned on Page: <span id="lia-jobsScannedOnPage" class="lia-stat-value">0</span></div> | |
<div class="lia-stat-item">Easy Apply Found: <span id="lia-easyApplyFound" class="lia-stat-value">0</span></div> | |
<div class="lia-stat-item">Apps Attempted: <span id="lia-applicationsAttempted" class="lia-stat-value">0</span></div> | |
<div class="lia-stat-item">Apps Submitted: <span id="lia-applicationsSubmitted" class="lia-stat-value">0</span></div> | |
<div class="lia-stat-item">Pages Processed: <span id="lia-pagesProcessed" class="lia-stat-value">0</span></div> | |
<div class="lia-stat-item">Total Processed IDs: <span id="lia-totalProcessedIds" class="lia-stat-value">0</span></div> | |
</div> | |
<div class="lia-settings-panel" id="lia-settingsPanel"> | |
<label for="lia-jobTitles">Job Titles (comma-separated):</label> | |
<textarea id="lia-jobTitles"></textarea> | |
<label for="lia-location">Location:</label> | |
<input type="text" id="lia-location"> | |
<label for="lia-geoId">Geo ID (for location):</label> | |
<input type="text" id="lia-geoId"> | |
<label for="lia-radius">Search Radius (miles):</label> | |
<input type="number" id="lia-radius" min="5"> | |
<label for="lia-experienceLevel">Experience Levels (e.g., 1,2 for Internship,Entry):</label> | |
<input type="text" id="lia-experienceLevel" placeholder="1=Intern, 2=Entry, 3=Assoc, ..."> | |
<label for="lia-positiveKeywords">Positive Keywords (optional, comma-sep):</label> | |
<input type="text" id="lia-positiveKeywords" placeholder="e.g., remote, junior"> | |
<label for="lia-negativeKeywords">Negative Keywords (optional, comma-sep):</label> | |
<input type="text" id="lia-negativeKeywords" placeholder="e.g., senior, C#"> | |
<label for="lia-noteTemplate">Cover Letter Note Template:</label> | |
<textarea id="lia-noteTemplate" placeholder="Use {{jobTitle}} and {{skills}}"></textarea> | |
<div class="lia-checkbox-group"> | |
<input type="checkbox" id="lia-addNote"> | |
<label for="lia-addNote">Add personalized note</label> | |
</div> | |
<label for="lia-skillsToHighlight">Skills to Highlight (comma-separated):</label> | |
<textarea id="lia-skillsToHighlight"></textarea> | |
<label for="lia-maxApplications">Max Applications Per Session:</label> | |
<input type="number" id="lia-maxApplications" min="1"> | |
<div class="lia-checkbox-group"> | |
<input type="checkbox" id="lia-autoStart"> | |
<label for="lia-autoStart">Auto-start on page load</label> | |
</div> | |
<p style="font-size:12px; color: #777; margin-top:10px;"><b>Delays (ms):</b> scroll, action, page load, next page</p> | |
<input type="number" id="lia-scrollDelay" placeholder="Scroll" style="width:22%; margin-right:2%;"> | |
<input type="number" id="lia-actionDelay" placeholder="Action" style="width:22%; margin-right:2%;"> | |
<input type="number" id="lia-pageLoadDelay" placeholder="Page Load" style="width:22%; margin-right:2%;"> | |
<input type="number" id="lia-nextPageDelay" placeholder="Next Page" style="width:22%;"> | |
<div class="lia-buttons" style="margin-top: 15px;"> | |
<button id="lia-saveSettings">Save Settings</button> | |
<button id="lia-resetSettings">Reset to Defaults</button> | |
<button id="lia-clearProcessed">Clear Processed Job History</button> | |
</div> | |
</div> | |
` | |
document.body.appendChild(panel) | |
attachUIEventListeners() | |
loadSettingsToUI() // Populate UI with loaded/default settings | |
} | |
function attachUIEventListeners() { | |
document.getElementById('lia-startButton').addEventListener('click', start) | |
document.getElementById('lia-stopButton').addEventListener('click', stop) | |
document | |
.getElementById('lia-settingsButton') | |
.addEventListener('click', toggleSettingsPanel) | |
document | |
.getElementById('lia-saveSettings') | |
.addEventListener('click', saveSettingsFromUI) | |
document | |
.getElementById('lia-resetSettings') | |
.addEventListener('click', resetSettingsToDefaults) | |
document | |
.getElementById('lia-clearProcessed') | |
.addEventListener('click', () => { | |
if ( | |
confirm('Are you sure you want to clear all processed job history?') | |
) { | |
processedJobIds.clear() | |
saveProcessedJobIds() | |
jobStats.applicationsSubmitted = 0 // Optionally reset this stat too | |
jobStats.applicationsAttempted = 0 | |
saveStats() | |
updateUI() | |
showNotification('Processed job history cleared.', 'info') | |
} | |
}) | |
} | |
function loadSettingsToUI() { | |
for (const key in defaultSettings) { | |
const element = document.getElementById(`lia-${key}`) | |
if (element) { | |
if (typeof config[key] === 'boolean') { | |
element.checked = config[key] | |
} else { | |
element.value = config[key] | |
} | |
} | |
} | |
} | |
function saveSettingsFromUI() { | |
const newSettings = {} | |
for (const key in defaultSettings) { | |
const element = document.getElementById(`lia-${key}`) | |
if (element) { | |
if (typeof defaultSettings[key] === 'boolean') { | |
newSettings[key] = element.checked | |
} else if (typeof defaultSettings[key] === 'number') { | |
newSettings[key] = parseInt(element.value, 10) || defaultSettings[key] | |
} else { | |
newSettings[key] = element.value | |
} | |
} | |
} | |
saveSettings(newSettings) | |
toggleSettingsPanel() // Hide after saving | |
} | |
function resetSettingsToDefaults() { | |
if (confirm('Reset all settings to default values?')) { | |
saveSettings(defaultSettings) // Save defaults, which also reloads config | |
loadSettingsToUI() // Update UI | |
showNotification('Settings reset to defaults.', 'info') | |
} | |
} | |
function toggleSettingsPanel() { | |
const settingsPanel = document.getElementById('lia-settingsPanel') | |
settingsPanel.style.display = | |
settingsPanel.style.display === 'block' ? 'none' : 'block' | |
if (settingsPanel.style.display === 'block') loadSettingsToUI() // Ensure current settings are shown | |
} | |
function updateUI() { | |
document.getElementById('lia-statusIndicator').className = `lia-indicator ${ | |
isProcessing ? 'lia-indicator-active' : 'lia-indicator-inactive' | |
}` | |
document.getElementById('lia-statusText').textContent = isProcessing | |
? 'Workflow is running' | |
: 'Workflow is not running' | |
document.getElementById('lia-startButton').disabled = isProcessing | |
document.getElementById('lia-stopButton').disabled = !isProcessing | |
document.getElementById('lia-jobsScannedOnPage').textContent = | |
jobStats.jobsScannedOnPage | |
document.getElementById('lia-easyApplyFound').textContent = | |
jobStats.easyApplyFound | |
document.getElementById('lia-applicationsAttempted').textContent = | |
jobStats.applicationsAttempted | |
document.getElementById('lia-applicationsSubmitted').textContent = | |
jobStats.applicationsSubmitted | |
document.getElementById('lia-pagesProcessed').textContent = | |
jobStats.pagesProcessed | |
document.getElementById('lia-totalProcessedIds').textContent = | |
processedJobIds.size | |
} | |
let notificationTimeout | |
function showNotification(message, type = 'info') { | |
// types: success, error, warning, info | |
const existing = document.querySelector('.lia-notification') | |
if (existing) existing.remove() | |
if (notificationTimeout) clearTimeout(notificationTimeout) | |
const notification = document.createElement('div') | |
notification.className = `lia-notification lia-notification-${type}` | |
notification.textContent = message | |
document.body.appendChild(notification) | |
notificationTimeout = setTimeout( | |
() => { | |
notification.remove() | |
}, | |
type === 'error' ? 5000 : 3000 | |
) | |
} | |
// --- Workflow Control --- | |
async function start() { | |
if (isProcessing) { | |
showNotification('Workflow is already running.', 'warning') | |
return | |
} | |
isProcessing = true | |
stopRequested = false | |
jobStats.jobsScannedOnPage = 0 // Reset per-page scan for this run | |
// Don't reset applicationsSubmitted here, it's a persistent stat for the session limit | |
updateUI() | |
showNotification('LinkedIn Easy Apply Bot started!', 'success') | |
// Check if on search results page, if not, navigate | |
if (!window.location.href.includes('/jobs/search/')) { | |
await navigateToJobsPage() | |
// Wait for navigation to complete before starting job processing | |
await delay(config.pageLoadDelay) // Give time for URL to change and page to load | |
if (window.location.href.includes('/jobs/search/')) { | |
// Check again | |
await processJobsOnSearchPage() | |
} else { | |
showNotification( | |
'Failed to navigate to job search page. Please navigate manually and restart.', | |
'error' | |
) | |
stop() | |
return | |
} | |
} else { | |
await processJobsOnSearchPage() | |
} | |
if (!stopRequested) { | |
// If not stopped by limit or error | |
showNotification('Workflow finished or no more jobs.', 'info') | |
} | |
stop() // Ensure state is reset if loop finishes naturally | |
} | |
function stop() { | |
isProcessing = false | |
stopRequested = true | |
updateUI() | |
showNotification('LinkedIn Easy Apply Bot stopped.', 'info') | |
console.log('LinkedIn Bot stopped.') | |
} | |
// --- Initialization --- | |
function initialize() { | |
config = loadSettings() | |
loadStats() | |
loadProcessedJobIds() | |
createUI() | |
updateUI() // Initial UI setup | |
if (config.autoStart && window.location.href.includes('/jobs/search/')) { | |
// Delay auto-start slightly to ensure page is fully interactive | |
showNotification('Auto-starting in 3 seconds...', 'info') | |
setTimeout(start, 3000) | |
} | |
} | |
// Wait for a moment for the page to be less busy, especially if @run-at document-idle isn't enough | |
if (document.readyState === 'complete') { | |
initialize() | |
} else { | |
window.addEventListener('load', initialize) | |
} | |
})() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment