628 lines
18 KiB
JavaScript
628 lines
18 KiB
JavaScript
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,
|
|
});
|
|
}
|
|
});
|
|
});
|