// ==UserScript==
// @name Joblum Board Auto-Apply
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Automates applying to frontend web developer jobs on a job board.
// @author You
// @match *://ru.joblum.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
;(function () {
'use strict'
// Default settings
const defaultSettings = {
positiveKeywords:
'frontend, front-end, react, nextjs, next.js, javascript, typescript, vue, angular',
negativeKeywords:
'native, .NET, Python, Django, PHP, Laravel, mobile, iOS, Android',
autoStart: false
}
// Job stats
let jobStats = {
jobsScanned: 0,
titleMatches: 0,
descriptionMatches: 0,
applicationsSubmitted: 0,
pagesProcessed: 0
}
// State
let processedJobUrls = new Set()
let currentSearchPageUrl = null
let isProcessing = false
let stopRequested = false
let pageChangeTimer = null
// Load settings
function loadSettings() {
const settings = {
positiveKeywords: GM_getValue(
'positiveKeywords',
defaultSettings.positiveKeywords
),
negativeKeywords: GM_getValue(
'negativeKeywords',
defaultSettings.negativeKeywords
),
autoStart: GM_getValue('autoStart', defaultSettings.autoStart)
}
return {
positiveKeywords: settings.positiveKeywords
.split(',')
.map((k) => k.trim().toLowerCase())
.filter((k) => k),
negativeKeywords: settings.negativeKeywords
.split(',')
.map((k) => k.trim().toLowerCase())
.filter((k) => k),
autoStart: settings.autoStart
}
}
// Load saved processed URLs
function loadProcessedUrls() {
const savedUrls = GM_getValue('processedJobUrls', '[]')
try {
processedJobUrls = new Set(JSON.parse(savedUrls))
} catch (e) {
console.error('Error loading processed URLs:', e)
processedJobUrls = new Set()
}
}
// Save processed URLs
function saveProcessedUrls() {
GM_setValue('processedJobUrls', JSON.stringify([...processedJobUrls]))
}
let POSITIVE_KEYWORDS = loadSettings().positiveKeywords
let NEGATIVE_KEYWORDS = loadSettings().negativeKeywords
// Save settings
function saveSettings(settings) {
GM_setValue('positiveKeywords', settings.positiveKeywords)
GM_setValue('negativeKeywords', settings.negativeKeywords)
GM_setValue('autoStart', settings.autoStart)
POSITIVE_KEYWORDS = settings.positiveKeywords
.split(',')
.map((k) => k.trim().toLowerCase())
.filter((k) => k)
NEGATIVE_KEYWORDS = settings.negativeKeywords
.split(',')
.map((k) => k.trim().toLowerCase())
.filter((k) => k)
}
// Save stats
function saveStats() {
GM_setValue('jobStats', JSON.stringify(jobStats))
}
// Load stats
function loadStats() {
const savedStats = GM_getValue('jobStats', null)
if (savedStats) {
jobStats = JSON.parse(savedStats)
}
}
// Utility to check if text matches criteria
function matchesCriteria(text) {
if (!text) return false
const lowerText = text.toLowerCase()
const hasPositive = POSITIVE_KEYWORDS.some((keyword) =>
lowerText.includes(keyword)
)
const hasNegative = NEGATIVE_KEYWORDS.some((keyword) =>
lowerText.includes(keyword)
)
return hasPositive && !hasNegative
}
// Utility to wait for an element
async function waitForElement(selector, timeout = 10000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const element = document.querySelector(selector)
if (element) return element
await new Promise((resolve) => setTimeout(resolve, 100))
}
console.log(`Element not found after timeout: ${selector}`)
return null
}
// Utility to wait for a condition
async function waitForCondition(condition, timeout = 10000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (condition()) return true
if (stopRequested) return false
await new Promise((resolve) => setTimeout(resolve, 100))
}
console.log(`Condition not met after timeout`)
return false
}
// Prevent links from opening in new tabs
function preventNewTabs() {
document.querySelectorAll('a[target="_blank"]').forEach((anchor) => {
anchor.removeAttribute('target')
})
}
// Check if URL is a search results page
function isSearchResultsPage() {
return !!document.querySelector('.content-card.card-has-jobs')
}
// Check if URL is a job details page
function isJobDetailsPage() {
return !!(
document.querySelector('h1.job-title') &&
document.querySelector('span[itemprop="description"]')
)
}
// Check if URL is an application form page
function isApplicationFormPage() {
return !!document.querySelector('form#w1')
}
// Determine current page type and start appropriate process
async function determinePage() {
if (stopRequested) return
if (isSearchResultsPage()) {
console.log('Detected search results page')
await processSearchResults()
} else if (isJobDetailsPage()) {
console.log('Detected job details page')
await processJobDetails(currentSearchPageUrl)
} else if (isApplicationFormPage()) {
console.log('Detected application form page')
await processApplicationForm(currentSearchPageUrl)
} else {
console.log('Not on a recognized job board page')
showNotification('Not on a job board page.', 'warning')
isProcessing = false
updateUI()
}
}
// Utility to delay execution
async function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// Process search results page
async function processSearchResults() {
if (stopRequested) return
preventNewTabs()
currentSearchPageUrl = window.location.href
console.log('Processing search page:', currentSearchPageUrl)
GM_setValue('lastSearchPage', currentSearchPageUrl)
// Wait 10 seconds for translation
console.log('Waiting 10 seconds for page translation...')
await delay(10000) // 10-second delay
const jobWrappers = document.querySelectorAll('.result-wrp.row')
console.log(`Found ${jobWrappers.length} job wrappers`)
const jobs = []
jobStats.pagesProcessed++
saveStats()
updateUI()
for (const wrapper of jobWrappers) {
if (stopRequested) return
jobStats.jobsScanned++
const titleElement = wrapper.querySelector('.job-title a')
if (titleElement) {
const title = titleElement?.title || titleElement.textContent || ''
const link = titleElement.href
if (matchesCriteria(title)) {
jobStats.titleMatches++
if (!processedJobUrls.has(link)) {
console.log(`Found matching job: ${title}`)
jobs.push({ link, title })
} else {
console.log(`Skipping already processed job: ${title}`)
}
}
}
saveStats()
updateUI()
}
if (jobs.length === 0) {
console.log('No matching jobs found on this page, trying next page')
await goToNextPage()
return
}
console.log(`Found ${jobs.length} matching jobs to process`)
await processNextJob(jobs, 0)
}
// Process jobs one by one
async function processNextJob(jobs, index) {
if (stopRequested || index >= jobs.length) {
// If we've processed all jobs, go to next page
if (!stopRequested && index >= jobs.length) {
await goToNextPage()
}
return
}
const job = jobs[index]
console.log(`Processing job ${index + 1}/${jobs.length}: ${job.title}`)
// Mark as processed to avoid duplicates
processedJobUrls.add(job.link)
saveProcessedUrls()
// Navigate to job details
console.log(`Navigating to: ${job.link}`)
window.location.href = job.link
}
// Process job details page
async function processJobDetails(returnUrl) {
if (stopRequested) return
console.log('Processing job details page')
preventNewTabs()
const titleElement = await waitForElement('h1.job-title')
const descriptionElement = await waitForElement(
'span[itemprop="description"]'
)
if (!titleElement || !descriptionElement) {
console.error('Job title or description not found')
returnToSearchPage(returnUrl)
return
}
const jobDetails = {
title: titleElement.textContent || '',
description: descriptionElement.textContent || ''
}
if (
matchesCriteria(jobDetails.title) &&
matchesCriteria(jobDetails.description)
) {
jobStats.descriptionMatches++
saveStats()
updateUI()
console.log('Job matches criteria, attempting to apply')
const respondButton = await waitForElement('.btn.btn-apply.btn-warning')
if (respondButton) {
respondButton.removeAttribute('target')
respondButton.click()
} else {
console.error('Respond button not found')
returnToSearchPage(returnUrl)
}
} else {
console.log('Job does not match full criteria')
returnToSearchPage(returnUrl)
}
}
// Process application form page
async function processApplicationForm(returnUrl) {
if (stopRequested) return
console.log('Processing application form')
const submitButton = await waitForElement(
'button.btn.btn-primary[type="submit"]'
)
if (submitButton) {
submitButton.click()
jobStats.applicationsSubmitted++
saveStats()
updateUI()
console.log('Application submitted successfully')
showNotification('Application submitted!', 'success')
// Wait for application submission to complete
await waitForCondition(
() => !window.location.href.includes('/candidate/apply'),
10000
)
returnToSearchPage(returnUrl)
} else {
console.error('Submit button not found')
showNotification('Failed to submit application.', 'error')
returnToSearchPage(returnUrl)
}
}
// Helper function to return to search page and continue processing
function returnToSearchPage(returnUrl) {
const lastSearchPage = GM_getValue('lastSearchPage', null)
if (returnUrl) {
console.log(`Returning to search page: ${returnUrl}`)
window.location.href = returnUrl
} else if (lastSearchPage) {
console.log(`Returning to last known search page: ${lastSearchPage}`)
window.location.href = lastSearchPage
} else {
console.error('No return URL provided and no last search page saved')
isProcessing = false
updateUI()
}
}
// Navigate to next page
async function goToNextPage() {
if (stopRequested) return
console.log('Looking for next page')
const nextLink = document.querySelector('.pagination .next a')
if (nextLink) {
console.log('Found next page link, clicking...')
nextLink.click()
} else {
console.log('No more pages to process')
stopRequested = true
isProcessing = false
updateUI()
showNotification('No more pages to process. Process complete!', 'info')
}
}
// Monitor for page changes to automatically continue workflow
function setupPageChangeMonitor() {
let lastUrl = window.location.href
// Clear any existing timer
if (pageChangeTimer) {
clearInterval(pageChangeTimer)
}
pageChangeTimer = setInterval(() => {
if (window.location.href !== lastUrl) {
console.log(`Page changed from ${lastUrl} to ${window.location.href}`)
lastUrl = window.location.href
// If we're still processing, determine the current page and continue
if (isProcessing && !stopRequested) {
// Give the page a moment to load
setTimeout(() => determinePage(), 1000)
}
}
}, 500)
}
// Main function
async function main() {
if (isProcessing) {
console.log('Already processing, ignoring start request')
return
}
isProcessing = true
stopRequested = false
updateUI()
showNotification('Workflow started.', 'success')
// Load saved processed URLs
loadProcessedUrls()
try {
// Set up page change monitoring
setupPageChangeMonitor()
// Start processing the current page
await determinePage()
} catch (error) {
console.error('Error in main function:', error)
showNotification('An error occurred: ' + error.message, 'error')
isProcessing = false
updateUI()
}
}
// Reset function
function resetStats() {
jobStats = {
jobsScanned: 0,
titleMatches: 0,
descriptionMatches: 0,
applicationsSubmitted: 0,
pagesProcessed: 0
}
saveStats()
updateUI()
showNotification('Statistics reset.', 'info')
}
// Reset processed jobs
function resetProcessedJobs() {
processedJobUrls = new Set()
saveProcessedUrls()
showNotification('Processed jobs list cleared.', 'info')
}
// Stop processing
function stop() {
stopRequested = true
isProcessing = false
if (pageChangeTimer) {
clearInterval(pageChangeTimer)
pageChangeTimer = null
}
updateUI()
showNotification('Workflow stopped.', 'info')
}
// Notification system
function showNotification(message, type) {
const existing = document.querySelector('.job-auto-apply-notification')
if (existing) existing.remove()
const notification = document.createElement('div')
notification.className = `job-auto-apply-notification notification-${type}`
notification.textContent = message
Object.assign(notification.style, {
position: 'fixed',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
padding: '10px 20px',
borderRadius: '4px',
zIndex: '1000',
color: 'white'
})
switch (type) {
case 'success':
notification.style.backgroundColor = '#4CAF50'
break
case 'error':
notification.style.backgroundColor = '#F44336'
break
case 'warning':
notification.style.backgroundColor = '#FF9800'
break
case 'info':
notification.style.backgroundColor = '#2196F3'
break
}
document.body.appendChild(notification)
setTimeout(() => notification.remove(), 3000)
}
// UI Creation
function createUI() {
GM_addStyle(`
.job-auto-apply-panel {
position: fixed;
top: 10px;
right: 10px;
width: 350px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 9999;
font-family: 'Segoe UI', Arial, sans-serif;
padding: 15px;
}
.job-auto-apply-panel h1 {
font-size: 18px;
margin: 0 0 10px;
color: #333;
}
.status-container {
display: flex;
align-items: center;
margin-bottom: 15px;
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 5px;
}
.status-active { background: #4caf50; }
.status-inactive { background: #f44336; }
.button-container {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.job-auto-apply-panel button {
flex: 1;
padding: 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.job-auto-apply-panel button:hover { opacity: 0.9; }
.job-auto-apply-panel button:active { transform: translateY(1px); }
#startButton { background: #4caf50; color: white; }
#stopButton { background: #f44336; color: white; }
#settingsButton { background: #2196f3; color: white; }
.stats-container {
background: #f9f9f9;
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
}
.stats-title {
font-weight: 600;
margin-bottom: 8px;
}
.stat-item {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.stat-value { font-weight: 600; }
.settings-panel {
display: none;
margin-top: 15px;
}
.settings-panel textarea {
width: 100%;
height: 80px;
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid #ddd;
padding: 5px;
}
.settings-panel label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.auto-start-checkbox {
margin: 10px 0;
}
.advanced-container {
margin-top: 10px;
}
.advanced-button-container {
display: flex;
gap: 10px;
margin-top: 10px;
}
#resetStatsButton { background: #ff9800; color: white; }
#resetJobsButton { background: #9c27b0; color: white; }
`)
const panel = document.createElement('div')
panel.className = 'job-auto-apply-panel'
panel.innerHTML = `
<h1>Job Application Assistant</h1>
<div class="status-container">
<div id="statusIndicator" class="status-indicator status-inactive"></div>
<div id="statusText">Workflow is not running</div>
</div>
<div class="button-container">
<button id="startButton">Start</button>
<button id="stopButton">Stop</button>
<button id="settingsButton">Settings</button>
</div>
<div class="stats-container">
<div class="stats-title">Statistics</div>
<div class="stat-item"><div>Jobs Scanned:</div><div id="jobsScanned" class="stat-value">0</div></div>
<div class="stat-item"><div>Title Matches:</div><div id="titleMatches" class="stat-value">0</div></div>
<div class="stat-item"><div>Description Matches:</div><div id="descriptionMatches" class="stat-value">0</div></div>
<div class="stat-item"><div>Applications Submitted:</div><div id="applicationsSubmitted" class="stat-value">0</div></div>
<div class="stat-item"><div>Pages Processed:</div><div id="pagesProcessed" class="stat-value">0</div></div>
</div>
<div class="advanced-container">
<div class="advanced-button-container">
<button id="resetStatsButton">Reset Stats</button>
<button id="resetJobsButton">Clear Job History</button>
</div>
</div>
<div class="settings-panel" id="settingsPanel">
<label for="positiveKeywords">Positive Keywords:</label>
<textarea id="positiveKeywords"></textarea>
<label for="negativeKeywords">Negative Keywords:</label>
<textarea id="negativeKeywords"></textarea>
<div class="auto-start-checkbox">
<input type="checkbox" id="autoStart"> <label for="autoStart">Auto-start on page load</label>
</div>
<div class="button-container">
<button id="saveSettings">Save</button>
<button id="cancelSettings">Cancel</button>
</div>
</div>
`
document.body.appendChild(panel)
// Event listeners
document
?.getElementById('startButton')
?.addEventListener('click', () => main())
document
?.getElementById('stopButton')
?.addEventListener('click', () => stop())
document
?.getElementById('resetStatsButton')
?.addEventListener('click', () => resetStats())
document
?.getElementById('resetJobsButton')
?.addEventListener('click', () => resetProcessedJobs())
document
?.getElementById('settingsButton')
?.addEventListener('click', () => {
const settingsPanel = document.getElementById('settingsPanel')
settingsPanel.style.display =
settingsPanel?.style.display === 'block' ? 'none' : 'block'
if (settingsPanel?.style.display === 'block') {
document.getElementById('positiveKeywords').value = GM_getValue(
'positiveKeywords',
defaultSettings.positiveKeywords
)
document.getElementById('negativeKeywords').value = GM_getValue(
'negativeKeywords',
defaultSettings.negativeKeywords
)
document.getElementById('autoStart').checked = GM_getValue(
'autoStart',
defaultSettings.autoStart
)
}
})
document.getElementById('saveSettings')?.addEventListener('click', () => {
const settings = {
positiveKeywords: document
.getElementById('positiveKeywords')
?.value.trim(),
negativeKeywords: document
.getElementById('negativeKeywords')
?.value.trim(),
autoStart: document.getElementById('autoStart')?.checked
}
if (!settings.positiveKeywords) {
showNotification(
'Please enter at least one positive keyword.',
'warning'
)
return
}
saveSettings(settings)
document.getElementById('settingsPanel').style.display = 'none'
showNotification('Settings saved!', 'success')
})
document.getElementById('cancelSettings')?.addEventListener('click', () => {
document.getElementById('settingsPanel').style.display = 'none'
})
}
// Update UI
function updateUI() {
document.getElementById('statusIndicator').className = `status-indicator ${
isProcessing ? 'status-active' : 'status-inactive'
}`
document.getElementById('statusText').textContent = isProcessing
? 'Workflow is running'
: 'Workflow is not running'
document.getElementById('jobsScanned').textContent = jobStats.jobsScanned
document.getElementById('titleMatches').textContent = jobStats.titleMatches
document.getElementById('descriptionMatches').textContent =
jobStats.descriptionMatches
document.getElementById('applicationsSubmitted').textContent =
jobStats.applicationsSubmitted
document.getElementById('pagesProcessed').textContent =
jobStats.pagesProcessed
}
// Initialize
function initialize() {
loadStats()
loadProcessedUrls()
createUI()
updateUI()
setupPageChangeMonitor()
const settings = loadSettings()
if (settings.autoStart && !isProcessing) {
// Small delay to ensure page is fully loaded
setTimeout(() => main(), 1500)
}
}
initialize()
})()