LinkedIn Job Search Usability Improvements

Make the interface easier to use

Per 15-01-2020. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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);
}());