Greasy Fork is available in English.

LinkedIn Job Search Usability Improvements

Make it easier to review and manage job search results, with faster keyboard shortcuts, read post tracking, and blacklists for companies and jobs

// ==UserScript==
// @name         LinkedIn Job Search Usability Improvements
// @namespace    http://tampermonkey.net/
// @version      0.2.11
// @description  Make it easier to review and manage job search results, with faster keyboard shortcuts, read post tracking, and blacklists for companies and jobs
// @author       Bryan Chan
// @match        https://www.linkedin.com/jobs/search/*
// @license      GNU GPLv3
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';

    /** Selectors for key elements */
    const JOBS_LIST_SELECTOR = "ul.jobs-search-results__list"
    const ACTIVE_JOB_SELECTOR = ".jobs-search-results-list__list-item--active"
    const JOB_CARD_COMPANY_NAME_SELECTOR = "a.job-card-container__company-name"
    const JOB_CARD_POST_TITLE_SELECTOR = ".job-card-list__title"
    const JOB_SEARCH_RESULTS_FEEDBACK_CLASS = "jobs-list-feedback"

    const DETAIL_POST_TITLE_SELECTOR = ".jobs-details-top-card__job-title"
    const DETAIL_COMPANY_SELECTOR = ".jobs-details-top-card__company-url"

    const NEXT_PAGE_SELECTOR = ".artdeco-pagination__indicator--number.active"
    const PREV_PAGE_SELECTOR = ".artdeco-pagination__indicator--number.active"

    function nextJobEl(jobCardEl) {
        return jobCardEl.nextElementSibling
    }

    function prevJobEl(jobCardEl) {
        return jobCardEl.previousElementSibling
    }

    function jobClickTarget(jobCardEl) {
        return jobCardEl.firstElementChild.firstElementChild
    }

    /** Check if a card is hidden */
    function isHidden (jobCardEl) {
        const node = jobCardEl.firstElementChild
        if(!node) return false;
        return node.classList.contains(JOB_SEARCH_RESULTS_FEEDBACK_CLASS) ||
            node.classList.contains("hidden");
    }



    console.log("Starting LinkedIn Job Search Usability Improvements");

    // Setup dictionaries to persist useful information across sessions
    class StoredDictionary {
        constructor(storageKey) {
            this.storageKey = storageKey;
            this.data = GM_getValue(storageKey) || {};
            console.log("Initial data read from", this.storageKey, this.data);
        }

        get(key) {
            return this.data[key];
        }

        set(key, value) {
            this.data[key] = value;
            GM_setValue(this.storageKey, this.data);
        }

        delete(key) {
            delete this.data[key];
            GM_setValue(this.storageKey, this.data);
        }

        getDictionary() {
            return this.data;
        }
    }

    const hiddenCompanies = new StoredDictionary("hidden_companies");
    const hiddenPosts = new StoredDictionary("hidden_posts");
    const readPosts = new StoredDictionary("read_posts");

    /** Install key handlers to allow for keyboard interactions */
    const KEY_HANDLER = {
        "e": handleMarkRead, // toggle marking the active post as read
        "j": goToNext, // open the next visible job post
        "k": goToPrevious, // open the previous visible job post
        "h": toggleHidden, // toggle showing the hidden posts
        "n": handleNextPage, // go to the next page
        "p": handlePrevPage, // go to the previous page
        "x": handleHidePost, // hide post forever,
        "X": handleShowPost, // show post again
        "y": handleHideCompany, // hide company forever
        "Y": handleShowCompany, // show company again
        "?": handlePrintDebug, // print debug information to the console
    }

    window.addEventListener("keydown", function(e) {
        const handler = KEY_HANDLER[e.key]
        if(handler) handler();
    });

    /** Event handler functions */
    const FEEDBACK_DELAY = 300;

    // Toggle whether to hide posts
    var showHidden = false;
    function toggleHidden() {
        showHidden = !showHidden;
        queueUpdate();
    }

    // Handle a request to hide a post forever
    function handleHidePost() {
        const activeJob = getActive();
        const data = getCardData(activeJob);

        // Show feedback
        activeJob.style.opacity = 0.6;
        const postTitle = getPostNode(activeJob);
        postTitle.style.textDecoration = "line-through";

        const detailPostTitle = document.querySelector(DETAIL_POST_TITLE_SELECTOR);
        detailPostTitle.style.textDecoration = "line-through";

        // Wait a little and then hide post
        setTimeout(() => {
            goToNext();
            detailPostTitle.style.textDecoration = "none";
            hiddenPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
            updateDisplay();
        }, FEEDBACK_DELAY);
    }

        // Handle a request to hide a post forever
    function handleShowPost() {
        const activeJob = getActive();
        const data = getCardData(activeJob);

        goToNext();
        hiddenPosts.delete(data.postUrl);
        updateDisplay();
    }

    // Handle request to hide all posts from a company, forever
    function handleHideCompany() {
        const activeJob = getActive();
        const data = getCardData(activeJob);

        // show feedback
        activeJob.style.opacity = 0.6;
        const company = getCompanyNode(activeJob);
        company.style.textDecoration = "line-through";

        const detailCompany = document.querySelector(DETAIL_COMPANY_SELECTOR);
        detailCompany.style.textDecoration = "line-through";

        // Wait a little and then hide company
        setTimeout(() => {
            // go to next post and hide the company
            goToNext();
            detailCompany.style.textDecoration = "none";
            hiddenCompanies.set(data.companyUrl, data.companyName);
            updateDisplay();
        }, FEEDBACK_DELAY);
    }

        // Handle request to hide all posts from a company, forever
    function handleShowCompany() {
        const activeJob = getActive();
        const data = getCardData(activeJob);

        activeJob.style.opacity = 1.0;
        const company = getCompanyNode(activeJob);
        company.style.textDecoration = "none";

        const detailCompany = document.querySelector(DETAIL_COMPANY_SELECTOR);
        detailCompany.style.textDecoration = "none";

        goToNext();
        hiddenCompanies.delete(data.companyUrl);
        updateDisplay();
    }


    const PAGE_DELAY = 300; // delay after loading new page to go to the first element
    function handleNextPage() {
        const activePage = document.querySelector(NEXT_PAGE_SELECTOR);
        if(!activePage) return;
        const nextPage = activePage.nextElementSibling.firstElementChild;
        triggerClick(nextPage);
    }

    function handlePrevPage() {
        const activePage = document.querySelector(PREV_PAGE_SELECTOR);
        if(!activePage) return;
        const prevPage = activePage.previousElementSibling.firstElementChild;
        triggerClick(prevPage);
    }


    // Handl request to mark a post as read (
    function handleMarkRead() {
        console.log('handleMarkRead')
        // @TODO implement this in a useful way
        const activeJob = getActive();
        console.log(activeJob)
        const data = getCardData(activeJob);
        console.log(data)
        const previouslyMarkedRead = !!readPosts.get(data.postUrl);

        goToNext();
        if(previouslyMarkedRead) {
            console.log('mark unread', data.postUrl)
            readPosts.delete(data.postUrl);
        } else {
            console.log('mark read', data.postUrl)
            readPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
        }
        updateDisplay();
    }

    // Handle requests to print debug information
    function handlePrintDebug() {

        const companies = hiddenCompanies.getDictionary();
        console.log("Hidden companies", Object.keys(companies).length);
        console.log(companies);

        const posts = hiddenPosts.getDictionary();
        console.log("Hidden posts", Object.keys(posts).length);
        console.log(posts);

        const read = readPosts.getDictionary();
        console.log("Read posts", Object.keys(read).length);
        console.log(read);
    }

    /** Functions to adjust jobs list display, based on which companies, posts are hidden and which posts are read */
    function getJobsList() {
        return document.querySelector(JOBS_LIST_SELECTOR);
    }
    var updateQueued = false;
    var updateTimer = null;
    function queueUpdate() {
        if(updateTimer) {
            clearTimeout(updateTimer);
        }
        updateTimer = setTimeout(function() {
            updateTimer = null;
            updateDisplay()
        }, 30);
    }
    function updateDisplay() {
        const start = +new Date();
        const jobsList = getJobsList();
        for(var job = jobsList.firstElementChild; job && job.nextSibling; job = nextJobEl(job)) {
            try {
                const data = getCardData(job);
                const jobDiv = job.firstElementChild;

                if(showHidden) {
                    jobDiv.classList.remove("hidden");
                    continue;
                }

                if(hiddenCompanies.get(data.companyUrl)) {
                    jobDiv.classList.add("hidden");
                } else if(hiddenPosts.get(data.postUrl)) {
                    jobDiv.classList.add("hidden");
                } else if(readPosts.get(data.postUrl)) {
                    jobDiv.classList.add("read");
                } else {
                    jobDiv.classList.remove("read");
                }

            } catch(e) {
            }
        }
        const elapsed = +new Date() - start;
        console.log("Updated display on jobs list in", elapsed, "ms");
    }

    function triggerMouseEvent (node, eventType) {
        var clickEvent = document.createEvent ('MouseEvents');
        clickEvent.initEvent (eventType, true, true);
        node.dispatchEvent (clickEvent);
    }

    /** Get active job card */
    function getActive() {
        const active = document.querySelector(ACTIVE_JOB_SELECTOR);
        return active ? active.parentNode.parentNode : undefined;
    }

    /** Select first card in the list */
    function goToFirst() {
        const jobsList = getJobsList();
        const firstPost = jobsList.firstElementChild;
        const clickableDiv = jobClickTarget(firstPost);
        triggerClick(clickableDiv);
    }

    function goToNext() {
        const active = getActive();
        if(active) {
            var next = nextJobEl(active)
            while(isHidden(next)) {
                next = nextJobEl(next);
            }
            if(next.firstElementChild) {
                triggerClick(jobClickTarget(next));
            } else { // no next job, try for the next page
                handleNextPage();
            }
        } else {
            goToFirst();
        }
    }

    function goToPrevious() {
        const active = getActive();
        if(active) {
            var prev = prevJobEl(active);
            while(isHidden(prev)) {
                prev = prevJobEl(prev);
            }
            if(prev.firstElementChild) {
                triggerClick(jobClickTarget(prev));
            } else { // no previous job, try to go to the previous page
                handlePrevPage();
            }
        } else {
            goToFirst();
        }
    }

    function triggerClick (node) {
        triggerMouseEvent (node, "mouseover");
        triggerMouseEvent (node, "mousedown");
        triggerMouseEvent (node, "mouseup");
        triggerMouseEvent (node, "click");
    }


    /** Extracts card data from a card */
    function getCompanyNode (node) {
        return node.querySelector(JOB_CARD_COMPANY_NAME_SELECTOR)
    }
    function getPostNode (node) {
        return node.querySelector(JOB_CARD_POST_TITLE_SELECTOR)
    }
    function getCardData (node) {
        var companyUrl, companyName, postUrl, postTitle;
        const company = getCompanyNode(node);
        if(company) {
            companyUrl = company.getAttribute("href").split('?')[0];
            companyName = company.text.trim(" ");
        }

        const post = getPostNode(node);
        if(post) {
            postUrl = post.getAttribute("href").split("/?")[0];
            postTitle = post.text.replace("Promoted","").trim(" \n");
        }
        return {
            companyUrl,
            companyName,
            postUrl,
            postTitle
        };
    }

    /** Add styles to handle hiding */
    GM_addStyle(`.${JOB_SEARCH_RESULTS_FEEDBACK_CLASS} { display: none }`);
    GM_addStyle(".hidden { display: none }");
    GM_addStyle(".read { opacity: 0.3 }");


    console.log("Adding mutation observer");

    // Options for the observer (which mutations to observe)
    const config = { attributes: true, childList: true, subtree: true };

    // Callback function to execute when mutations are observed
    const callback = function(mutationsList, observer) {
        // Use traditional 'for loops' for IE 11
        for(let mutation of mutationsList) {
            const target = mutation.target;
            if (mutation.type === 'childList') {
                queueUpdate();
            }
            else if (mutation.type === 'attributes') {
                //console.log('The ' + mutation.attributeName + ' attribute was modified.', target);
            }
        }
    };


    // Create an observer instance linked to the callback function
    const observer = new MutationObserver(callback);

    // Start observing the target node for configured mutations
    observer.observe(getJobsList(), config);
}());