const FAST_CHECK_INTERVAL = 5; // minutes const MULTI_TITLE_INTERVAL = 30; // minutes const DEBUG = true; const JOBS_URL = 'https://app.andela.com/'; const PROFILE_URL = 'https://app.andela.com/profile'; const NOTIFICATION_ICON = 'icon128.png'; // Full-Stack must stay first (it's the default we always reset back to). const PROFESSIONAL_TITLES = [ 'Full-Stack Engineer', 'AI Engineer', 'Data Scientist', 'Front-End Engineer', ]; const DEFAULT_TITLE = 'Full-Stack Engineer'; const DEFAULT_NTFY_SERVER = 'https://notify.tstitagency.com'; const DEFAULT_NTFY_TOPIC = 'andela-jobs'; let isLoggingIn = false; let activeOperation = null; // null | 'fast' | 'sweep' — only one tab operation runs at a time function log(...args) { if (DEBUG) console.log('[Andela Notifier]', ...args); } async function getConfig() { return new Promise((resolve) => { chrome.storage.local.get( [ 'appsScriptUrl', 'appsScriptKey', 'loginEmail', 'ntfyServer', 'ntfyTopic', 'ntfyToken', ], (result) => { resolve({ url: result.appsScriptUrl || '', key: result.appsScriptKey || '', email: result.loginEmail || '', ntfyServer: result.ntfyServer || DEFAULT_NTFY_SERVER, ntfyTopic: result.ntfyTopic || DEFAULT_NTFY_TOPIC, ntfyToken: result.ntfyToken || '', }); }, ); }); } async function createOffscreen() { if (await chrome.offscreen.hasDocument()) return; await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['AUDIO_PLAYBACK'], justification: 'Playing notification sound', }); } async function getStoredData(keys) { return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); } async function setStoredData(data) { return new Promise((resolve) => chrome.storage.local.set(data, resolve)); } async function checkIfLoginPage(tabId) { return new Promise((resolve) => { chrome.tabs.sendMessage(tabId, { action: 'checkLoginPage' }, (response) => { if (chrome.runtime.lastError) resolve(false); else resolve(response?.isLoginPage || false); }); }); } async function getCurrentProfileTitle(tabId) { return new Promise((resolve) => { chrome.tabs.sendMessage( tabId, { action: 'getCurrentProfileTitle' }, (response) => { if (chrome.runtime.lastError) resolve(''); else resolve((response && response.title) || ''); }, ); }); } async function setProfileTitle(tabId, title) { await chrome.tabs.update(tabId, { url: PROFILE_URL }); await new Promise((resolve) => setTimeout(resolve, 3000)); return new Promise((resolve) => { chrome.tabs.sendMessage( tabId, { action: 'setProfessionalTitle', title: title }, (response) => { if (chrome.runtime.lastError) { log('setProfileTitle error:', chrome.runtime.lastError.message); resolve(false); } else { resolve((response && response.success) || false); } }, ); }); } async function handleAutoLogin(tab) { isLoggingIn = true; try { const config = await getConfig(); if (!config.url || !config.key || !config.email) { log('Missing configuration! Please go to extension settings.'); chrome.notifications.create('config-missing', { type: 'basic', iconUrl: NOTIFICATION_ICON, title: 'Andela Notifier - Setup Required', message: 'Please configure your settings (Apps Script URL, Key, Email)', priority: 2, }); isLoggingIn = false; return false; } const emailEntered = await new Promise((resolve) => { chrome.tabs.sendMessage( tab.id, { action: 'enterEmailAndLogin', email: config.email }, (response) => { if (chrome.runtime.lastError) resolve(false); else resolve(response?.success || false); }, ); }); if (!emailEntered) { log('Failed to enter email'); isLoggingIn = false; return false; } const requestId = 'login-' + Date.now(); const response = await fetch(config.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'pollForLoginLink', email: config.email, key: config.key, requestId: requestId, }), }); const result = await response.json(); log('Apps Script response:', result); if ( result.error === 'Unauthorized' || result.error === 'Unauthorized email' ) { chrome.notifications.create('auth-failed', { type: 'basic', iconUrl: NOTIFICATION_ICON, title: 'Andela Notifier - Auth Failed', message: 'Secret key or email mismatch. Check your settings.', priority: 2, }); isLoggingIn = false; return false; } if (!result.success || !result.loginLink) { log('No magic link received from Apps Script'); isLoggingIn = false; return false; } await chrome.tabs.update(tab.id, { url: result.loginLink }); await new Promise((resolve) => setTimeout(resolve, 5000)); const stillOnLogin = await checkIfLoginPage(tab.id); if (stillOnLogin) { log('Still on login page after magic link, login may have failed'); isLoggingIn = false; return false; } log('Login successful!'); isLoggingIn = false; return true; } catch (error) { log('Error in handleAutoLogin:', error.message); isLoggingIn = false; return false; } } // Opens/reuses the Andela tab and handles login if needed. Shared by both // the fast check and the multi-title sweep. async function getReadyTab() { const tabs = await chrome.tabs.query({ url: 'https://app.andela.com/*' }); let tab; let createdNewTab = false; if (tabs.length === 0) { tab = await chrome.tabs.create({ url: JOBS_URL, active: false }); createdNewTab = true; await new Promise((resolve) => setTimeout(resolve, 5000)); } else { tab = tabs[0]; await chrome.tabs.update(tab.id, { url: JOBS_URL }); await new Promise((resolve) => setTimeout(resolve, 4000)); } const isLoginPage = await checkIfLoginPage(tab.id); if (isLoginPage) { if (isLoggingIn) { if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {}); return null; } const loginSuccess = await handleAutoLogin(tab); if (!loginSuccess) { if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {}); return null; } log('Login successful, waiting 2 minutes before resuming...'); await new Promise((resolve) => setTimeout(resolve, 120000)); await chrome.tabs.update(tab.id, { url: JOBS_URL }); await new Promise((resolve) => setTimeout(resolve, 4000)); } return { tab, createdNewTab }; } // Fast path: scrapes whatever profile title is currently active, no switching. async function fetchJobsFast() { log('fetchJobsFast: Starting...'); const ready = await getReadyTab(); if (!ready) return []; const { tab, createdNewTab } = ready; const jobs = await new Promise((resolve) => { chrome.tabs.sendMessage(tab.id, { action: 'extractJobs' }, (response) => { if (chrome.runtime.lastError) resolve([]); else resolve((response && response.jobs) || []); }); }); const currentTitle = await getCurrentProfileTitle(tab.id); jobs.forEach((job) => { if (job.kind === 'opportunity') job.professionalTitle = currentTitle || DEFAULT_TITLE; }); if (createdNewTab && tab) chrome.tabs.remove(tab.id).catch(() => {}); log( 'fetchJobsFast: Completed, ' + jobs.length + ' jobs under "' + currentTitle + '"', ); return jobs; } // Slow path: cycles through every professional title, scraping opportunities // under each, then resets the profile back to the default title. async function fetchAllJobsAcrossTitles() { log('fetchAllJobsAcrossTitles: Starting...'); const ready = await getReadyTab(); if (!ready) return []; const { tab, createdNewTab } = ready; let allJobs = []; for (const title of PROFESSIONAL_TITLES) { log('--- Title: ' + title + ' ---'); const currentTitle = await getCurrentProfileTitle(tab.id); if (currentTitle.toLowerCase() !== title.toLowerCase()) { const switched = await setProfileTitle(tab.id, title); log( 'Switch to "' + title + '": ' + (switched ? 'ok' : 'FAILED (scraping anyway)'), ); } else { log('Already on "' + title + '"'); } await chrome.tabs.update(tab.id, { url: JOBS_URL }); await new Promise((resolve) => setTimeout(resolve, 4000)); const jobs = await new Promise((resolve) => { chrome.tabs.sendMessage(tab.id, { action: 'extractJobs' }, (response) => { if (chrome.runtime.lastError) resolve([]); else resolve((response && response.jobs) || []); }); }); jobs.forEach((job) => { if (job.kind === 'opportunity') job.professionalTitle = title; allJobs.push(job); }); log('Collected ' + jobs.length + ' jobs under "' + title + '"'); } const finalCheck = await getCurrentProfileTitle(tab.id); if (finalCheck.toLowerCase() !== DEFAULT_TITLE.toLowerCase()) { log('Resetting profile back to default: ' + DEFAULT_TITLE); await setProfileTitle(tab.id, DEFAULT_TITLE); } if (createdNewTab && tab) chrome.tabs.remove(tab.id).catch(() => {}); log('fetchAllJobsAcrossTitles: Completed, ' + allJobs.length + ' jobs total'); return allJobs; } function dedupeJobs(jobs) { const seen = {}; const deduped = []; jobs.forEach((job) => { const key = (job.kind === 'opportunity' ? 'opp:' : 'app:') + job.id; if (seen[key]) return; seen[key] = true; deduped.push(job); }); return deduped; } function sortJobsByRecency(jobs) { return jobs .slice() .sort((a, b) => (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0)); } function isFrontendJob(job) { if (job.professionalTitle) return job.professionalTitle === 'Front-End Engineer'; return /front[\s-]?end/i.test(job.title || ''); } async function sendNtfyNotification(job, companyName) { const config = await getConfig(); if (!config.ntfyTopic) return; const server = config.ntfyServer.replace(/\/+$/, ''); const headers = { 'Content-Type': 'application/json' }; if (config.ntfyToken) headers.Authorization = 'Bearer ' + config.ntfyToken; const frontend = isFrontendJob(job); const roleTag = job.professionalTitle ? ' [' + job.professionalTitle + ']' : ''; const payload = { topic: config.ntfyTopic, title: (frontend ? 'Your Perfect Fit - Frontend' : 'New Job on Andela!') + roleTag, message: job.title + ' - ' + companyName, tags: frontend ? ['star', 'computer'] : ['briefcase'], priority: frontend ? 5 : 4, }; if (job.url) payload.click = job.url; try { const response = await fetch(server, { method: 'POST', headers: headers, body: JSON.stringify(payload), }); if (!response.ok) log('ntfy push failed: ' + response.status + ' ' + response.statusText); else log( 'ntfy push sent to topic "' + config.ntfyTopic + '" for: ' + job.title, ); } catch (error) { log('ntfy push error:', error.message); } } async function notifyNewJob(job) { await createOffscreen(); chrome.runtime.sendMessage({ action: 'playSound' }); const companyName = job.company || 'Company'; chrome.notifications.create('andela-job-' + job.id, { type: 'basic', iconUrl: NOTIFICATION_ICON, title: 'New Job on Andela!', message: job.title + ' - ' + companyName, priority: 2, requireInteraction: true, }); await sendNtfyNotification(job, companyName); } 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) 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() { if (keepAliveInterval !== null) return; keepAliveInterval = setInterval(() => { chrome.runtime.getPlatformInfo(() => { if (chrome.runtime.lastError) { } }); }, 20000); } function stopKeepAlive() { if (keepAliveInterval !== null) { clearInterval(keepAliveInterval); keepAliveInterval = null; } } // Merges freshly scraped jobs into stored jobs, notifies on new opportunities, // fetches posted dates, tracks a "new" badge window per job, and persists // everything. Used by both check types. async function processScrapedJobs(freshJobs) { const stored = await getStoredData([ 'lastAndelaJobs', 'notifiedJobIds', 'postedDates', 'backfillDone', 'newBadgeUntil', ]); const notifiedJobIds = new Set(stored.notifiedJobIds || []); const postedDates = stored.postedDates || {}; const backfillDone = stored.backfillDone || false; const previousJobs = stored.lastAndelaJobs || []; const newBadgeUntil = stored.newBadgeUntil || {}; // { jobId: expiryTimestampMs } // Merge: fresh scrape wins for any job id/kind it covers; anything not // re-scraped this cycle (e.g. other titles during a fast check) is kept // from the previous snapshot so the popup doesn't lose jobs between sweeps. const freshKeys = new Set( freshJobs.map((j) => (j.kind === 'opportunity' ? 'opp:' : 'app:') + j.id), ); const carriedOver = previousJobs.filter( (j) => !freshKeys.has((j.kind === 'opportunity' ? 'opp:' : 'app:') + j.id), ); const merged = dedupeJobs(sortJobsByRecency([...freshJobs, ...carriedOver])); merged.forEach((job) => { if (postedDates[job.id]) job.posted_ago = postedDates[job.id]; }); if (!backfillDone) { const toBackfill = merged.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 = merged.filter( (job) => job.kind === 'opportunity' && !notifiedJobIds.has(job.id), ); log('Found ' + newJobs.length + ' new jobs'); const now = Date.now(); const badgeDurationMs = MULTI_TITLE_INTERVAL * 60 * 1000; 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); newBadgeUntil[job.id] = now + badgeDurationMs; } // Clean up expired badges so storage doesn't grow forever. Object.keys(newBadgeUntil).forEach((id) => { if (newBadgeUntil[id] <= now) delete newBadgeUntil[id]; }); await setStoredData({ notifiedJobIds: Array.from(notifiedJobIds), postedDates: postedDates, backfillDone: true, newBadgeUntil: newBadgeUntil, lastAndelaJobs: merged, lastAndelaUpdated: new Date().toISOString(), totalAndelaMatching: merged.filter((j) => j.kind === 'opportunity').length, }); } async function checkForNewJobsFast() { if (activeOperation) { log('"' + activeOperation + '" in progress, skipping fast check'); return; } activeOperation = 'fast'; log('=== Starting fast check ==='); startKeepAlive(); try { const freshJobs = await fetchJobsFast(); await processScrapedJobs(freshJobs); } catch (error) { console.error('Error in checkForNewJobsFast:', error); } finally { stopKeepAlive(); activeOperation = null; } log('=== Fast check completed ==='); } async function runMultiTitleSweep() { if (activeOperation) { log('"' + activeOperation + '" in progress, skipping sweep'); return; } activeOperation = 'sweep'; log('=== Starting multi-title sweep ==='); startKeepAlive(); try { const freshJobs = await fetchAllJobsAcrossTitles(); await processScrapedJobs(freshJobs); } catch (error) { console.error('Error in runMultiTitleSweep:', error); } finally { stopKeepAlive(); activeOperation = null; } log('=== Multi-title sweep completed ==='); } chrome.alarms.create('fastCheck', { periodInMinutes: FAST_CHECK_INTERVAL }); chrome.alarms.create('multiTitleSweep', { periodInMinutes: MULTI_TITLE_INTERVAL, }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'fastCheck') checkForNewJobsFast(); if (alarm.name === 'multiTitleSweep') runMultiTitleSweep(); }); chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'checkNow') { checkForNewJobsFast(); sendResponse({ status: 'Check initiated' }); } return true; }); createOffscreen().catch(console.error); chrome.runtime.onInstalled.addListener(async () => { await runMultiTitleSweep(); // covers Full-Stack too, so no separate fast check needed right after testNotification(); }); async function testNotification() { chrome.notifications.create('test-notification', { type: 'basic', iconUrl: NOTIFICATION_ICON, title: 'Andela Job Notifier Test', message: 'Extension is installed and working!', priority: 2, }); } ['fastCheck', 'multiTitleSweep'].forEach((name) => { chrome.alarms.get(name, (alarm) => { if (!alarm) { chrome.alarms.create(name, { periodInMinutes: name === 'fastCheck' ? FAST_CHECK_INTERVAL : MULTI_TITLE_INTERVAL, delayInMinutes: 0.1, }); } }); });