|
import {dirname,sep} from 'node:path'; |
|
import {fileURLToPath} from 'node:url'; |
|
import {chromium} from '@playwright/test' // Or 'firefox' or 'webkit'. |
|
import {Bot} from "grammy"; |
|
import {readFileSync,writeFileSync,existsSync} from 'node:fs'; |
|
|
|
/** Constants */ |
|
const __dirname = dirname(fileURLToPath(import.meta.url)) |
|
const JOB_SEARCH_URL = process.env.JOB_SEARCH_URL; |
|
const LAST_RUN_STAMP_PATH = `${__dirname}${sep}last_run_stamp.json`; |
|
const AUTH_STATE_PATH = `${__dirname}${sep}auth_state.json`; |
|
const MY_TELEGRAM_USER_ID = process.env.MY_TELEGRAM_USER_ID; |
|
const TELEGRAM_API_KEY = process.env.TELEGRAM_API_KEY; |
|
const CHECK_FREQUENCY_MINUTES = process.env.CHECK_FREQUENCY_MINUTES; |
|
|
|
/** Frequently used options */ |
|
const waitOptions = {state:'attached',timeout: 6000}; |
|
const sendMessageOptions = {parse_mode:'HTML',link_preview_options:{is_disabled:true}}; |
|
|
|
|
|
/** |
|
* Initialize |
|
*/ |
|
const bot = new Bot(TELEGRAM_API_KEY); |
|
if (process.env.NODE_ENV !== 'development') { |
|
bot.api.sendMessage(MY_TELEGRAM_USER_ID, `Starting LinkedIn notifier for <a href="${JOB_SEARCH_URL}">Job Search</a>`,sendMessageOptions); |
|
} |
|
|
|
ensureJSONFileExists(AUTH_STATE_PATH,{}); |
|
ensureJSONFileExists(LAST_RUN_STAMP_PATH,Date.now() - 1000*60*60*24); // default to 1 day ago |
|
|
|
// Run the notifier once immediately, and again every CHECK_FREQUENCY_MINUTES |
|
setInterval(startNotifier, CHECK_FREQUENCY_MINUTES * 60 * 1000); |
|
startNotifier(); |
|
|
|
|
|
/** |
|
* Main function to run the bot. |
|
*/ |
|
async function startNotifier() { |
|
const lastRunTime = readLastRun(); |
|
const currentRunTime = Date.now(); |
|
const secondsSinceLastRun = Math.floor((currentRunTime-lastRunTime)/1000); |
|
const _1DayInSeconds = 24 * 60 * 60; |
|
const secondsToQuery = Math.min(secondsSinceLastRun, _1DayInSeconds); |
|
const JOB_SEARCH_URL_LATEST = JOB_SEARCH_URL.replace(/f_TPR=r\d+/,`f_TPR=r${secondsToQuery}`); |
|
const browser = await chromium.launch({ |
|
headless: true, |
|
// chrome flag to automatically open developer tools on start, for debugging |
|
// args: ['--auto-open-devtools-for-tabs'], |
|
}); |
|
|
|
try{ |
|
|
|
const page = await browser.newPage({ |
|
storageState: AUTH_STATE_PATH, |
|
// Arbitrary viewport numbers, but changing them may break the script |
|
// if linkedin generates different html at different sizes |
|
viewport:{ width: 3000, height: 1700 } |
|
}); |
|
|
|
await page.goto(JOB_SEARCH_URL_LATEST,{waitUntil:'load'}); |
|
|
|
const needsAuth = await Promise.any([ |
|
page.locator('.global-nav').waitFor(waitOptions).then(()=>false), |
|
page.locator('#base-contextual-sign-in-modal').waitFor(waitOptions).then(()=>true), |
|
]); |
|
|
|
if(needsAuth){ |
|
await page.goto('https://www.linkedin.com/login') |
|
const rememeberedLocator = page.locator('#rememberme-div button.member-profile__details'); |
|
const emailOrPhoneLocator = page.getByLabel('Email or phone'); |
|
const hasWelcomeBack = await Promise.any([ |
|
rememeberedLocator.waitFor(waitOptions).then(()=>true), |
|
emailOrPhoneLocator.waitFor(waitOptions).then(()=>false) |
|
]); |
|
if (hasWelcomeBack){ |
|
await rememeberedLocator.click() |
|
} else{ |
|
await emailOrPhoneLocator.fill(process.env.LINKEDIN_EMAIL); |
|
await page.getByLabel('Password').fill(process.env.LINKEDIN_PASS); |
|
await page.getByRole('button', { name: 'Sign in',exact:true }).click(); |
|
await page.waitForSelector('.profile-card-member-details',waitOptions); |
|
// persist the authorization data |
|
await page.context().storageState({path: AUTH_STATE_PATH}); |
|
} |
|
await page.goto(JOB_SEARCH_URL_LATEST); |
|
} |
|
|
|
const postedDateLocator = page.locator('.job-details-jobs-unified-top-card__primary-description-container'); |
|
const linkLocator = page.locator('.job-details-jobs-unified-top-card__job-title a'); |
|
const descriptionSelector = '#job-details'; |
|
const descriptionLocator = page.locator(descriptionSelector); |
|
const detailsCompanyLocator = page.locator('.job-details-jobs-unified-top-card__company-name'); |
|
const detailsTitleLocator = page.locator('.job-details-jobs-unified-top-card__job-title'); |
|
const positionLocators = await getPositionLocators(page); |
|
|
|
const minutes = Math.floor(secondsToQuery/60); |
|
const hours = Math.floor(minutes/60); |
|
const seconds = secondsToQuery%60; |
|
console.log(`${positionLocators.length} new jobs posted since ${hours} hours ${minutes%60} minutes ${seconds} seconds ago`); |
|
|
|
// parse messages in one loop and publish in a second loop. Ensures lastRunTime only |
|
// gets written when all positions are parsed successfully, preventing duplicate messages. |
|
const messages = []; |
|
for (const p of positionLocators){ |
|
await p.scrollIntoViewIfNeeded(); |
|
const detailsTriggerLocator = p.locator('a'); |
|
const [title, company, location, otherDetails] = (await Promise.all([ |
|
p.locator('.artdeco-entity-lockup__title strong').innerText().catch(()=>''), |
|
p.locator('.artdeco-entity-lockup__subtitle').innerText().catch(()=>''), |
|
p.locator('.artdeco-entity-lockup__caption').innerText().catch(()=>''), |
|
p.locator('.artdeco-entity-lockup__metadata').first().innerText().catch(()=>''), |
|
])).map(text=>text.trim()); |
|
|
|
await Promise.all([ |
|
detailsTriggerLocator.click(), |
|
waitForTextMatch(detailsCompanyLocator, company), |
|
waitForTextMatch(detailsTitleLocator, title) |
|
]); |
|
const description = (await descriptionLocator.innerText()).trim().replace(/(\n\n)+/g,'\n\n'); |
|
const linkURL = (await linkLocator.evaluate(el=>el.href)).replace(/\?.*$/,'').trimStart(); |
|
const posted = (await postedDateLocator.innerText()).split('·')[1].trim(); |
|
const msg = `<b><a href="${linkURL}">${title}</a></b>\n${company}\n${location}\n${posted}\n${otherDetails}\n<blockquote expandable></blockquote>`; |
|
|
|
// Telegram has 4096 char sendMessage constraint; truncate over that. |
|
const totalLen = msg.length + description.length; |
|
const overAmt = Math.max(totalLen - 4096,0); |
|
const truncatedDesc = overAmt > 0 ? description.slice(0,-overAmt) : description; |
|
|
|
// collapse the description so messages aren't huge. |
|
const collapsedMessage = msg.replace('</blockquote>',`${truncatedDesc}</blockquote>`); |
|
messages.push(collapsedMessage); |
|
}; |
|
|
|
messages.forEach(message=>{ |
|
bot.api.sendMessage(MY_TELEGRAM_USER_ID, message,sendMessageOptions); |
|
}); |
|
writeLastRun(currentRunTime); |
|
} catch(e){ |
|
console.error(e); |
|
} |
|
|
|
browser.close(); |
|
} |
|
|
|
|
|
/** |
|
* Utilities |
|
*/ |
|
|
|
/** |
|
* Reads the last run time |
|
* @returns timeStamp of last run, else the current timestamp |
|
*/ |
|
function readLastRun () { |
|
// convert timestamp to number or empty string to 0. |
|
const data = +readJsonFile(LAST_RUN_STAMP_PATH); |
|
return data || Date.now(); |
|
} |
|
|
|
/** |
|
* Persists the current time to track last run so |
|
* the script can search the time since it was last run. |
|
* This is used to track the last time the script was run. |
|
* @param {number} currentRunTime - The current time to persist |
|
* @returns {void} |
|
*/ |
|
function writeLastRun (currentRunTime){ |
|
return writeJsonFile(LAST_RUN_STAMP_PATH, currentRunTime); |
|
} |
|
|
|
/** |
|
* Gets the position locators in the first page of results (up to 25 currently). |
|
* @param {@type import('@playwright/test').Page} page - The Playwright page object. |
|
* @returns {Promise<import('@playwright/test').Locator[]>} |
|
*/ |
|
async function getPositionLocators (page) { |
|
const noResultsLocator = page.locator('.jobs-search-no-results-banner'); |
|
const positionListLocator = page.locator('.jobs-search-results-list__header + div > ul'); |
|
const positionLocator = positionListLocator.locator('> li'); |
|
|
|
/** @type import('@playwright/test').Locator[] */ |
|
const positionLocators = await Promise.any([ |
|
// match only valid posted positions, not suggested positions |
|
positionLocator.first().waitFor(waitOptions).then(()=>positionLocator.all()), |
|
noResultsLocator.first().waitFor(waitOptions).then(()=>[]), |
|
]); |
|
|
|
if(positionLocators.length > 0){ |
|
// ensure all dynamically loaded positions are present |
|
const coords = await positionListLocator.boundingBox(); |
|
await page.mouse.move(coords.x + 10, coords.y + 10); |
|
for (let i=0;i<5;i++){ |
|
await page.mouse.wheel(0, 1000); |
|
await waitForDOMTreeChange(positionListLocator,2000); |
|
const newLocators = await positionLocator.all(); |
|
if (newLocators.length === positionLocators.length){ |
|
break; |
|
} |
|
positionLocators.push(...newLocators.slice(positionLocators.length)); |
|
} |
|
} |
|
|
|
return positionLocators; |
|
} |
|
|
|
/** |
|
* Waits for a DOM change in the locator. |
|
* @param {Locator} locator - The locator to wait for a DOM change. |
|
* @param {object} waitOptions - The options for waiting. |
|
* @returns {Promise<string>} - The locator it was passed, after a DOM change, or timeout. |
|
*/ |
|
async function waitForDOMTreeChange(locator, timeout=3000) { |
|
const maxTime = Date.now() + timeout; |
|
return locator.evaluate((elem,endTime) =>{ |
|
return new Promise(resolve=>{ |
|
const obs = new MutationObserver(async (mutations, observer) => { |
|
if(mutations.some(({type,addedNodes})=>type === 'childList')){ |
|
resolve(); |
|
} |
|
}); |
|
const remainingTime = Math.max(0,endTime - Date.now()); |
|
setTimeout(()=>{ obs.disconnect(); resolve(); }, remainingTime); |
|
obs.observe(elem, { childList: true, subtree: true }); |
|
}) |
|
}, maxTime) |
|
// this catches cases where the tree above or on the locator's element was destroyed |
|
.catch(e=>locator) |
|
} |
|
|
|
/** |
|
* Waits for a selector's innerText to match |
|
* Unaffected by DOM node tree changes. |
|
* @param {import('@playwright/test').Locator} locator - The Playwright locator object. |
|
* @param {string} match - The initial text to compare against. |
|
* @param {number} timeout - The timeout in milliseconds. |
|
* @returns {Promise<void>} - Resolves when the text matches. Else throws. |
|
*/ |
|
async function waitForTextMatch(locator, match, timeout = 3000) { |
|
const maxTime = timeout + Date.now(); |
|
let currentText; |
|
while (Date.now() < maxTime) { |
|
currentText = (await locator.innerText()).trim(); |
|
if (currentText === match) { |
|
return currentText; |
|
} |
|
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay to avoid busy-waiting |
|
} |
|
throw new Error(`Timeout waiting for text match. Expected:${match} Actual:${currentText}`); |
|
} |
|
|
|
|
|
/** |
|
* Ensures that the JSON file exists. |
|
* If it doesn't exist, it creates a new file with the default value. |
|
* @param {string} fullFilePath - The path to the JSON file. |
|
* @param {object} defaultVal - The default value to write to the file. |
|
* @returns {void} |
|
*/ |
|
function ensureJSONFileExists(fullFilePath,defaultVal={}) { |
|
if (!existsSync(fullFilePath)){ |
|
// ensure auth state file exists; newPage throws without it |
|
writeJsonFile(fullFilePath, defaultVal); |
|
} |
|
} |
|
|
|
|
|
/** |
|
* Reads a JSON file and returns its contents. |
|
* If the file doesn't exist or there's an error, it returns an empty string. |
|
* @param {string} fullFilePath - The path to the JSON file. |
|
* @returns {string} - The parsed string, else empty if the file doesn't exist. |
|
*/ |
|
function readJsonFile(fullFilePath) { |
|
// get os root path |
|
try { |
|
return JSON.parse(readFileSync(fullFilePath, 'utf-8')); |
|
} catch (error) { |
|
console.log('error reading json file', fullFilePath); |
|
console.error(error); |
|
return ''; |
|
} |
|
} |
|
|
|
|
|
/** |
|
* Writes a value to a file, as json. |
|
* If the file doesn't exist, it creates a new one. |
|
* If the file exists, it overwrites the contents. |
|
* @param {string} fullFilePath - The full absolute path to the JSON file. |
|
* @param {string} jsonData - The data to write to the file. |
|
* @returns {void} |
|
*/ |
|
function writeJsonFile(fullFilePath, jsonData) { |
|
try { |
|
const data = JSON.stringify(jsonData, null, 2); |
|
writeFileSync(fullFilePath, data, 'utf-8'); |
|
} catch (error) { |
|
console.error(`Error writing JSON file at ${fullFilePath}:`, error); |
|
} |
|
} |