diff --git a/job-notifier/background.js b/job-notifier/background.js index aa25740..7163868 100644 --- a/job-notifier/background.js +++ b/job-notifier/background.js @@ -324,6 +324,41 @@ async function notifyNewJob(job) { await sendNtfyNotification(job, companyName); } +// Opens a job's detail page briefly just to read its "Posted X ago" text, +// then closes the tab. Only called for genuinely new jobs, not on every +// 5-minute check, to avoid needlessly opening tabs for jobs we already know about. +async function fetchPostedDate(url) { + if (!url) return null; + let tab; + try { + tab = await chrome.tabs.create({ url: url, active: false }); + await new Promise((resolve) => setTimeout(resolve, 3500)); + const postedDate = await new Promise((resolve) => { + chrome.tabs.sendMessage( + tab.id, + { action: 'extractPostedDate' }, + (response) => { + if (chrome.runtime.lastError) { + log( + 'Error extracting posted date:', + chrome.runtime.lastError.message, + ); + resolve(null); + } else { + resolve((response && response.postedDate) || null); + } + }, + ); + }); + return postedDate; + } catch (err) { + log('Error in fetchPostedDate:', err.message); + return null; + } finally { + if (tab) chrome.tabs.remove(tab.id).catch(() => {}); + } +} + let keepAliveInterval = null; function startKeepAlive() { @@ -354,24 +389,61 @@ async function checkForNewJobs() { const allJobs = await fetchJobsViaContentScript(); const sortedJobs = sortJobsByRecency(allJobs); - const result = await getStoredData(['notifiedJobIds']); + const result = await getStoredData([ + 'notifiedJobIds', + 'postedDates', + 'backfillDone', + ]); const notifiedJobIds = new Set(result.notifiedJobIds || []); + const postedDates = result.postedDates || {}; + const backfillDone = result.backfillDone || false; - log('Previously notified jobs: ' + notifiedJobIds.size); + sortedJobs.forEach((job) => { + if (postedDates[job.id]) job.posted_ago = postedDates[job.id]; + }); - const newJobs = sortedJobs.filter((job) => !notifiedJobIds.has(job.id)); + if (!backfillDone) { + const toBackfill = sortedJobs.filter( + (job) => job.kind === 'opportunity' && !postedDates[job.id], + ); + log( + 'Backfilling posted dates for ' + + toBackfill.length + + ' existing jobs...', + ); + + for (const job of toBackfill) { + const postedDate = await fetchPostedDate(job.url); + if (postedDate) { + job.posted_ago = postedDate; + postedDates[job.id] = postedDate; + } + } + } + + const newJobs = sortedJobs.filter( + (job) => job.kind === 'opportunity' && !notifiedJobIds.has(job.id), + ); log('Found ' + newJobs.length + ' new jobs'); for (const job of newJobs) { + const postedDate = await fetchPostedDate(job.url); + if (postedDate) { + job.posted_ago = postedDate; + postedDates[job.id] = postedDate; + } await notifyNewJob(job); notifiedJobIds.add(job.id); } await setStoredData({ notifiedJobIds: Array.from(notifiedJobIds), + postedDates: postedDates, + backfillDone: true, lastAndelaJobs: sortedJobs, lastAndelaUpdated: new Date().toISOString(), - totalAndelaMatching: sortedJobs.length, + totalAndelaMatching: sortedJobs.filter((j) => j.kind === 'opportunity') + .length, }); log('Notifications stored successfully'); diff --git a/job-notifier/content.js b/job-notifier/content.js index 122ba15..c449484 100644 --- a/job-notifier/content.js +++ b/job-notifier/content.js @@ -8,7 +8,12 @@ function isLoginPage() { var hasLoginForm = !!(emailInput && emailLabel); var isLoginUrl = window.location.pathname.indexOf('/login') === 0; - console.log('[Andela Content Script] Login check - hasForm:', hasLoginForm, 'isLoginUrl:', isLoginUrl); + console.log( + '[Andela Content Script] Login check - hasForm:', + hasLoginForm, + 'isLoginUrl:', + isLoginUrl, + ); return hasLoginForm || (isLoginUrl && !!emailInput); } @@ -28,19 +33,23 @@ function enterEmailAndClickLogin(email) { emailInput.dispatchEvent(new Event('change', { bubbles: true })); emailInput.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); - console.log('[Andela Content Script] Email entered, looking for submit button...'); + console.log( + '[Andela Content Script] Email entered, looking for submit button...', + ); - setTimeout(function() { + setTimeout(function () { var submitButton = document.querySelector('button[type="submit"]'); if (!submitButton) { var buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var btnText = buttons[i].textContent.toLowerCase(); - if (btnText.indexOf('login') !== -1 || - btnText.indexOf('sign in') !== -1 || - btnText.indexOf('continue') !== -1 || - btnText.indexOf('submit') !== -1) { + if ( + btnText.indexOf('login') !== -1 || + btnText.indexOf('sign in') !== -1 || + btnText.indexOf('continue') !== -1 || + btnText.indexOf('submit') !== -1 + ) { submitButton = buttons[i]; break; } @@ -48,10 +57,15 @@ function enterEmailAndClickLogin(email) { } if (submitButton) { - console.log('[Andela Content Script] Clicking submit button:', submitButton.textContent); + console.log( + '[Andela Content Script] Clicking submit button:', + submitButton.textContent, + ); submitButton.click(); } else { - console.log('[Andela Content Script] Submit button not found, trying form submit'); + console.log( + '[Andela Content Script] Submit button not found, trying form submit', + ); var form = emailInput.closest('form'); if (form) { if (form.requestSubmit) form.requestSubmit(); @@ -67,17 +81,61 @@ function enterEmailAndClickLogin(email) { } } +// A section counts as "opportunities" if its heading contains this word — +// matches "Open Full-Stack Engineer opportunities", "Open AI Engineer +// opportunities", etc. Anything else (e.g. "Your applications") is +// treated as already-applied. +function isOpportunitySection(headingText) { + return /opportunit/i.test(headingText || ''); +} + +// Walks the page in DOM order and tags every "See more" button and every +// job-posting link with the heading text of the section it falls under. +function getPageSections() { + var elements = document.querySelectorAll( + 'h3, button, a[href*="/job-postings/"]', + ); + var currentHeading = ''; + var seeMoreButtons = []; // { button, heading } + var jobLinks = []; // { link, heading } + + elements.forEach(function (el) { + if (el.tagName === 'H3') { + currentHeading = el.textContent.trim(); + console.log( + '[Andela Content Script] Section heading found:', + currentHeading, + ); + return; + } + if (el.tagName === 'BUTTON') { + var text = (el.textContent || '').trim().toLowerCase(); + if (text.indexOf('see') === 0 && text.indexOf('more') !== -1) { + seeMoreButtons.push({ button: el, heading: currentHeading }); + } + return; + } + if (el.tagName === 'A') { + jobLinks.push({ link: el, heading: currentHeading }); + } + }); + + return { seeMoreButtons: seeMoreButtons, jobLinks: jobLinks }; +} + +// Only expands "See more" under opportunity sections — leaves the +// applied-jobs section collapsed as-is. function expandAllSeeMoreButtons(maxRounds) { maxRounds = maxRounds || 25; - return new Promise(function(resolve) { + return new Promise(function (resolve) { var rounds = 0; function findSeeMoreButton() { - var buttons = document.querySelectorAll('button'); - for (var i = 0; i < buttons.length; i++) { - var text = (buttons[i].textContent || '').trim().toLowerCase(); - if (text.indexOf('see') === 0 && text.indexOf('more') !== -1) { - return buttons[i]; + var sections = getPageSections(); + for (var i = 0; i < sections.seeMoreButtons.length; i++) { + var entry = sections.seeMoreButtons[i]; + if (isOpportunitySection(entry.heading)) { + return entry.button; } } return null; @@ -87,7 +145,11 @@ function expandAllSeeMoreButtons(maxRounds) { rounds++; var btn = findSeeMoreButton(); if (!btn || rounds > maxRounds) { - console.log('[Andela Content Script] Done expanding "See more" buttons after', rounds - 1, 'click(s)'); + console.log( + '[Andela Content Script] Done expanding opportunity "See more" buttons after', + rounds - 1, + 'click(s)', + ); resolve(); return; } @@ -105,11 +167,15 @@ function extractJobFromLink(link) { var idMatch = href.match(/\/job-postings\/(\d+)/); var id = idMatch ? idMatch[1] : href; - var card = link.closest('div.rounded-lg.border.border-neutral-200.bg-white') || link.parentElement; + var card = + link.closest('div.rounded-lg.border.border-neutral-200.bg-white') || + link.parentElement; var title = ''; var company = ''; - var titleEl = card ? card.querySelector('.text-primary-900.font-semibold') : null; + var titleEl = card + ? card.querySelector('.text-primary-900.font-semibold') + : null; if (titleEl) { var clone = titleEl.cloneNode(true); @@ -125,8 +191,12 @@ function extractJobFromLink(link) { var startInfo = paragraphs[0] ? paragraphs[0].textContent.trim() : ''; var locationInfo = paragraphs[1] ? paragraphs[1].textContent.trim() : ''; - var skillEls = card ? card.querySelectorAll('.flex.flex-wrap.gap-2 span') : []; - var skills = Array.prototype.map.call(skillEls, function(s) { return s.textContent.trim(); }); + var skillEls = card + ? card.querySelectorAll('.flex.flex-wrap.gap-2 span') + : []; + var skills = Array.prototype.map.call(skillEls, function (s) { + return s.textContent.trim(); + }); var url = href; if (url && url.indexOf('http') !== 0) { @@ -140,7 +210,7 @@ function extractJobFromLink(link) { posted_date: startInfo || '', location: locationInfo || '', skills: skills, - url: url + url: url, }; } @@ -148,33 +218,50 @@ function extractJobsFromPage() { console.log('[Andela Content Script] Extracting jobs...'); var jobs = []; var seen = {}; - var links = document.querySelectorAll('a[href*="/job-postings/"]'); - console.log('[Andela Content Script] Found ' + links.length + ' job-posting links'); + var jobLinks = getPageSections().jobLinks; + console.log( + '[Andela Content Script] Found ' + jobLinks.length + ' job-posting links', + ); - links.forEach(function(link) { + jobLinks.forEach(function (entry) { try { - var job = extractJobFromLink(link); + var job = extractJobFromLink(entry.link); if (!job.id || seen[job.id]) return; seen[job.id] = true; + job.section = entry.heading; + job.kind = isOpportunitySection(entry.heading) + ? 'opportunity' + : 'applied'; if (job.title) { jobs.push(job); - console.log('[Andela Content Script] Extracted job:', job.title, '(' + job.id + ')'); + console.log( + '[Andela Content Script] Extracted job:', + job.title, + '(' + job.id + ')', + '-', + job.kind, + ); } } catch (err) { - console.error('[Andela Content Script] Error extracting a job card:', err); + console.error( + '[Andela Content Script] Error extracting a job card:', + err, + ); } }); - console.log('[Andela Content Script] Extracted ' + jobs.length + ' jobs total'); + console.log( + '[Andela Content Script] Extracted ' + jobs.length + ' jobs total', + ); return jobs; } -chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { +chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { console.log('[Andela Content Script] Received message:', request); if (request.action === 'extractJobs') { - expandAllSeeMoreButtons().then(function() { - setTimeout(function() { + expandAllSeeMoreButtons().then(function () { + setTimeout(function () { try { var jobs = extractJobsFromPage(); sendResponse({ jobs: jobs }); @@ -198,5 +285,15 @@ chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { sendResponse({ success: success }); } + if (request.action === 'extractPostedDate') { + var el = document.querySelector('span.text-success-500'); + var postedText = el ? el.textContent.trim() : ''; + console.log( + '[Andela Content Script] Extracted posted date text:', + postedText, + ); + sendResponse({ postedDate: postedText }); + } + return true; }); diff --git a/job-notifier/manifest.json b/job-notifier/manifest.json index d30d926..8b52587 100644 --- a/job-notifier/manifest.json +++ b/job-notifier/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Andela Job Notifier", - "version": "2.0", + "version": "2.1", "description": "Checks for new job opportunities on Andela with auto-login", "permissions": [ "notifications", @@ -40,8 +40,10 @@ "48": "icon48.png", "128": "icon128.png" }, - "web_accessible_resources": [{ - "resources": ["notify.wav"], - "matches": [""] - }] + "web_accessible_resources": [ + { + "resources": ["notify.wav"], + "matches": [""] + } + ] } diff --git a/job-notifier/popup.html b/job-notifier/popup.html index f3556b5..76f55b0 100644 --- a/job-notifier/popup.html +++ b/job-notifier/popup.html @@ -1,104 +1,117 @@ - + - - Andela Job Notifier - - - -

Andela Job Notifier

-
-
- - -
-
-
- - + + Andela Job Notifier + + + +

Andela Job Notifier

+
+
+ + +
+
+
+ + diff --git a/job-notifier/popup.js b/job-notifier/popup.js index 12fffbe..ec7771a 100644 --- a/job-notifier/popup.js +++ b/job-notifier/popup.js @@ -1,7 +1,7 @@ // popup.js function sortJobsById(jobs) { - return jobs.slice().sort(function(a, b) { + return jobs.slice().sort(function (a, b) { return (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0); }); } @@ -11,66 +11,119 @@ function updateUI(data) { var lastAndelaUpdated = data.lastAndelaUpdated; var totalAndelaMatching = data.totalAndelaMatching || 0; - document.getElementById('jobCount').textContent = totalAndelaMatching + ' jobs found'; + document.getElementById('jobCount').textContent = + totalAndelaMatching + ' open opportunities'; var jobList = document.getElementById('jobList'); jobList.innerHTML = ''; if (lastAndelaJobs.length === 0) { - jobList.innerHTML = '
No jobs found yet. Click "Check Now" to scan for jobs.
'; + jobList.innerHTML = + '
No jobs found yet. Click "Check Now" to scan for jobs.
'; } else { - var sortedJobs = sortJobsById(lastAndelaJobs); + var opportunities = sortJobsById( + lastAndelaJobs.filter(function (j) { + return j.kind === 'opportunity'; + }), + ); + var applied = sortJobsById( + lastAndelaJobs.filter(function (j) { + return j.kind !== 'opportunity'; + }), + ); - sortedJobs.slice(0, 10).forEach(function(job) { + function renderJob(job) { var jobElement = document.createElement('div'); jobElement.className = 'job-item'; - var postedDate = job.posted_date || 'Recently posted'; - var companyName = typeof job.company === 'object' ? (job.company && job.company.name || 'Company') : (job.company || 'Company'); + var startInfo = job.posted_date || ''; + var postedAgo = job.posted_ago || ''; + var companyName = + typeof job.company === 'object' + ? (job.company && job.company.name) || 'Company' + : job.company || 'Company'; jobElement.innerHTML = - '
' + job.title + '
' + - '
' + companyName + '
' + - '
' + (job.location || 'Remote') + '
' + - '
' + postedDate + '
'; + '
' + + job.title + + '
' + + '
' + + companyName + + '
' + + '
' + + (job.location || 'Remote') + + '
' + + '
' + + startInfo + + (postedAgo ? ' · ' + postedAgo : '') + + '
'; - jobElement.addEventListener('click', function() { - var url = job.url || ('https://app.andela.com/job-postings/' + job.id); + jobElement.addEventListener('click', function () { + var url = job.url || 'https://app.andela.com/job-postings/' + job.id; chrome.tabs.create({ url: url }); }); jobList.appendChild(jobElement); - }); + } + + if (opportunities.length) { + var oppHeading = document.createElement('div'); + oppHeading.className = 'section-heading section-heading-opportunities'; + oppHeading.textContent = 'Open Opportunities'; + jobList.appendChild(oppHeading); + opportunities.slice(0, 10).forEach(renderJob); + } + + if (applied.length) { + var appliedHeading = document.createElement('div'); + appliedHeading.className = 'section-heading section-heading-applied'; + appliedHeading.textContent = 'Applied'; + jobList.appendChild(appliedHeading); + applied.slice(0, 10).forEach(renderJob); + } } var status = document.getElementById('status'); - status.textContent = lastAndelaUpdated ? - 'Last checked: ' + new Date(lastAndelaUpdated).toLocaleString() : - 'Not checked yet'; + status.textContent = lastAndelaUpdated + ? 'Last checked: ' + new Date(lastAndelaUpdated).toLocaleString() + : 'Not checked yet'; } -document.addEventListener('DOMContentLoaded', function() { - document.getElementById('checkNow').addEventListener('click', function() { +document.addEventListener('DOMContentLoaded', function () { + document.getElementById('checkNow').addEventListener('click', function () { console.log('Manual check triggered'); - chrome.runtime.sendMessage({ action: 'checkNow' }, function(response) { + chrome.runtime.sendMessage({ action: 'checkNow' }, function (response) { console.log('Check initiated'); }); }); - document.getElementById('openSettings').addEventListener('click', function() { - chrome.tabs.create({ url: 'settings.html' }); - }); + document + .getElementById('openSettings') + .addEventListener('click', function () { + chrome.tabs.create({ url: 'settings.html' }); + }); - chrome.storage.local.get(['lastAndelaJobs', 'lastAndelaUpdated', 'totalAndelaMatching'], function(result) { - updateUI(result); - }); + chrome.storage.local.get( + ['lastAndelaJobs', 'lastAndelaUpdated', 'totalAndelaMatching'], + function (result) { + updateUI(result); + }, + ); - chrome.storage.onChanged.addListener(function(changes, areaName) { - if (areaName === 'local' && (changes.lastAndelaJobs || changes.lastAndelaUpdated || changes.totalAndelaMatching)) { + chrome.storage.onChanged.addListener(function (changes, areaName) { + if ( + areaName === 'local' && + (changes.lastAndelaJobs || + changes.lastAndelaUpdated || + changes.totalAndelaMatching) + ) { console.log('Storage updated, refreshing UI...'); - chrome.storage.local.get(['lastAndelaJobs', 'lastAndelaUpdated', 'totalAndelaMatching'], function(result) { - updateUI(result); - }); + chrome.storage.local.get( + ['lastAndelaJobs', 'lastAndelaUpdated', 'totalAndelaMatching'], + function (result) { + updateUI(result); + }, + ); } }); });