// ==UserScript== // @name LinkedIn Job Search Usability Improvements // @namespace http://tampermonkey.net/ // @version 0.2.1 // @description Make the interface easier to use // @author Bryan Chan // @match http://www.linkedin.com/jobs/search/* // @license GNU GPLv3 // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @downloadURL none // ==/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() { const start = +new Date(); for(var job = jobsList.firstElementChild; job.nextSibling; job = job.nextSibling.nextSibling) { try { const data = getCardData(job); const jobDiv = job.firstElementChild; 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 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(".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(next.firstElementChild && isHidden(next.firstElementChild)) { next = next.nextSibling.nextSibling; } if(next.firstElementChild) { triggerClick(next.firstElementChild); } } else { goToFirst(); } } function goToPrevious() { const active = getActive(); if(active) { var prev = active.previousSibling.previousSibling; while(prev.firstElementChild && isHidden(prev.firstElementChild)) { prev = prev.previousSibling.previousSibling; } if(prev.firstElementChild) { 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) { // 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 console.log("Jobs List element", jobsList); observer.observe(jobsList, config); }());