Fixed multi-job scrape, and added labels and single job scrape
This commit is contained in:
@@ -1,9 +1,24 @@
|
|||||||
const CHECK_INTERVAL = 5; // minutes
|
const FAST_CHECK_INTERVAL = 5; // minutes
|
||||||
|
const MULTI_TITLE_INTERVAL = 30; // minutes
|
||||||
const DEBUG = true;
|
const DEBUG = true;
|
||||||
const JOBS_URL = 'https://app.andela.com/';
|
const JOBS_URL = 'https://app.andela.com/';
|
||||||
|
const PROFILE_URL = 'https://app.andela.com/profile';
|
||||||
const NOTIFICATION_ICON = 'icon128.png';
|
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 isLoggingIn = false;
|
||||||
|
let activeOperation = null; // null | 'fast' | 'sweep' — only one tab operation runs at a time
|
||||||
|
|
||||||
function log(...args) {
|
function log(...args) {
|
||||||
if (DEBUG) console.log('[Andela Notifier]', ...args);
|
if (DEBUG) console.log('[Andela Notifier]', ...args);
|
||||||
@@ -25,8 +40,8 @@ async function getConfig() {
|
|||||||
url: result.appsScriptUrl || '',
|
url: result.appsScriptUrl || '',
|
||||||
key: result.appsScriptKey || '',
|
key: result.appsScriptKey || '',
|
||||||
email: result.loginEmail || '',
|
email: result.loginEmail || '',
|
||||||
ntfyServer: result.ntfyServer || 'https://notify.tstitagency.com',
|
ntfyServer: result.ntfyServer || DEFAULT_NTFY_SERVER,
|
||||||
ntfyTopic: result.ntfyTopic || '',
|
ntfyTopic: result.ntfyTopic || DEFAULT_NTFY_TOPIC,
|
||||||
ntfyToken: result.ntfyToken || '',
|
ntfyToken: result.ntfyToken || '',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -54,13 +69,41 @@ async function setStoredData(data) {
|
|||||||
async function checkIfLoginPage(tabId) {
|
async function checkIfLoginPage(tabId) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.tabs.sendMessage(tabId, { action: 'checkLoginPage' }, (response) => {
|
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) {
|
if (chrome.runtime.lastError) {
|
||||||
log('Error checking login page:', chrome.runtime.lastError.message);
|
log('setProfileTitle error:', chrome.runtime.lastError.message);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
} else {
|
} else {
|
||||||
resolve(response?.isLoginPage || false);
|
resolve((response && response.success) || false);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,19 +126,13 @@ async function handleAutoLogin(tab) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Step 1: Entering email and clicking login...');
|
|
||||||
|
|
||||||
const emailEntered = await new Promise((resolve) => {
|
const emailEntered = await new Promise((resolve) => {
|
||||||
chrome.tabs.sendMessage(
|
chrome.tabs.sendMessage(
|
||||||
tab.id,
|
tab.id,
|
||||||
{ action: 'enterEmailAndLogin', email: config.email },
|
{ action: 'enterEmailAndLogin', email: config.email },
|
||||||
(response) => {
|
(response) => {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) resolve(false);
|
||||||
log('Error entering email:', chrome.runtime.lastError.message);
|
else resolve(response?.success || false);
|
||||||
resolve(false);
|
|
||||||
} else {
|
|
||||||
resolve(response?.success || false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -106,8 +143,6 @@ async function handleAutoLogin(tab) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Step 2: Requesting Apps Script to poll for magic link...');
|
|
||||||
|
|
||||||
const requestId = 'login-' + Date.now();
|
const requestId = 'login-' + Date.now();
|
||||||
|
|
||||||
const response = await fetch(config.url, {
|
const response = await fetch(config.url, {
|
||||||
@@ -128,7 +163,6 @@ async function handleAutoLogin(tab) {
|
|||||||
result.error === 'Unauthorized' ||
|
result.error === 'Unauthorized' ||
|
||||||
result.error === 'Unauthorized email'
|
result.error === 'Unauthorized email'
|
||||||
) {
|
) {
|
||||||
log('Authentication failed - check your secret key / login email');
|
|
||||||
chrome.notifications.create('auth-failed', {
|
chrome.notifications.create('auth-failed', {
|
||||||
type: 'basic',
|
type: 'basic',
|
||||||
iconUrl: NOTIFICATION_ICON,
|
iconUrl: NOTIFICATION_ICON,
|
||||||
@@ -146,7 +180,6 @@ async function handleAutoLogin(tab) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Step 3: Visiting magic link...');
|
|
||||||
await chrome.tabs.update(tab.id, { url: result.loginLink });
|
await chrome.tabs.update(tab.id, { url: result.loginLink });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
@@ -167,91 +200,143 @@ async function handleAutoLogin(tab) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobsViaContentScript() {
|
// Opens/reuses the Andela tab and handles login if needed. Shared by both
|
||||||
log('fetchJobsViaContentScript: Starting...');
|
// the fast check and the multi-title sweep.
|
||||||
|
async function getReadyTab() {
|
||||||
try {
|
|
||||||
const tabs = await chrome.tabs.query({ url: 'https://app.andela.com/*' });
|
const tabs = await chrome.tabs.query({ url: 'https://app.andela.com/*' });
|
||||||
log('Found ' + tabs.length + ' existing Andela tabs');
|
|
||||||
|
|
||||||
let tab;
|
let tab;
|
||||||
let createdNewTab = false;
|
let createdNewTab = false;
|
||||||
|
|
||||||
if (tabs.length === 0) {
|
if (tabs.length === 0) {
|
||||||
log('Opening new tab for Andela dashboard...');
|
|
||||||
tab = await chrome.tabs.create({ url: JOBS_URL, active: false });
|
tab = await chrome.tabs.create({ url: JOBS_URL, active: false });
|
||||||
createdNewTab = true;
|
createdNewTab = true;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
} else {
|
} else {
|
||||||
tab = tabs[0];
|
tab = tabs[0];
|
||||||
log('Using existing tab with ID: ' + tab.id);
|
|
||||||
await chrome.tabs.update(tab.id, { url: JOBS_URL });
|
await chrome.tabs.update(tab.id, { url: JOBS_URL });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoginPage = await checkIfLoginPage(tab.id);
|
const isLoginPage = await checkIfLoginPage(tab.id);
|
||||||
|
|
||||||
if (isLoginPage) {
|
if (isLoginPage) {
|
||||||
log('Session expired! Starting login flow...');
|
|
||||||
|
|
||||||
if (isLoggingIn) {
|
if (isLoggingIn) {
|
||||||
log('Already logging in, skipping...');
|
|
||||||
if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {});
|
if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {});
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginSuccess = await handleAutoLogin(tab);
|
const loginSuccess = await handleAutoLogin(tab);
|
||||||
|
|
||||||
if (!loginSuccess) {
|
if (!loginSuccess) {
|
||||||
log('Login failed, aborting job check');
|
|
||||||
if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {});
|
if (createdNewTab) chrome.tabs.remove(tab.id).catch(() => {});
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Login successful, waiting 2 minutes before resuming...');
|
log('Login successful, waiting 2 minutes before resuming...');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 120000));
|
await new Promise((resolve) => setTimeout(resolve, 120000));
|
||||||
|
|
||||||
await chrome.tabs.update(tab.id, { url: JOBS_URL });
|
await chrome.tabs.update(tab.id, { url: JOBS_URL });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||||
}
|
}
|
||||||
|
|
||||||
let jobs = [];
|
return { tab, createdNewTab };
|
||||||
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) {
|
// Fast path: scrapes whatever profile title is currently active, no switching.
|
||||||
log('Closing created tab...');
|
async function fetchJobsFast() {
|
||||||
chrome.tabs.remove(tab.id).catch(() => {});
|
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(
|
log(
|
||||||
'fetchJobsViaContentScript: Completed, found ' +
|
'fetchJobsFast: Completed, ' +
|
||||||
jobs.length +
|
jobs.length +
|
||||||
' unique jobs',
|
' jobs under "' +
|
||||||
|
currentTitle +
|
||||||
|
'"',
|
||||||
);
|
);
|
||||||
return jobs;
|
return jobs;
|
||||||
} catch (error) {
|
}
|
||||||
log('Error in fetchJobsViaContentScript:', error.message, error.stack);
|
|
||||||
return [];
|
// 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) {
|
function sortJobsByRecency(jobs) {
|
||||||
@@ -260,27 +345,30 @@ function sortJobsByRecency(jobs) {
|
|||||||
.sort((a, b) => (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0));
|
.sort((a, b) => (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFrontendJob(title) {
|
function isFrontendJob(job) {
|
||||||
return /front[\s-]?end/i.test(title || '');
|
if (job.professionalTitle)
|
||||||
|
return job.professionalTitle === 'Front-End Engineer';
|
||||||
|
return /front[\s-]?end/i.test(job.title || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendNtfyNotification(job, companyName) {
|
async function sendNtfyNotification(job, companyName) {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
if (!config.ntfyTopic) return;
|
||||||
if (!config.ntfyTopic) {
|
|
||||||
log('ntfy topic not configured, skipping push notification');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = config.ntfyServer.replace(/\/+$/, '');
|
const server = config.ntfyServer.replace(/\/+$/, '');
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (config.ntfyToken) headers.Authorization = 'Bearer ' + config.ntfyToken;
|
if (config.ntfyToken) headers.Authorization = 'Bearer ' + config.ntfyToken;
|
||||||
|
|
||||||
const frontend = isFrontendJob(job.title);
|
const frontend = isFrontendJob(job);
|
||||||
|
const roleTag = job.professionalTitle
|
||||||
|
? ' [' + job.professionalTitle + ']'
|
||||||
|
: '';
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
topic: config.ntfyTopic,
|
topic: config.ntfyTopic,
|
||||||
title: frontend ? 'Your Perfect Fit - Frontend' : 'New Job on Andela!',
|
title:
|
||||||
|
(frontend ? 'Your Perfect Fit - Frontend' : 'New Job on Andela!') +
|
||||||
|
roleTag,
|
||||||
message: job.title + ' - ' + companyName,
|
message: job.title + ' - ' + companyName,
|
||||||
tags: frontend ? ['star', 'computer'] : ['briefcase'],
|
tags: frontend ? ['star', 'computer'] : ['briefcase'],
|
||||||
priority: frontend ? 5 : 4,
|
priority: frontend ? 5 : 4,
|
||||||
@@ -293,20 +381,18 @@ async function sendNtfyNotification(job, companyName) {
|
|||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok)
|
||||||
if (!response.ok) {
|
|
||||||
log('ntfy push failed: ' + response.status + ' ' + response.statusText);
|
log('ntfy push failed: ' + response.status + ' ' + response.statusText);
|
||||||
} else {
|
else
|
||||||
log('ntfy push sent for job: ' + job.title);
|
log(
|
||||||
}
|
'ntfy push sent to topic "' + config.ntfyTopic + '" for: ' + job.title,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('ntfy push error:', error.message);
|
log('ntfy push error:', error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notifyNewJob(job) {
|
async function notifyNewJob(job) {
|
||||||
log('Notifying for job: ' + job.title);
|
|
||||||
|
|
||||||
await createOffscreen();
|
await createOffscreen();
|
||||||
chrome.runtime.sendMessage({ action: 'playSound' });
|
chrome.runtime.sendMessage({ action: 'playSound' });
|
||||||
|
|
||||||
@@ -324,9 +410,6 @@ async function notifyNewJob(job) {
|
|||||||
await sendNtfyNotification(job, companyName);
|
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) {
|
async function fetchPostedDate(url) {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
let tab;
|
let tab;
|
||||||
@@ -338,15 +421,8 @@ async function fetchPostedDate(url) {
|
|||||||
tab.id,
|
tab.id,
|
||||||
{ action: 'extractPostedDate' },
|
{ action: 'extractPostedDate' },
|
||||||
(response) => {
|
(response) => {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) resolve(null);
|
||||||
log(
|
else resolve((response && response.postedDate) || null);
|
||||||
'Error extracting posted date:',
|
|
||||||
chrome.runtime.lastError.message,
|
|
||||||
);
|
|
||||||
resolve(null);
|
|
||||||
} else {
|
|
||||||
resolve((response && response.postedDate) || null);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -366,7 +442,6 @@ function startKeepAlive() {
|
|||||||
keepAliveInterval = setInterval(() => {
|
keepAliveInterval = setInterval(() => {
|
||||||
chrome.runtime.getPlatformInfo(() => {
|
chrome.runtime.getPlatformInfo(() => {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
/* no-op, purpose is just to reset idle timer */
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 20000);
|
}, 20000);
|
||||||
@@ -379,39 +454,45 @@ function stopKeepAlive() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkForNewJobs() {
|
// Merges freshly scraped jobs into stored jobs, notifies on new opportunities,
|
||||||
log('=== Starting job check ===');
|
// fetches posted dates, tracks a "new" badge window per job, and persists
|
||||||
log('Current time:', new Date().toLocaleString());
|
// everything. Used by both check types.
|
||||||
|
async function processScrapedJobs(freshJobs) {
|
||||||
startKeepAlive();
|
const stored = await getStoredData([
|
||||||
|
'lastAndelaJobs',
|
||||||
try {
|
|
||||||
const allJobs = await fetchJobsViaContentScript();
|
|
||||||
const sortedJobs = sortJobsByRecency(allJobs);
|
|
||||||
|
|
||||||
const result = await getStoredData([
|
|
||||||
'notifiedJobIds',
|
'notifiedJobIds',
|
||||||
'postedDates',
|
'postedDates',
|
||||||
'backfillDone',
|
'backfillDone',
|
||||||
|
'newBadgeUntil',
|
||||||
]);
|
]);
|
||||||
const notifiedJobIds = new Set(result.notifiedJobIds || []);
|
const notifiedJobIds = new Set(stored.notifiedJobIds || []);
|
||||||
const postedDates = result.postedDates || {};
|
const postedDates = stored.postedDates || {};
|
||||||
const backfillDone = result.backfillDone || false;
|
const backfillDone = stored.backfillDone || false;
|
||||||
|
const previousJobs = stored.lastAndelaJobs || [];
|
||||||
|
const newBadgeUntil = stored.newBadgeUntil || {}; // { jobId: expiryTimestampMs }
|
||||||
|
|
||||||
sortedJobs.forEach((job) => {
|
// 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 (postedDates[job.id]) job.posted_ago = postedDates[job.id];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!backfillDone) {
|
if (!backfillDone) {
|
||||||
const toBackfill = sortedJobs.filter(
|
const toBackfill = merged.filter(
|
||||||
(job) => job.kind === 'opportunity' && !postedDates[job.id],
|
(job) => job.kind === 'opportunity' && !postedDates[job.id],
|
||||||
);
|
);
|
||||||
log(
|
log(
|
||||||
'Backfilling posted dates for ' +
|
'Backfilling posted dates for ' + toBackfill.length + ' existing jobs...',
|
||||||
toBackfill.length +
|
|
||||||
' existing jobs...',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const job of toBackfill) {
|
for (const job of toBackfill) {
|
||||||
const postedDate = await fetchPostedDate(job.url);
|
const postedDate = await fetchPostedDate(job.url);
|
||||||
if (postedDate) {
|
if (postedDate) {
|
||||||
@@ -421,11 +502,14 @@ async function checkForNewJobs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newJobs = sortedJobs.filter(
|
const newJobs = merged.filter(
|
||||||
(job) => job.kind === 'opportunity' && !notifiedJobIds.has(job.id),
|
(job) => job.kind === 'opportunity' && !notifiedJobIds.has(job.id),
|
||||||
);
|
);
|
||||||
log('Found ' + newJobs.length + ' new jobs');
|
log('Found ' + newJobs.length + ' new jobs');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const badgeDurationMs = MULTI_TITLE_INTERVAL * 60 * 1000;
|
||||||
|
|
||||||
for (const job of newJobs) {
|
for (const job of newJobs) {
|
||||||
const postedDate = await fetchPostedDate(job.url);
|
const postedDate = await fetchPostedDate(job.url);
|
||||||
if (postedDate) {
|
if (postedDate) {
|
||||||
@@ -434,40 +518,80 @@ async function checkForNewJobs() {
|
|||||||
}
|
}
|
||||||
await notifyNewJob(job);
|
await notifyNewJob(job);
|
||||||
notifiedJobIds.add(job.id);
|
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({
|
await setStoredData({
|
||||||
notifiedJobIds: Array.from(notifiedJobIds),
|
notifiedJobIds: Array.from(notifiedJobIds),
|
||||||
postedDates: postedDates,
|
postedDates: postedDates,
|
||||||
backfillDone: true,
|
backfillDone: true,
|
||||||
lastAndelaJobs: sortedJobs,
|
newBadgeUntil: newBadgeUntil,
|
||||||
|
lastAndelaJobs: merged,
|
||||||
lastAndelaUpdated: new Date().toISOString(),
|
lastAndelaUpdated: new Date().toISOString(),
|
||||||
totalAndelaMatching: sortedJobs.filter((j) => j.kind === 'opportunity')
|
totalAndelaMatching: merged.filter((j) => j.kind === 'opportunity').length,
|
||||||
.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 });
|
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) => {
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
if (alarm.name === 'checkAndelaJobs') checkForNewJobs();
|
if (alarm.name === 'fastCheck') checkForNewJobsFast();
|
||||||
|
if (alarm.name === 'multiTitleSweep') runMultiTitleSweep();
|
||||||
});
|
});
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||||
log('Received message:', request);
|
|
||||||
if (request.action === 'checkNow') {
|
if (request.action === 'checkNow') {
|
||||||
log('Manual check requested from popup');
|
checkForNewJobsFast();
|
||||||
checkForNewJobs();
|
|
||||||
sendResponse({ status: 'Check initiated' });
|
sendResponse({ status: 'Check initiated' });
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -475,14 +599,12 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|||||||
|
|
||||||
createOffscreen().catch(console.error);
|
createOffscreen().catch(console.error);
|
||||||
|
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(async () => {
|
||||||
log('Extension installed/updated, checking for jobs...');
|
await runMultiTitleSweep(); // covers Full-Stack too, so no separate fast check needed right after
|
||||||
checkForNewJobs();
|
|
||||||
testNotification();
|
testNotification();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function testNotification() {
|
async function testNotification() {
|
||||||
log('Testing notification system...');
|
|
||||||
chrome.notifications.create('test-notification', {
|
chrome.notifications.create('test-notification', {
|
||||||
type: 'basic',
|
type: 'basic',
|
||||||
iconUrl: NOTIFICATION_ICON,
|
iconUrl: NOTIFICATION_ICON,
|
||||||
@@ -492,19 +614,14 @@ async function testNotification() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Andela Job Notifier started at:', new Date().toLocaleString());
|
['fastCheck', 'multiTitleSweep'].forEach((name) => {
|
||||||
|
chrome.alarms.get(name, (alarm) => {
|
||||||
chrome.alarms.get('checkAndelaJobs', (alarm) => {
|
if (!alarm) {
|
||||||
if (alarm) {
|
chrome.alarms.create(name, {
|
||||||
log(
|
periodInMinutes:
|
||||||
'Alarm is set, next check at:',
|
name === 'fastCheck' ? FAST_CHECK_INTERVAL : MULTI_TITLE_INTERVAL,
|
||||||
new Date(alarm.scheduledTime).toLocaleString(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log('No alarm found, creating one...');
|
|
||||||
chrome.alarms.create('checkAndelaJobs', {
|
|
||||||
periodInMinutes: CHECK_INTERVAL,
|
|
||||||
delayInMinutes: 0.1,
|
delayInMinutes: 0.1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// content.js - Handles job extraction and login automation for app.andela.com
|
// content.js - Handles job extraction, login automation, and profile
|
||||||
|
// title switching for app.andela.com
|
||||||
|
|
||||||
console.log('[Andela Content Script] Loaded on', window.location.href);
|
console.log('[Andela Content Script] Loaded on', window.location.href);
|
||||||
|
|
||||||
@@ -7,25 +8,13 @@ function isLoginPage() {
|
|||||||
var emailLabel = document.querySelector('label[for="email"]');
|
var emailLabel = document.querySelector('label[for="email"]');
|
||||||
var hasLoginForm = !!(emailInput && emailLabel);
|
var hasLoginForm = !!(emailInput && emailLabel);
|
||||||
var isLoginUrl = window.location.pathname.indexOf('/login') === 0;
|
var isLoginUrl = window.location.pathname.indexOf('/login') === 0;
|
||||||
|
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Login check - hasForm:',
|
|
||||||
hasLoginForm,
|
|
||||||
'isLoginUrl:',
|
|
||||||
isLoginUrl,
|
|
||||||
);
|
|
||||||
return hasLoginForm || (isLoginUrl && !!emailInput);
|
return hasLoginForm || (isLoginUrl && !!emailInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterEmailAndClickLogin(email) {
|
function enterEmailAndClickLogin(email) {
|
||||||
try {
|
try {
|
||||||
console.log('[Andela Content Script] Attempting to enter email:', email);
|
|
||||||
|
|
||||||
var emailInput = document.querySelector('input[type="email"]#email');
|
var emailInput = document.querySelector('input[type="email"]#email');
|
||||||
if (!emailInput) {
|
if (!emailInput) return false;
|
||||||
console.log('[Andela Content Script] Email input not found');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
emailInput.focus();
|
emailInput.focus();
|
||||||
emailInput.value = email;
|
emailInput.value = email;
|
||||||
@@ -33,39 +22,26 @@ function enterEmailAndClickLogin(email) {
|
|||||||
emailInput.dispatchEvent(new Event('change', { bubbles: true }));
|
emailInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
emailInput.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
emailInput.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||||
|
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Email entered, looking for submit button...',
|
|
||||||
);
|
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
var submitButton = document.querySelector('button[type="submit"]');
|
var submitButton = document.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
if (!submitButton) {
|
if (!submitButton) {
|
||||||
var buttons = document.querySelectorAll('button');
|
var buttons = document.querySelectorAll('button');
|
||||||
for (var i = 0; i < buttons.length; i++) {
|
for (var i = 0; i < buttons.length; i++) {
|
||||||
var btnText = buttons[i].textContent.toLowerCase();
|
var t = buttons[i].textContent.toLowerCase();
|
||||||
if (
|
if (
|
||||||
btnText.indexOf('login') !== -1 ||
|
t.indexOf('login') !== -1 ||
|
||||||
btnText.indexOf('sign in') !== -1 ||
|
t.indexOf('sign in') !== -1 ||
|
||||||
btnText.indexOf('continue') !== -1 ||
|
t.indexOf('continue') !== -1 ||
|
||||||
btnText.indexOf('submit') !== -1
|
t.indexOf('submit') !== -1
|
||||||
) {
|
) {
|
||||||
submitButton = buttons[i];
|
submitButton = buttons[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submitButton) {
|
if (submitButton) {
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Clicking submit button:',
|
|
||||||
submitButton.textContent,
|
|
||||||
);
|
|
||||||
submitButton.click();
|
submitButton.click();
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Submit button not found, trying form submit',
|
|
||||||
);
|
|
||||||
var form = emailInput.closest('form');
|
var form = emailInput.closest('form');
|
||||||
if (form) {
|
if (form) {
|
||||||
if (form.requestSubmit) form.requestSubmit();
|
if (form.requestSubmit) form.requestSubmit();
|
||||||
@@ -89,15 +65,13 @@ function isOpportunitySection(headingText) {
|
|||||||
return /opportunit/i.test(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() {
|
function getPageSections() {
|
||||||
var elements = document.querySelectorAll(
|
var elements = document.querySelectorAll(
|
||||||
'h3, button, a[href*="/job-postings/"]',
|
'h3, button, a[href*="/job-postings/"]',
|
||||||
);
|
);
|
||||||
var currentHeading = '';
|
var currentHeading = '';
|
||||||
var seeMoreButtons = []; // { button, heading }
|
var seeMoreButtons = [];
|
||||||
var jobLinks = []; // { link, heading }
|
var jobLinks = [];
|
||||||
|
|
||||||
elements.forEach(function (el) {
|
elements.forEach(function (el) {
|
||||||
if (el.tagName === 'H3') {
|
if (el.tagName === 'H3') {
|
||||||
@@ -123,8 +97,6 @@ function getPageSections() {
|
|||||||
return { seeMoreButtons: seeMoreButtons, jobLinks: jobLinks };
|
return { seeMoreButtons: seeMoreButtons, jobLinks: jobLinks };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only expands "See more" under opportunity sections — leaves the
|
|
||||||
// applied-jobs section collapsed as-is.
|
|
||||||
function expandAllSeeMoreButtons(maxRounds) {
|
function expandAllSeeMoreButtons(maxRounds) {
|
||||||
maxRounds = maxRounds || 25;
|
maxRounds = maxRounds || 25;
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
@@ -134,9 +106,7 @@ function expandAllSeeMoreButtons(maxRounds) {
|
|||||||
var sections = getPageSections();
|
var sections = getPageSections();
|
||||||
for (var i = 0; i < sections.seeMoreButtons.length; i++) {
|
for (var i = 0; i < sections.seeMoreButtons.length; i++) {
|
||||||
var entry = sections.seeMoreButtons[i];
|
var entry = sections.seeMoreButtons[i];
|
||||||
if (isOpportunitySection(entry.heading)) {
|
if (isOpportunitySection(entry.heading)) return entry.button;
|
||||||
return entry.button;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -145,15 +115,9 @@ function expandAllSeeMoreButtons(maxRounds) {
|
|||||||
rounds++;
|
rounds++;
|
||||||
var btn = findSeeMoreButton();
|
var btn = findSeeMoreButton();
|
||||||
if (!btn || rounds > maxRounds) {
|
if (!btn || rounds > maxRounds) {
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Done expanding opportunity "See more" buttons after',
|
|
||||||
rounds - 1,
|
|
||||||
'click(s)',
|
|
||||||
);
|
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[Andela Content Script] Clicking:', btn.textContent.trim());
|
|
||||||
btn.click();
|
btn.click();
|
||||||
setTimeout(clickNext, 700);
|
setTimeout(clickNext, 700);
|
||||||
}
|
}
|
||||||
@@ -199,9 +163,7 @@ function extractJobFromLink(link) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var url = href;
|
var url = href;
|
||||||
if (url && url.indexOf('http') !== 0) {
|
if (url && url.indexOf('http') !== 0) url = window.location.origin + url;
|
||||||
url = window.location.origin + url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
@@ -215,13 +177,9 @@ function extractJobFromLink(link) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractJobsFromPage() {
|
function extractJobsFromPage() {
|
||||||
console.log('[Andela Content Script] Extracting jobs...');
|
|
||||||
var jobs = [];
|
var jobs = [];
|
||||||
var seen = {};
|
var seen = {};
|
||||||
var jobLinks = getPageSections().jobLinks;
|
var jobLinks = getPageSections().jobLinks;
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Found ' + jobLinks.length + ' job-posting links',
|
|
||||||
);
|
|
||||||
|
|
||||||
jobLinks.forEach(function (entry) {
|
jobLinks.forEach(function (entry) {
|
||||||
try {
|
try {
|
||||||
@@ -232,16 +190,7 @@ function extractJobsFromPage() {
|
|||||||
job.kind = isOpportunitySection(entry.heading)
|
job.kind = isOpportunitySection(entry.heading)
|
||||||
? 'opportunity'
|
? 'opportunity'
|
||||||
: 'applied';
|
: 'applied';
|
||||||
if (job.title) {
|
if (job.title) jobs.push(job);
|
||||||
jobs.push(job);
|
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Extracted job:',
|
|
||||||
job.title,
|
|
||||||
'(' + job.id + ')',
|
|
||||||
'-',
|
|
||||||
job.kind,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
'[Andela Content Script] Error extracting a job card:',
|
'[Andela Content Script] Error extracting a job card:',
|
||||||
@@ -250,21 +199,148 @@ function extractJobsFromPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
|
||||||
'[Andela Content Script] Extracted ' + jobs.length + ' jobs total',
|
|
||||||
);
|
|
||||||
return jobs;
|
return jobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
|
// ============================================
|
||||||
console.log('[Andela Content Script] Received message:', request);
|
// Profile title switching (used to surface jobs for other roles)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function getCurrentProfileTitle() {
|
||||||
|
var el = document.querySelector('.text-primary-600.mt-0\\.5.text-sm');
|
||||||
|
return el ? el.textContent.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitFor(conditionFn, timeoutMs, intervalMs) {
|
||||||
|
timeoutMs = timeoutMs || 5000;
|
||||||
|
intervalMs = intervalMs || 200;
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var elapsed = 0;
|
||||||
|
function check() {
|
||||||
|
var result = conditionFn();
|
||||||
|
if (result) {
|
||||||
|
resolve(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elapsed += intervalMs;
|
||||||
|
if (elapsed >= timeoutMs) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(check, intervalMs);
|
||||||
|
}
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain .click() doesn't reliably open this dropdown when dispatched
|
||||||
|
// programmatically — needs the full pointer-event sequence instead.
|
||||||
|
function dispatchFullClick(el) {
|
||||||
|
el.focus();
|
||||||
|
['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(
|
||||||
|
function (type) {
|
||||||
|
el.dispatchEvent(
|
||||||
|
new MouseEvent(type, { bubbles: true, cancelable: true, view: window }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setProfessionalTitle(title) {
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] setProfessionalTitle: looking for edit button...',
|
||||||
|
);
|
||||||
|
var editBtn = await waitFor(function () {
|
||||||
|
return document.querySelector('button[aria-label="Edit profile details"]');
|
||||||
|
}, 6000);
|
||||||
|
if (!editBtn) {
|
||||||
|
console.log('[Andela Content Script] STEP FAILED: edit button not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] STEP OK: edit button found, clicking...',
|
||||||
|
);
|
||||||
|
editBtn.click();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] looking for professional_title input...',
|
||||||
|
);
|
||||||
|
var titleInput = await waitFor(function () {
|
||||||
|
return document.querySelector('#professional_title');
|
||||||
|
}, 5000);
|
||||||
|
if (!titleInput) {
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] STEP FAILED: professional_title input not found (modal may not have opened)',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] STEP OK: input found, opening dropdown...',
|
||||||
|
);
|
||||||
|
dispatchFullClick(titleInput);
|
||||||
|
|
||||||
|
console.log('[Andela Content Script] looking for option matching:', title);
|
||||||
|
var optionEl = await waitFor(
|
||||||
|
function () {
|
||||||
|
var options = document.querySelectorAll('[role="option"]');
|
||||||
|
for (var i = 0; i < options.length; i++) {
|
||||||
|
var span = options[i].querySelector('span.col-start-2') || options[i];
|
||||||
|
if (
|
||||||
|
span &&
|
||||||
|
span.textContent.trim().toLowerCase() === title.toLowerCase()
|
||||||
|
) {
|
||||||
|
return options[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
if (!optionEl) {
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] STEP FAILED: no option matched "' + title + '"',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('[Andela Content Script] STEP OK: option found, clicking...');
|
||||||
|
optionEl.click();
|
||||||
|
|
||||||
|
await new Promise(function (r) {
|
||||||
|
setTimeout(r, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Andela Content Script] looking for save button...');
|
||||||
|
var saveBtn = document.querySelector(
|
||||||
|
'button[type="submit"][form="edit-profile-details-form"]',
|
||||||
|
);
|
||||||
|
if (!saveBtn) {
|
||||||
|
console.log('[Andela Content Script] STEP FAILED: save button not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] STEP OK: save button found, clicking...',
|
||||||
|
);
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
console.log('[Andela Content Script] waiting for modal to close...');
|
||||||
|
var closed = await waitFor(function () {
|
||||||
|
return !document.querySelector('#professional_title');
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'[Andela Content Script] setProfessionalTitle(' + title + ') closed modal:',
|
||||||
|
!!closed,
|
||||||
|
);
|
||||||
|
return !!closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
|
||||||
if (request.action === 'extractJobs') {
|
if (request.action === 'extractJobs') {
|
||||||
expandAllSeeMoreButtons().then(function () {
|
expandAllSeeMoreButtons().then(function () {
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
try {
|
try {
|
||||||
var jobs = extractJobsFromPage();
|
sendResponse({ jobs: extractJobsFromPage() });
|
||||||
sendResponse({ jobs: jobs });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Andela Content Script] Error:', error);
|
console.error('[Andela Content Script] Error:', error);
|
||||||
sendResponse({ jobs: [] });
|
sendResponse({ jobs: [] });
|
||||||
@@ -275,24 +351,31 @@ chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.action === 'checkLoginPage') {
|
if (request.action === 'checkLoginPage') {
|
||||||
var result = isLoginPage();
|
sendResponse({ isLoginPage: isLoginPage() });
|
||||||
console.log('[Andela Content Script] Is login page:', result);
|
return true;
|
||||||
sendResponse({ isLoginPage: result });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.action === 'enterEmailAndLogin') {
|
if (request.action === 'enterEmailAndLogin') {
|
||||||
var success = enterEmailAndClickLogin(request.email);
|
sendResponse({ success: enterEmailAndClickLogin(request.email) });
|
||||||
sendResponse({ success: success });
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.action === 'extractPostedDate') {
|
if (request.action === 'extractPostedDate') {
|
||||||
var el = document.querySelector('span.text-success-500');
|
var el = document.querySelector('span.text-success-500');
|
||||||
var postedText = el ? el.textContent.trim() : '';
|
sendResponse({ postedDate: el ? el.textContent.trim() : '' });
|
||||||
console.log(
|
return true;
|
||||||
'[Andela Content Script] Extracted posted date text:',
|
}
|
||||||
postedText,
|
|
||||||
);
|
if (request.action === 'getCurrentProfileTitle') {
|
||||||
sendResponse({ postedDate: postedText });
|
sendResponse({ title: getCurrentProfileTitle() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.action === 'setProfessionalTitle') {
|
||||||
|
setProfessionalTitle(request.title).then(function (success) {
|
||||||
|
sendResponse({ success: success });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
<title>Andela Job Notifier</title>
|
<title>Andela Job Notifier</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
@@ -28,6 +29,21 @@
|
|||||||
.section-heading-applied {
|
.section-heading-applied {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
|
.profession-heading {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.profession-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.job-item {
|
.job-item {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -43,6 +59,17 @@
|
|||||||
color: #333;
|
color: #333;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
.new-badge {
|
||||||
|
display: inline-block;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-right: 6px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
.job-company {
|
.job-company {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,38 +1,26 @@
|
|||||||
// popup.js
|
// popup.js
|
||||||
|
|
||||||
|
var TITLE_ORDER = [
|
||||||
|
'Full-Stack Engineer',
|
||||||
|
'AI Engineer',
|
||||||
|
'Data Scientist',
|
||||||
|
'Front-End Engineer',
|
||||||
|
];
|
||||||
|
|
||||||
|
var PROFESSION_BADGES = {
|
||||||
|
'Full-Stack Engineer': { label: 'FS', color: '#4C6EF5' },
|
||||||
|
'AI Engineer': { label: 'AI', color: '#9C36B5' },
|
||||||
|
'Data Scientist': { label: 'DS', color: '#0CA678' },
|
||||||
|
'Front-End Engineer': { label: 'FE', color: '#E8590C' },
|
||||||
|
};
|
||||||
|
|
||||||
function sortJobsById(jobs) {
|
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);
|
return (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUI(data) {
|
function renderJob(jobList, job, newBadgeUntil, now) {
|
||||||
var lastAndelaJobs = data.lastAndelaJobs || [];
|
|
||||||
var lastAndelaUpdated = data.lastAndelaUpdated;
|
|
||||||
var totalAndelaMatching = data.totalAndelaMatching || 0;
|
|
||||||
|
|
||||||
document.getElementById('jobCount').textContent =
|
|
||||||
totalAndelaMatching + ' open opportunities';
|
|
||||||
|
|
||||||
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 opportunities = sortJobsById(
|
|
||||||
lastAndelaJobs.filter(function (j) {
|
|
||||||
return j.kind === 'opportunity';
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
var applied = sortJobsById(
|
|
||||||
lastAndelaJobs.filter(function (j) {
|
|
||||||
return j.kind !== 'opportunity';
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
function renderJob(job) {
|
|
||||||
var jobElement = document.createElement('div');
|
var jobElement = document.createElement('div');
|
||||||
jobElement.className = 'job-item';
|
jobElement.className = 'job-item';
|
||||||
|
|
||||||
@@ -43,8 +31,26 @@ function updateUI(data) {
|
|||||||
? (job.company && job.company.name) || 'Company'
|
? (job.company && job.company.name) || 'Company'
|
||||||
: job.company || 'Company';
|
: job.company || 'Company';
|
||||||
|
|
||||||
|
var isNew =
|
||||||
|
newBadgeUntil && newBadgeUntil[job.id] && newBadgeUntil[job.id] > now;
|
||||||
|
var badgeHtml = '';
|
||||||
|
if (isNew) {
|
||||||
|
var profession = job.professionalTitle || 'Full-Stack Engineer';
|
||||||
|
var badgeInfo = PROFESSION_BADGES[profession] || {
|
||||||
|
label: 'NEW',
|
||||||
|
color: '#555555',
|
||||||
|
};
|
||||||
|
badgeHtml =
|
||||||
|
'<span class="new-badge" style="background-color:' +
|
||||||
|
badgeInfo.color +
|
||||||
|
'">' +
|
||||||
|
badgeInfo.label +
|
||||||
|
' \u00B7 NEW</span>';
|
||||||
|
}
|
||||||
|
|
||||||
jobElement.innerHTML =
|
jobElement.innerHTML =
|
||||||
'<div class="job-title">' +
|
'<div class="job-title">' +
|
||||||
|
badgeHtml +
|
||||||
job.title +
|
job.title +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="job-company">' +
|
'<div class="job-company">' +
|
||||||
@@ -55,7 +61,7 @@ function updateUI(data) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="job-posted">' +
|
'<div class="job-posted">' +
|
||||||
startInfo +
|
startInfo +
|
||||||
(postedAgo ? ' · ' + postedAgo : '') +
|
(postedAgo ? ' - ' + postedAgo : '') +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
jobElement.addEventListener('click', function () {
|
jobElement.addEventListener('click', function () {
|
||||||
@@ -64,14 +70,81 @@ function updateUI(data) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jobList.appendChild(jobElement);
|
jobList.appendChild(jobElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortWithNewFirst(jobs, newBadgeUntil, now) {
|
||||||
|
return jobs.slice().sort(function (a, b) {
|
||||||
|
var aNew =
|
||||||
|
newBadgeUntil && newBadgeUntil[a.id] && newBadgeUntil[a.id] > now ? 1 : 0;
|
||||||
|
var bNew =
|
||||||
|
newBadgeUntil && newBadgeUntil[b.id] && newBadgeUntil[b.id] > now ? 1 : 0;
|
||||||
|
if (aNew !== bNew) return bNew - aNew;
|
||||||
|
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;
|
||||||
|
var newBadgeUntil = data.newBadgeUntil || {};
|
||||||
|
var now = Date.now();
|
||||||
|
|
||||||
|
document.getElementById('jobCount').textContent =
|
||||||
|
totalAndelaMatching + ' open opportunities';
|
||||||
|
|
||||||
|
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>';
|
||||||
|
var status0 = document.getElementById('status');
|
||||||
|
status0.textContent = lastAndelaUpdated
|
||||||
|
? 'Last checked: ' + new Date(lastAndelaUpdated).toLocaleString()
|
||||||
|
: 'Not checked yet';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var opportunities = lastAndelaJobs.filter(function (j) {
|
||||||
|
return j.kind === 'opportunity';
|
||||||
|
});
|
||||||
|
var applied = sortJobsById(
|
||||||
|
lastAndelaJobs.filter(function (j) {
|
||||||
|
return j.kind !== 'opportunity';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (opportunities.length) {
|
if (opportunities.length) {
|
||||||
var oppHeading = document.createElement('div');
|
var oppHeading = document.createElement('div');
|
||||||
oppHeading.className = 'section-heading section-heading-opportunities';
|
oppHeading.className = 'section-heading section-heading-opportunities';
|
||||||
oppHeading.textContent = 'Open Opportunities';
|
oppHeading.textContent = 'Open Opportunities';
|
||||||
jobList.appendChild(oppHeading);
|
jobList.appendChild(oppHeading);
|
||||||
opportunities.slice(0, 10).forEach(renderJob);
|
|
||||||
|
TITLE_ORDER.forEach(function (title) {
|
||||||
|
var group = sortWithNewFirst(
|
||||||
|
opportunities.filter(function (j) {
|
||||||
|
return (j.professionalTitle || 'Full-Stack Engineer') === title;
|
||||||
|
}),
|
||||||
|
newBadgeUntil,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
if (!group.length) return;
|
||||||
|
|
||||||
|
var badgeInfo = PROFESSION_BADGES[title] || { color: '#C9971C' };
|
||||||
|
var titleHeading = document.createElement('h3');
|
||||||
|
titleHeading.className = 'profession-heading';
|
||||||
|
titleHeading.innerHTML =
|
||||||
|
'<span class="profession-dot" style="background-color:' +
|
||||||
|
badgeInfo.color +
|
||||||
|
'"></span>' +
|
||||||
|
title;
|
||||||
|
jobList.appendChild(titleHeading);
|
||||||
|
|
||||||
|
group.slice(0, 10).forEach(function (job) {
|
||||||
|
renderJob(jobList, job, newBadgeUntil, now);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (applied.length) {
|
if (applied.length) {
|
||||||
@@ -79,8 +152,9 @@ function updateUI(data) {
|
|||||||
appliedHeading.className = 'section-heading section-heading-applied';
|
appliedHeading.className = 'section-heading section-heading-applied';
|
||||||
appliedHeading.textContent = 'Applied';
|
appliedHeading.textContent = 'Applied';
|
||||||
jobList.appendChild(appliedHeading);
|
jobList.appendChild(appliedHeading);
|
||||||
applied.slice(0, 10).forEach(renderJob);
|
applied.slice(0, 10).forEach(function (job) {
|
||||||
}
|
renderJob(jobList, job, newBadgeUntil, now);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var status = document.getElementById('status');
|
var status = document.getElementById('status');
|
||||||
@@ -91,10 +165,7 @@ function updateUI(data) {
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
document.getElementById('checkNow').addEventListener('click', function () {
|
document.getElementById('checkNow').addEventListener('click', function () {
|
||||||
console.log('Manual check triggered');
|
chrome.runtime.sendMessage({ action: 'checkNow' }, function () {});
|
||||||
chrome.runtime.sendMessage({ action: 'checkNow' }, function (response) {
|
|
||||||
console.log('Check initiated');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
@@ -104,10 +175,13 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
chrome.storage.local.get(
|
chrome.storage.local.get(
|
||||||
['lastAndelaJobs', 'lastAndelaUpdated', 'totalAndelaMatching'],
|
[
|
||||||
function (result) {
|
'lastAndelaJobs',
|
||||||
updateUI(result);
|
'lastAndelaUpdated',
|
||||||
},
|
'totalAndelaMatching',
|
||||||
|
'newBadgeUntil',
|
||||||
|
],
|
||||||
|
updateUI,
|
||||||
);
|
);
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(function (changes, areaName) {
|
chrome.storage.onChanged.addListener(function (changes, areaName) {
|
||||||
@@ -115,14 +189,17 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
areaName === 'local' &&
|
areaName === 'local' &&
|
||||||
(changes.lastAndelaJobs ||
|
(changes.lastAndelaJobs ||
|
||||||
changes.lastAndelaUpdated ||
|
changes.lastAndelaUpdated ||
|
||||||
changes.totalAndelaMatching)
|
changes.totalAndelaMatching ||
|
||||||
|
changes.newBadgeUntil)
|
||||||
) {
|
) {
|
||||||
console.log('Storage updated, refreshing UI...');
|
|
||||||
chrome.storage.local.get(
|
chrome.storage.local.get(
|
||||||
['lastAndelaJobs', 'lastAndelaUpdated', 'totalAndelaMatching'],
|
[
|
||||||
function (result) {
|
'lastAndelaJobs',
|
||||||
updateUI(result);
|
'lastAndelaUpdated',
|
||||||
},
|
'totalAndelaMatching',
|
||||||
|
'newBadgeUntil',
|
||||||
|
],
|
||||||
|
updateUI,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// settings.js - Handle secure configuration storage
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
chrome.storage.local.get(
|
chrome.storage.local.get(
|
||||||
[
|
[
|
||||||
@@ -19,8 +17,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
document.getElementById('loginEmail').value = result.loginEmail;
|
document.getElementById('loginEmail').value = result.loginEmail;
|
||||||
document.getElementById('ntfyServer').value =
|
document.getElementById('ntfyServer').value =
|
||||||
result.ntfyServer || 'https://notify.tstitagency.com';
|
result.ntfyServer || 'https://notify.tstitagency.com';
|
||||||
if (result.ntfyTopic)
|
document.getElementById('ntfyTopic').value =
|
||||||
document.getElementById('ntfyTopic').value = result.ntfyTopic;
|
result.ntfyTopic || 'andela-jobs';
|
||||||
if (result.ntfyToken)
|
if (result.ntfyToken)
|
||||||
document.getElementById('ntfyToken').value = result.ntfyToken;
|
document.getElementById('ntfyToken').value = result.ntfyToken;
|
||||||
},
|
},
|
||||||
@@ -47,8 +45,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
appsScriptUrl: appsScriptUrl,
|
appsScriptUrl: appsScriptUrl,
|
||||||
appsScriptKey: appsScriptKey,
|
appsScriptKey: appsScriptKey,
|
||||||
loginEmail: loginEmail,
|
loginEmail: loginEmail,
|
||||||
ntfyServer: ntfyServer,
|
ntfyServer: ntfyServer || 'https://notify.tstitagency.com',
|
||||||
ntfyTopic: ntfyTopic,
|
ntfyTopic: ntfyTopic || 'andela-jobs',
|
||||||
ntfyToken: ntfyToken,
|
ntfyToken: ntfyToken,
|
||||||
},
|
},
|
||||||
function () {
|
function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user