// ==UserScript==
// @name LinkedIn Job Search Usability Improvements
// @namespace http://tampermonkey.net/
// @version 0.2.3
// @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 http://www.linkedin.com/jobs/search/*
// @license GNU GPLv3
// @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, // mark 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
"x": handleHidePost, // hide post forever
"y": handleHideCompany, // hide company forever
"?": 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(".jobs-details-top-card__job-title");
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 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(".jobs-details-top-card__company-url");
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);
}
// 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(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");
}
} 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) {
var companyUrl, companyName, postUrl, postTitle;
const company = getCompanyNode(node);
if(company) {
companyUrl = company.getAttribute("href");
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
};
}
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);
}());