LinkedIn Job Search Usability Improvements

Make the interface easier to use

As of 2020-01-15. See the latest version.

// ==UserScript==
// @name         LinkedIn Job Search Usability Improvements
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Make the interface easier to use
// @author       Bryan Chan
// @match        http://www.linkedin.com/jobs/search/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';

    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);
            console.log("Updated data", 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,
        "j": goToNext,
        "k": goToPrevious,
        "x": handleHidePost,
        "y": handleHideCompany,
        "?": handlePrintDebug,
    }

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

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

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

        const postTitle = getPostNode(activeJob);
        postTitle.style.textDecoration = "line-through";

        setTimeout(() => {
            goToNext();
            hiddenPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
            updateDisplay();
        }, FEEDBACK_DELAY);
    }

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

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

        setTimeout(() => {
            // go to next post and hide the company
            goToNext();
            hiddenCompanies.set(data.companyUrl, data.companyName);
            updateDisplay();
        }, FEEDBACK_DELAY);
    }

    // Handl request to mark a post as read (
    function handleMarkRead() {
        // @TODO implement this in a useful way
        const activeJob = getActive();
        const data = getCardData(activeJob);
        goToNext();
        readPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
        updateDisplay();
    }

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

        console.log("Hidden companies");
        console.log(hiddenCompanies.getDictionary());

        console.log("Hidden posts");
        console.log(hiddenPosts.getDictionary());

        console.log("Read posts");
        console.log(readPosts.getDictionary());
    }

    /** Functions to adjust jobs list display, based on which companies, posts are hidden and which posts are read */
    const jobsList = document.querySelector("ul.jobs-search-results__list");
    var updateQueued = false;
    var updateTimer = null;
    function queueUpdate() {
        if(updateTimer) {
            clearTimeout(updateTimer);
        }
        updateTimer = setTimeout(function() {
            updateTimer = null;
            updateDisplay()
        }, 30);
    }
    function updateDisplay() {
        console.log("Updating display on jobs list");
        const start = +new Date();
        for(var job = jobsList.firstElementChild; job.nextSibling; job = job.nextSibling.nextSibling) {
            try {
                const data = getCardData(job);
                const jobDiv = job.firstElementChild;
                //console.log("Updating display on", data);
                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");
                }

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

    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(".job-card-search--is-active");
        return active ? active.parentNode : undefined;
    }

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

    function goToNext() {
        const active = getActive();
        if(active) {
            var next = active.nextSibling.nextSibling;
            while(isHidden(next.firstElementChild)) {
                next = next.nextSibling.nextSibling;
            }
            triggerClick(next.firstElementChild);
        } else {
            goToFirst();
        }
    }

    function goToPrevious() {
        const active = getActive();
        if(active) {
            var prev = active.previousSibling.previousSibling;
            while(isHidden(prev.firstElementChild)) {
                prev = prev.previousSibling.previousSibling;
            }
            triggerClick(prev.firstElementChild);
        } else {
            goToFirst();
        }
    }

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

    /** Check if a card is hidden */
    function isHidden (node) {
        return node.classList.contains("jobs-search-results-feedback") ||
            node.classList.contains("hidden");
    }

    /** Extracts card data from a card */
    function getCompanyNode (node) {
        return node.querySelector("a.job-card-search__company-name-link")
    }
    function getPostNode (node) {
        return node.querySelector(".job-card-search__title a.job-card-search__link-wrapper")
    }
    function getCardData (node) {
        const company = getCompanyNode(node);
        const companyUrl = company.getAttribute("href");
        const companyName = company.text.trim(" ");
        const post = getPostNode(node);
        const postUrl = post.getAttribute("href").split("/?")[0];
        const postTitle = post.text.replace("Promoted","").trim(" \n");
        return {
            companyUrl,
            companyName,
            postUrl,
            postTitle
        };
    }

    GM_addStyle(".jobs-search-results-feedback { 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) {
        console.log("Mutation!");
        // Use traditional 'for loops' for IE 11
        for(let mutation of mutationsList) {
            const target = mutation.target;
            if (mutation.type === 'childList') {
                console.log('Children were modified on', target);
                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
    console.log("Jobs List element", jobsList);
    observer.observe(jobsList, config);
}());