Andela Notifications Updated
This commit is contained in:
173
job-notifier/andela-appscript/Code.gs
Normal file
173
job-notifier/andela-appscript/Code.gs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// Code.gs — Google Apps Script Web App
|
||||||
|
// Deploy as: Web app, Execute as "Me", Who has access: "Anyone"
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURATION — change these two, then create a NEW deployment version
|
||||||
|
// ============================================
|
||||||
|
var SECRET_KEY = '5f5bd4c1751f7582f870954da60cafe2'; // e.g. output of: openssl rand -hex 16
|
||||||
|
var ALLOWED_EMAIL = 'justiceawudi@outlook.com'; // must match "Login Email" in extension Settings
|
||||||
|
|
||||||
|
function doPost(e) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(e.postData.contents);
|
||||||
|
|
||||||
|
if (!data.key || data.key !== SECRET_KEY) {
|
||||||
|
Logger.log('Unauthorized request - invalid key');
|
||||||
|
return ContentService.createTextOutput(
|
||||||
|
JSON.stringify({ error: 'Unauthorized' }),
|
||||||
|
).setMimeType(ContentService.MimeType.JSON);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.email !== ALLOWED_EMAIL) {
|
||||||
|
Logger.log('Unauthorized request - invalid email: ' + data.email);
|
||||||
|
return ContentService.createTextOutput(
|
||||||
|
JSON.stringify({ error: 'Unauthorized email' }),
|
||||||
|
).setMimeType(ContentService.MimeType.JSON);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'pollForLoginLink') {
|
||||||
|
var result = pollForMagicLink(data.requestId);
|
||||||
|
return ContentService.createTextOutput(
|
||||||
|
JSON.stringify(result),
|
||||||
|
).setMimeType(ContentService.MimeType.JSON);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentService.createTextOutput(
|
||||||
|
JSON.stringify({ error: 'Unknown action' }),
|
||||||
|
).setMimeType(ContentService.MimeType.JSON);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.log('Error in doPost: ' + err.message);
|
||||||
|
return ContentService.createTextOutput(
|
||||||
|
JSON.stringify({ error: err.message }),
|
||||||
|
).setMimeType(ContentService.MimeType.JSON);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Poll for magic login link.
|
||||||
|
//
|
||||||
|
// No "to:" filter — the email is sent to your Outlook address and
|
||||||
|
// lands in Gmail via a forwarding rule, so its To/Delivered-To headers
|
||||||
|
// may still reference Outlook rather than Gmail. from + subject +
|
||||||
|
// newer_than + is:unread is specific enough since this only ever
|
||||||
|
// searches your own mailbox.
|
||||||
|
// ============================================
|
||||||
|
function pollForMagicLink(requestId) {
|
||||||
|
var maxAttempts = 4;
|
||||||
|
var pollInterval = 15000;
|
||||||
|
|
||||||
|
Logger.log('[' + requestId + '] Starting poll for login link...');
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
Logger.log('[' + requestId + '] Attempt ' + attempt + '/' + maxAttempts);
|
||||||
|
|
||||||
|
var searchQuery =
|
||||||
|
'from:talent.andela.com subject:"Andela Login Link" newer_than:5m is:unread';
|
||||||
|
var threads = GmailApp.search(searchQuery, 0, 5);
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
'[' + requestId + '] Found ' + threads.length + ' matching threads',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (threads.length > 0) {
|
||||||
|
var allMessages = [];
|
||||||
|
for (var i = 0; i < threads.length; i++) {
|
||||||
|
var messages = threads[i].getMessages();
|
||||||
|
for (var j = 0; j < messages.length; j++) allMessages.push(messages[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
allMessages.sort(function (a, b) {
|
||||||
|
return b.getDate().getTime() - a.getDate().getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
var latestMessage = allMessages[0];
|
||||||
|
var body = latestMessage.getBody();
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
'[' + requestId + '] Processing email from: ' + latestMessage.getDate(),
|
||||||
|
);
|
||||||
|
|
||||||
|
var accessAccountPattern =
|
||||||
|
/<a\s+[^>]*href=["']([^"']+)["'][^>]*>\s*Access account\s*<\/a>/i;
|
||||||
|
var match = body.match(accessAccountPattern);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
var link = match[1]
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/=/g, '=')
|
||||||
|
.replace(/=/g, '=')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
|
||||||
|
Logger.log('[' + requestId + '] Found Access account link: ' + link);
|
||||||
|
latestMessage.markRead();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
loginLink: link,
|
||||||
|
emailDate: latestMessage.getDate().toISOString(),
|
||||||
|
attempt: attempt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
'[' + requestId + '] Email found but no Access account link detected',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
Logger.log('[' + requestId + '] Waiting ' + pollInterval / 1000 + 's...');
|
||||||
|
Utilities.sleep(pollInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
'[' +
|
||||||
|
requestId +
|
||||||
|
'] No login link found after ' +
|
||||||
|
maxAttempts +
|
||||||
|
' attempts',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No login link found within timeout',
|
||||||
|
attempts: maxAttempts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Debug helpers — run manually from the Apps Script editor's function
|
||||||
|
// dropdown, then check the Executions log
|
||||||
|
// ============================================
|
||||||
|
function debugLoginEmails() {
|
||||||
|
var searchQuery =
|
||||||
|
'from:talent.andela.com subject:"Andela Login Link" newer_than:7d';
|
||||||
|
var threads = GmailApp.search(searchQuery, 0, 5);
|
||||||
|
Logger.log('Found ' + threads.length + ' emails');
|
||||||
|
|
||||||
|
for (var i = 0; i < threads.length; i++) {
|
||||||
|
var messages = threads[i].getMessages();
|
||||||
|
for (var j = 0; j < messages.length; j++) {
|
||||||
|
var msg = messages[j];
|
||||||
|
Logger.log('---');
|
||||||
|
Logger.log('Subject: ' + msg.getSubject());
|
||||||
|
Logger.log('From: ' + msg.getFrom());
|
||||||
|
Logger.log('To: ' + msg.getTo());
|
||||||
|
Logger.log('Date: ' + msg.getDate());
|
||||||
|
|
||||||
|
var body = msg.getBody();
|
||||||
|
var pattern =
|
||||||
|
/<a\s+[^>]*href=["']([^"']+)["'][^>]*>\s*Access account\s*<\/a>/i;
|
||||||
|
var match = body.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
Logger.log('Link found: ' + match[1].substring(0, 100));
|
||||||
|
} else {
|
||||||
|
Logger.log('Access account link not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testExtraction() {
|
||||||
|
var result = pollForMagicLink('test-' + Date.now());
|
||||||
|
Logger.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
19
job-notifier/andela-appscript/README.md
Normal file
19
job-notifier/andela-appscript/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Andela Apps Script (Gmail magic-link fetcher)
|
||||||
|
|
||||||
|
Deploy this as a Google Apps Script Web App under the Google account whose
|
||||||
|
Gmail receives the forwarded Andela login emails.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
1. https://script.google.com → New project → paste `Code.gs` in as `Code.gs`.
|
||||||
|
2. Edit `SECRET_KEY` (generate with `openssl rand -hex 16`) and `ALLOWED_EMAIL`
|
||||||
|
(the email typed into the Andela login form — your Outlook address, not Gmail).
|
||||||
|
3. Deploy → New deployment → Web app → Execute as **Me** → Who has access **Anyone**.
|
||||||
|
4. Authorize Gmail access when prompted, then copy the `/exec` URL.
|
||||||
|
5. Put that URL + the same SECRET_KEY + the same email into the Chrome
|
||||||
|
extension's Settings page.
|
||||||
|
6. Whenever you edit this file: Deploy → Manage deployments → Edit →
|
||||||
|
Version: New version → Deploy (saving alone does not update the live web app).
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
Run `debugLoginEmails` from the function dropdown in the editor, then check
|
||||||
|
Executions in the left sidebar for the Logger output.
|
||||||
438
job-notifier/background.js
Normal file
438
job-notifier/background.js
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
const CHECK_INTERVAL = 5; // minutes
|
||||||
|
const DEBUG = true;
|
||||||
|
const JOBS_URL = 'https://app.andela.com/';
|
||||||
|
const NOTIFICATION_ICON = 'icon128.png';
|
||||||
|
|
||||||
|
let isLoggingIn = false;
|
||||||
|
|
||||||
|
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 || 'https://notify.tstitagency.com',
|
||||||
|
ntfyTopic: result.ntfyTopic || '',
|
||||||
|
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) {
|
||||||
|
log('Error checking login page:', chrome.runtime.lastError.message);
|
||||||
|
resolve(false);
|
||||||
|
} else {
|
||||||
|
resolve(response?.isLoginPage || 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Step 1: Entering email and clicking login...');
|
||||||
|
|
||||||
|
const emailEntered = await new Promise((resolve) => {
|
||||||
|
chrome.tabs.sendMessage(
|
||||||
|
tab.id,
|
||||||
|
{ action: 'enterEmailAndLogin', email: config.email },
|
||||||
|
(response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
log('Error entering email:', chrome.runtime.lastError.message);
|
||||||
|
resolve(false);
|
||||||
|
} else {
|
||||||
|
resolve(response?.success || false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailEntered) {
|
||||||
|
log('Failed to enter email');
|
||||||
|
isLoggingIn = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Step 2: Requesting Apps Script to poll for magic link...');
|
||||||
|
|
||||||
|
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'
|
||||||
|
) {
|
||||||
|
log('Authentication failed - check your secret key / login 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Step 3: Visiting magic link...');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJobsViaContentScript() {
|
||||||
|
log('fetchJobsViaContentScript: Starting...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tabs = await chrome.tabs.query({ url: 'https://app.andela.com/*' });
|
||||||
|
log('Found ' + tabs.length + ' existing Andela tabs');
|
||||||
|
|
||||||
|
let tab;
|
||||||
|
let createdNewTab = false;
|
||||||
|
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
log('Opening new tab for Andela dashboard...');
|
||||||
|
tab = await chrome.tabs.create({ url: JOBS_URL, active: false });
|
||||||
|
createdNewTab = true;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
} else {
|
||||||
|
tab = tabs[0];
|
||||||
|
log('Using existing tab with ID: ' + tab.id);
|
||||||
|
await chrome.tabs.update(tab.id, { url: JOBS_URL });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoginPage = await checkIfLoginPage(tab.id);
|
||||||
|
|
||||||
|
if (isLoginPage) {
|
||||||
|
log('Session expired! Starting login flow...');
|
||||||
|
|
||||||
|
if (isLoggingIn) {
|
||||||
|
log('Already logging in, skipping...');
|
||||||
|
if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSuccess = await handleAutoLogin(tab);
|
||||||
|
|
||||||
|
if (!loginSuccess) {
|
||||||
|
log('Login failed, aborting job check');
|
||||||
|
if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobs = [];
|
||||||
|
try {
|
||||||
|
log('Extracting jobs from dashboard (expanding "See more" first)...');
|
||||||
|
jobs = await new Promise((resolve) => {
|
||||||
|
chrome.tabs.sendMessage(
|
||||||
|
tab.id,
|
||||||
|
{ action: 'extractJobs' },
|
||||||
|
(response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
log('Content script error:', chrome.runtime.lastError.message);
|
||||||
|
resolve([]);
|
||||||
|
} else {
|
||||||
|
resolve((response && response.jobs) || []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
log('Found ' + jobs.length + ' jobs');
|
||||||
|
} catch (err) {
|
||||||
|
log('Error during job extraction:', err.message, err.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdNewTab && tab) {
|
||||||
|
log('Closing created tab...');
|
||||||
|
chrome.tabs.remove(tab.id).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
'fetchJobsViaContentScript: Completed, found ' +
|
||||||
|
jobs.length +
|
||||||
|
' unique jobs',
|
||||||
|
);
|
||||||
|
return jobs;
|
||||||
|
} catch (error) {
|
||||||
|
log('Error in fetchJobsViaContentScript:', error.message, error.stack);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortJobsByRecency(jobs) {
|
||||||
|
return jobs
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFrontendJob(title) {
|
||||||
|
return /front[\s-]?end/i.test(title || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNtfyNotification(job, companyName) {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
if (!config.ntfyTopic) {
|
||||||
|
log('ntfy topic not configured, skipping push notification');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = config.ntfyServer.replace(/\/+$/, '');
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (config.ntfyToken) headers.Authorization = 'Bearer ' + config.ntfyToken;
|
||||||
|
|
||||||
|
const frontend = isFrontendJob(job.title);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
topic: config.ntfyTopic,
|
||||||
|
title: frontend ? 'Your Perfect Fit - Frontend' : 'New Job on Andela!',
|
||||||
|
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 for job: ' + job.title);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('ntfy push error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyNewJob(job) {
|
||||||
|
log('Notifying for job: ' + job.title);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keepAliveInterval = null;
|
||||||
|
|
||||||
|
function startKeepAlive() {
|
||||||
|
if (keepAliveInterval !== null) return;
|
||||||
|
keepAliveInterval = setInterval(() => {
|
||||||
|
chrome.runtime.getPlatformInfo(() => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
/* no-op, purpose is just to reset idle timer */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopKeepAlive() {
|
||||||
|
if (keepAliveInterval !== null) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
keepAliveInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForNewJobs() {
|
||||||
|
log('=== Starting job check ===');
|
||||||
|
log('Current time:', new Date().toLocaleString());
|
||||||
|
|
||||||
|
startKeepAlive();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allJobs = await fetchJobsViaContentScript();
|
||||||
|
const sortedJobs = sortJobsByRecency(allJobs);
|
||||||
|
|
||||||
|
const result = await getStoredData(['notifiedJobIds']);
|
||||||
|
const notifiedJobIds = new Set(result.notifiedJobIds || []);
|
||||||
|
|
||||||
|
log('Previously notified jobs: ' + notifiedJobIds.size);
|
||||||
|
|
||||||
|
const newJobs = sortedJobs.filter((job) => !notifiedJobIds.has(job.id));
|
||||||
|
log('Found ' + newJobs.length + ' new jobs');
|
||||||
|
|
||||||
|
for (const job of newJobs) {
|
||||||
|
await notifyNewJob(job);
|
||||||
|
notifiedJobIds.add(job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setStoredData({
|
||||||
|
notifiedJobIds: Array.from(notifiedJobIds),
|
||||||
|
lastAndelaJobs: sortedJobs,
|
||||||
|
lastAndelaUpdated: new Date().toISOString(),
|
||||||
|
totalAndelaMatching: sortedJobs.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
log('Notifications stored successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in checkForNewJobs:', error);
|
||||||
|
log('Error details:', error.message, error.stack);
|
||||||
|
} finally {
|
||||||
|
stopKeepAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
log('=== Job check completed ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.alarms.create('checkAndelaJobs', { periodInMinutes: CHECK_INTERVAL });
|
||||||
|
|
||||||
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
|
if (alarm.name === 'checkAndelaJobs') checkForNewJobs();
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||||
|
log('Received message:', request);
|
||||||
|
if (request.action === 'checkNow') {
|
||||||
|
log('Manual check requested from popup');
|
||||||
|
checkForNewJobs();
|
||||||
|
sendResponse({ status: 'Check initiated' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
createOffscreen().catch(console.error);
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
log('Extension installed/updated, checking for jobs...');
|
||||||
|
checkForNewJobs();
|
||||||
|
testNotification();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function testNotification() {
|
||||||
|
log('Testing notification system...');
|
||||||
|
chrome.notifications.create('test-notification', {
|
||||||
|
type: 'basic',
|
||||||
|
iconUrl: NOTIFICATION_ICON,
|
||||||
|
title: 'Andela Job Notifier Test',
|
||||||
|
message: 'Extension is installed and working!',
|
||||||
|
priority: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Andela Job Notifier started at:', new Date().toLocaleString());
|
||||||
|
|
||||||
|
chrome.alarms.get('checkAndelaJobs', (alarm) => {
|
||||||
|
if (alarm) {
|
||||||
|
log(
|
||||||
|
'Alarm is set, next check at:',
|
||||||
|
new Date(alarm.scheduledTime).toLocaleString(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log('No alarm found, creating one...');
|
||||||
|
chrome.alarms.create('checkAndelaJobs', {
|
||||||
|
periodInMinutes: CHECK_INTERVAL,
|
||||||
|
delayInMinutes: 0.1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
202
job-notifier/content.js
Normal file
202
job-notifier/content.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// content.js - Handles job extraction and login automation for app.andela.com
|
||||||
|
|
||||||
|
console.log('[Andela Content Script] Loaded on', window.location.href);
|
||||||
|
|
||||||
|
function isLoginPage() {
|
||||||
|
var emailInput = document.querySelector('input[type="email"]#email');
|
||||||
|
var emailLabel = document.querySelector('label[for="email"]');
|
||||||
|
var hasLoginForm = !!(emailInput && emailLabel);
|
||||||
|
var isLoginUrl = window.location.pathname.indexOf('/login') === 0;
|
||||||
|
|
||||||
|
console.log('[Andela Content Script] Login check - hasForm:', hasLoginForm, 'isLoginUrl:', isLoginUrl);
|
||||||
|
return hasLoginForm || (isLoginUrl && !!emailInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterEmailAndClickLogin(email) {
|
||||||
|
try {
|
||||||
|
console.log('[Andela Content Script] Attempting to enter email:', email);
|
||||||
|
|
||||||
|
var emailInput = document.querySelector('input[type="email"]#email');
|
||||||
|
if (!emailInput) {
|
||||||
|
console.log('[Andela Content Script] Email input not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emailInput.focus();
|
||||||
|
emailInput.value = email;
|
||||||
|
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
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...');
|
||||||
|
|
||||||
|
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) {
|
||||||
|
submitButton = buttons[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitButton) {
|
||||||
|
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');
|
||||||
|
var form = emailInput.closest('form');
|
||||||
|
if (form) {
|
||||||
|
if (form.requestSubmit) form.requestSubmit();
|
||||||
|
else form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Andela Content Script] Error entering email:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAllSeeMoreButtons(maxRounds) {
|
||||||
|
maxRounds = maxRounds || 25;
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickNext() {
|
||||||
|
rounds++;
|
||||||
|
var btn = findSeeMoreButton();
|
||||||
|
if (!btn || rounds > maxRounds) {
|
||||||
|
console.log('[Andela Content Script] Done expanding "See more" buttons after', rounds - 1, 'click(s)');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[Andela Content Script] Clicking:', btn.textContent.trim());
|
||||||
|
btn.click();
|
||||||
|
setTimeout(clickNext, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
clickNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJobFromLink(link) {
|
||||||
|
var href = link.getAttribute('href') || '';
|
||||||
|
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 title = '';
|
||||||
|
var company = '';
|
||||||
|
var titleEl = card ? card.querySelector('.text-primary-900.font-semibold') : null;
|
||||||
|
|
||||||
|
if (titleEl) {
|
||||||
|
var clone = titleEl.cloneNode(true);
|
||||||
|
var companySpan = clone.querySelector('span.font-normal');
|
||||||
|
if (companySpan) {
|
||||||
|
company = companySpan.textContent.replace(/^[\s—-]+/, '').trim();
|
||||||
|
companySpan.remove();
|
||||||
|
}
|
||||||
|
title = clone.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraphs = card ? card.querySelectorAll('p') : [];
|
||||||
|
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 url = href;
|
||||||
|
if (url && url.indexOf('http') !== 0) {
|
||||||
|
url = window.location.origin + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
company: company || 'Company',
|
||||||
|
posted_date: startInfo || '',
|
||||||
|
location: locationInfo || '',
|
||||||
|
skills: skills,
|
||||||
|
url: url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
links.forEach(function(link) {
|
||||||
|
try {
|
||||||
|
var job = extractJobFromLink(link);
|
||||||
|
if (!job.id || seen[job.id]) return;
|
||||||
|
seen[job.id] = true;
|
||||||
|
if (job.title) {
|
||||||
|
jobs.push(job);
|
||||||
|
console.log('[Andela Content Script] Extracted job:', job.title, '(' + job.id + ')');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Andela Content Script] Error extracting a job card:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Andela Content Script] Extracted ' + jobs.length + ' jobs total');
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
try {
|
||||||
|
var jobs = extractJobsFromPage();
|
||||||
|
sendResponse({ jobs: jobs });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Andela Content Script] Error:', error);
|
||||||
|
sendResponse({ jobs: [] });
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.action === 'checkLoginPage') {
|
||||||
|
var result = isLoginPage();
|
||||||
|
console.log('[Andela Content Script] Is login page:', result);
|
||||||
|
sendResponse({ isLoginPage: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.action === 'enterEmailAndLogin') {
|
||||||
|
var success = enterEmailAndClickLogin(request.email);
|
||||||
|
sendResponse({ success: success });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
36
job-notifier/icon.svg
Normal file
36
job-notifier/icon.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0F9D6E"/>
|
||||||
|
<stop offset="55%" stop-color="#0B7A56"/>
|
||||||
|
<stop offset="100%" stop-color="#053B29"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bell" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="100%" stop-color="#EAF6F0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="dot" cx="35%" cy="30%" r="75%">
|
||||||
|
<stop offset="0%" stop-color="#FFE27A"/>
|
||||||
|
<stop offset="100%" stop-color="#F5A623"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="soft" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2.2" flood-color="#000000" flood-opacity="0.28"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#bg)"/>
|
||||||
|
<rect x="4" y="4" width="120" height="120" rx="28" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- subtle inner highlight -->
|
||||||
|
<path d="M20 30 Q64 8 108 30" stroke="#ffffff" stroke-opacity="0.10" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- bell -->
|
||||||
|
<g filter="url(#soft)">
|
||||||
|
<path d="M64 24c-6.2 0-11.2 5-11.2 11.2v3.1c-11 3.4-19 13.7-19 25.8v14.3l-7.4 8.6c-1.6 1.9-.3 4.9 2.2 4.9h70.8c2.5 0 3.8-3 2.2-4.9l-7.4-8.6V64.1c0-12.1-8-22.4-19-25.8v-3.1C75.2 29 70.2 24 64 24z" fill="url(#bell)"/>
|
||||||
|
<path d="M51 96c0 7.2 5.8 13 13 13s13-5.8 13-13z" fill="url(#bell)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- notification dot -->
|
||||||
|
<circle cx="94" cy="34" r="15" fill="url(#dot)" filter="url(#soft)"/>
|
||||||
|
<circle cx="94" cy="34" r="15" fill="none" stroke="#053B29" stroke-width="2.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
job-notifier/icon128.png
Normal file
BIN
job-notifier/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
job-notifier/icon16.png
Normal file
BIN
job-notifier/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 702 B |
BIN
job-notifier/icon48.png
Normal file
BIN
job-notifier/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
47
job-notifier/manifest.json
Normal file
47
job-notifier/manifest.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Andela Job Notifier",
|
||||||
|
"version": "2.0",
|
||||||
|
"description": "Checks for new job opportunities on Andela with auto-login",
|
||||||
|
"permissions": [
|
||||||
|
"notifications",
|
||||||
|
"alarms",
|
||||||
|
"storage",
|
||||||
|
"offscreen",
|
||||||
|
"tabs",
|
||||||
|
"scripting"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://app.andela.com/*",
|
||||||
|
"https://notify.tstitagency.com/*",
|
||||||
|
"https://script.google.com/*",
|
||||||
|
"https://script.googleusercontent.com/*"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://app.andela.com/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icon16.png",
|
||||||
|
"48": "icon48.png",
|
||||||
|
"128": "icon128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icon16.png",
|
||||||
|
"48": "icon48.png",
|
||||||
|
"128": "icon128.png"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [{
|
||||||
|
"resources": ["notify.wav"],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}]
|
||||||
|
}
|
||||||
BIN
job-notifier/notify.wav
Normal file
BIN
job-notifier/notify.wav
Normal file
Binary file not shown.
9
job-notifier/offscreen.html
Normal file
9
job-notifier/offscreen.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Sound Player</title>
|
||||||
|
<script src="offscreen.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
job-notifier/offscreen.js
Normal file
21
job-notifier/offscreen.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
let audioContext;
|
||||||
|
let audioBuffer;
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (message.action === 'playSound') {
|
||||||
|
playNotificationSound();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function playNotificationSound() {
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new AudioContext();
|
||||||
|
const response = await fetch(chrome.runtime.getURL('notify.wav'));
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
}
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
source.start(0);
|
||||||
|
}
|
||||||
104
job-notifier/popup.html
Normal file
104
job-notifier/popup.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Andela Job Notifier</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
width: 350px;
|
||||||
|
padding: 15px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.job-list {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.job-item {
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.job-item:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.job-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.job-company {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.job-location {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.job-posted {
|
||||||
|
color: #0066cc;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.job-count {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.no-jobs {
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #0066cc;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #0055aa;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Andela Job Notifier</h2>
|
||||||
|
<div id="jobCount" class="job-count"></div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="checkNow" class="btn btn-primary">Check Now</button>
|
||||||
|
<button id="openSettings" class="btn btn-secondary">Settings</button>
|
||||||
|
</div>
|
||||||
|
<div id="jobList" class="job-list"></div>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
76
job-notifier/popup.js
Normal file
76
job-notifier/popup.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// popup.js
|
||||||
|
|
||||||
|
function sortJobsById(jobs) {
|
||||||
|
return jobs.slice().sort(function(a, b) {
|
||||||
|
return (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI(data) {
|
||||||
|
var lastAndelaJobs = data.lastAndelaJobs || [];
|
||||||
|
var lastAndelaUpdated = data.lastAndelaUpdated;
|
||||||
|
var totalAndelaMatching = data.totalAndelaMatching || 0;
|
||||||
|
|
||||||
|
document.getElementById('jobCount').textContent = totalAndelaMatching + ' jobs found';
|
||||||
|
|
||||||
|
var jobList = document.getElementById('jobList');
|
||||||
|
jobList.innerHTML = '';
|
||||||
|
|
||||||
|
if (lastAndelaJobs.length === 0) {
|
||||||
|
jobList.innerHTML = '<div class="no-jobs">No jobs found yet. Click "Check Now" to scan for jobs.</div>';
|
||||||
|
} else {
|
||||||
|
var sortedJobs = sortJobsById(lastAndelaJobs);
|
||||||
|
|
||||||
|
sortedJobs.slice(0, 10).forEach(function(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');
|
||||||
|
|
||||||
|
jobElement.innerHTML =
|
||||||
|
'<div class="job-title">' + job.title + '</div>' +
|
||||||
|
'<div class="job-company">' + companyName + '</div>' +
|
||||||
|
'<div class="job-location">' + (job.location || 'Remote') + '</div>' +
|
||||||
|
'<div class="job-posted">' + postedDate + '</div>';
|
||||||
|
|
||||||
|
jobElement.addEventListener('click', function() {
|
||||||
|
var url = job.url || ('https://app.andela.com/job-postings/' + job.id);
|
||||||
|
chrome.tabs.create({ url: url });
|
||||||
|
});
|
||||||
|
|
||||||
|
jobList.appendChild(jobElement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = document.getElementById('status');
|
||||||
|
status.textContent = lastAndelaUpdated ?
|
||||||
|
'Last checked: ' + new Date(lastAndelaUpdated).toLocaleString() :
|
||||||
|
'Not checked yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.getElementById('checkNow').addEventListener('click', function() {
|
||||||
|
console.log('Manual check triggered');
|
||||||
|
chrome.runtime.sendMessage({ action: 'checkNow' }, function(response) {
|
||||||
|
console.log('Check initiated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
83
job-notifier/settings.html
Normal file
83
job-notifier/settings.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Andela Notifier - Settings</title>
|
||||||
|
<style>
|
||||||
|
body { width: 400px; padding: 20px; font-family: Arial, sans-serif; }
|
||||||
|
h2 { color: #333; margin-bottom: 20px; border-bottom: 2px solid #0066cc; padding-bottom: 10px; }
|
||||||
|
.form-group { margin-bottom: 15px; }
|
||||||
|
label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
|
||||||
|
input[type="text"], input[type="email"], input[type="password"] {
|
||||||
|
width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;
|
||||||
|
box-sizing: border-box; font-size: 14px;
|
||||||
|
}
|
||||||
|
input:focus { outline: none; border-color: #0066cc; }
|
||||||
|
.hint { font-size: 12px; color: #888; margin-top: 3px; }
|
||||||
|
button {
|
||||||
|
width: 100%; padding: 12px; background: #0066cc; color: white;
|
||||||
|
border: none; border-radius: 4px; cursor: pointer; font-size: 16px; margin-top: 10px;
|
||||||
|
}
|
||||||
|
button:hover { background: #0055aa; }
|
||||||
|
.status { margin-top: 15px; padding: 10px; border-radius: 4px; text-align: center; }
|
||||||
|
.status.success { background: #d4edda; color: #155724; }
|
||||||
|
.status.error { background: #f8d7da; color: #721c24; }
|
||||||
|
.back-link { display: block; text-align: center; margin-top: 15px; color: #0066cc; text-decoration: none; }
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
.warning {
|
||||||
|
background: #fff3cd; border: 1px solid #ffc107; padding: 10px; border-radius: 4px;
|
||||||
|
margin-bottom: 15px; font-size: 13px; color: #856404;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
Your credentials are stored locally in your browser and never sent anywhere except to your own Apps Script.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="appsScriptUrl">Apps Script URL</label>
|
||||||
|
<input type="text" id="appsScriptUrl" placeholder="https://script.google.com/macros/s/.../exec">
|
||||||
|
<div class="hint">Your deployed Apps Script web app URL</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="appsScriptKey">Secret Key</label>
|
||||||
|
<input type="password" id="appsScriptKey" placeholder="Your secret key">
|
||||||
|
<div class="hint">Must match SECRET_KEY in your Apps Script</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginEmail">Login Email</label>
|
||||||
|
<input type="email" id="loginEmail" placeholder="you@outlook.com">
|
||||||
|
<div class="hint">The email Andela actually has on file — this is typed into the Andela login form (not your Gmail forwarding address)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ntfyServer">ntfy Server URL</label>
|
||||||
|
<input type="text" id="ntfyServer" placeholder="https://notify.tstitagency.com">
|
||||||
|
<div class="hint">Your self-hosted ntfy server (leave default if unsure)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ntfyTopic">ntfy Topic</label>
|
||||||
|
<input type="text" id="ntfyTopic" placeholder="andela-jobs">
|
||||||
|
<div class="hint">Topic to publish job alerts to. Leave empty to disable phone push.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ntfyToken">ntfy Access Token (optional)</label>
|
||||||
|
<input type="password" id="ntfyToken" placeholder="tk_...">
|
||||||
|
<div class="hint">Only needed if your ntfy server requires authentication</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="saveBtn">Save Settings</button>
|
||||||
|
|
||||||
|
<div id="status" class="status" style="display: none;"></div>
|
||||||
|
|
||||||
|
<a href="popup.html" class="back-link">← Back to Jobs</a>
|
||||||
|
|
||||||
|
<script src="settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
job-notifier/settings.js
Normal file
69
job-notifier/settings.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// settings.js - Handle secure configuration storage
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
chrome.storage.local.get(
|
||||||
|
[
|
||||||
|
'appsScriptUrl',
|
||||||
|
'appsScriptKey',
|
||||||
|
'loginEmail',
|
||||||
|
'ntfyServer',
|
||||||
|
'ntfyTopic',
|
||||||
|
'ntfyToken',
|
||||||
|
],
|
||||||
|
function (result) {
|
||||||
|
if (result.appsScriptUrl)
|
||||||
|
document.getElementById('appsScriptUrl').value = result.appsScriptUrl;
|
||||||
|
if (result.appsScriptKey)
|
||||||
|
document.getElementById('appsScriptKey').value = result.appsScriptKey;
|
||||||
|
if (result.loginEmail)
|
||||||
|
document.getElementById('loginEmail').value = result.loginEmail;
|
||||||
|
document.getElementById('ntfyServer').value =
|
||||||
|
result.ntfyServer || 'https://notify.tstitagency.com';
|
||||||
|
if (result.ntfyTopic)
|
||||||
|
document.getElementById('ntfyTopic').value = result.ntfyTopic;
|
||||||
|
if (result.ntfyToken)
|
||||||
|
document.getElementById('ntfyToken').value = result.ntfyToken;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', function () {
|
||||||
|
var appsScriptUrl = document.getElementById('appsScriptUrl').value.trim();
|
||||||
|
var appsScriptKey = document.getElementById('appsScriptKey').value.trim();
|
||||||
|
var loginEmail = document.getElementById('loginEmail').value.trim();
|
||||||
|
var ntfyServer = document.getElementById('ntfyServer').value.trim();
|
||||||
|
var ntfyTopic = document.getElementById('ntfyTopic').value.trim();
|
||||||
|
var ntfyToken = document.getElementById('ntfyToken').value.trim();
|
||||||
|
|
||||||
|
if (!appsScriptUrl)
|
||||||
|
return showStatus('Please enter Apps Script URL', 'error');
|
||||||
|
if (!appsScriptUrl.startsWith('https://script.google.com/'))
|
||||||
|
return showStatus('Invalid Apps Script URL', 'error');
|
||||||
|
if (!appsScriptKey) return showStatus('Please enter Secret Key', 'error');
|
||||||
|
if (!loginEmail || !loginEmail.includes('@'))
|
||||||
|
return showStatus('Please enter a valid email', 'error');
|
||||||
|
|
||||||
|
chrome.storage.local.set(
|
||||||
|
{
|
||||||
|
appsScriptUrl: appsScriptUrl,
|
||||||
|
appsScriptKey: appsScriptKey,
|
||||||
|
loginEmail: loginEmail,
|
||||||
|
ntfyServer: ntfyServer,
|
||||||
|
ntfyTopic: ntfyTopic,
|
||||||
|
ntfyToken: ntfyToken,
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
showStatus('Settings saved successfully!', 'success');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function showStatus(message, type) {
|
||||||
|
var statusEl = document.getElementById('status');
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.className = 'status ' + type;
|
||||||
|
statusEl.style.display = 'block';
|
||||||
|
setTimeout(function () {
|
||||||
|
statusEl.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user