Skip to content

Instantly share code, notes, and snippets.

@a-laughlin
Last active July 2, 2026 18:13
Show Gist options
  • Select an option

  • Save a-laughlin/4e57ded2255b44240391bf3ca12a9d0d to your computer and use it in GitHub Desktop.

Select an option

Save a-laughlin/4e57ded2255b44240391bf3ca12a9d0d to your computer and use it in GitHub Desktop.
Send Fresh LinkedIn Job Postings to Telegram 24/7

LinkedIn Jobs Notifier

This script sends fresh LinkedIn job postings to Telegram 24/7 so you can apply quickly.

image

Setup

  1. Create a folder for this project. It should initially contain two files, and the script will generate two more:

    • linkedin-notifier.mjs (copy from this gist)
    • .env.local (copy .env.local.example from this gist)
    • auth_state.json generated: authentication cookies so the script doesn't need to log in each time
    • last_run_stamp.json generated: timestamp so the script can show only new postings
  2. Open your your .env.local file and add all the values

  3. Assuming NodeJs >= 20.10.0 is installed, run the following commands in your terminal:

    cd /path/to/the/project/folder/you/chose
    npm init -y
    npm install playwright grammy
    npx playwright install

Usage

In your terminal, run:

node --env-file='.env.local' linkedin-notifier.mjs

# and stop it with `ctrl+c`
# Copy this file to .env.local
# It will contain your private data necessary to run the bot.
# Do not share it with anyone.
# How often should your bot scan and message you about fresh job postings?
# Only jobs appearing since the last scan will be sent (or 24 hours,
# whichever is less).
CHECK_FREQUENCY_MINUTES=30
# The email you use to log into LinkedIn.
# The script needs to log in to get the same personalized results that you see.
LINKEDIN_EMAIL='__here__'
# password for that email
LINKEDIN_PASS='__here__'
# get by visiting linkedin.com and customizing your search for jobs.
# Once you have the parameters you want (e.g., location, salary, etc.),
# copy the address from your browser's address bar.
# example: 'https://www.linkedin.com/jobs/search?...'
JOB_SEARCH_LINK="__here__"
# get by visiting <https://t.me/userinfobot>
# and using the /start command
MY_TELEGRAM_USER_ID='__here__'
# get and create a bot by visiting <https://t.me/BotFather>
# and using the /newbot command
TELEGRAM_API_KEY='__here__'
# **Note**: Privacy settings prevent your bot from contacting you first.
# Once you create a bot to get your api key, sign in to telegram as
# yourself and manually send a message to your bot so it can contact you.
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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment