Skip to content

Instantly share code, notes, and snippets.

@MarwanShehata
Created May 17, 2025 16:56
Show Gist options
  • Save MarwanShehata/1d902246ad3afab2e3bd5073f4a6af5f to your computer and use it in GitHub Desktop.
Save MarwanShehata/1d902246ad3afab2e3bd5073f4a6af5f to your computer and use it in GitHub Desktop.
LinkedIn Easy Apply
// ==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