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

  1. // ==UserScript==
  2. // @name LinkedIn Job Search Usability Improvements
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.11
  5. // @description Make it easier to review and manage job search results, with faster keyboard shortcuts, read post tracking, and blacklists for companies and jobs
  6. // @author Bryan Chan
  7. // @match https://www.linkedin.com/jobs/search/*
  8. // @license GNU GPLv3
  9. // @grant GM_addStyle
  10. // @grant GM_setValue
  11. // @grant GM_getValue
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. /** Selectors for key elements */
  18. const JOBS_LIST_SELECTOR = "ul.jobs-search-results__list"
  19. const ACTIVE_JOB_SELECTOR = ".jobs-search-results-list__list-item--active"
  20. const JOB_CARD_COMPANY_NAME_SELECTOR = "a.job-card-container__company-name"
  21. const JOB_CARD_POST_TITLE_SELECTOR = ".job-card-list__title"
  22. const JOB_SEARCH_RESULTS_FEEDBACK_CLASS = "jobs-list-feedback"
  23.  
  24. const DETAIL_POST_TITLE_SELECTOR = ".jobs-details-top-card__job-title"
  25. const DETAIL_COMPANY_SELECTOR = ".jobs-details-top-card__company-url"
  26.  
  27. const NEXT_PAGE_SELECTOR = ".artdeco-pagination__indicator--number.active"
  28. const PREV_PAGE_SELECTOR = ".artdeco-pagination__indicator--number.active"
  29.  
  30. function nextJobEl(jobCardEl) {
  31. return jobCardEl.nextElementSibling
  32. }
  33.  
  34. function prevJobEl(jobCardEl) {
  35. return jobCardEl.previousElementSibling
  36. }
  37.  
  38. function jobClickTarget(jobCardEl) {
  39. return jobCardEl.firstElementChild.firstElementChild
  40. }
  41.  
  42. /** Check if a card is hidden */
  43. function isHidden (jobCardEl) {
  44. const node = jobCardEl.firstElementChild
  45. if(!node) return false;
  46. return node.classList.contains(JOB_SEARCH_RESULTS_FEEDBACK_CLASS) ||
  47. node.classList.contains("hidden");
  48. }
  49.  
  50.  
  51.  
  52. console.log("Starting LinkedIn Job Search Usability Improvements");
  53.  
  54. // Setup dictionaries to persist useful information across sessions
  55. class StoredDictionary {
  56. constructor(storageKey) {
  57. this.storageKey = storageKey;
  58. this.data = GM_getValue(storageKey) || {};
  59. console.log("Initial data read from", this.storageKey, this.data);
  60. }
  61.  
  62. get(key) {
  63. return this.data[key];
  64. }
  65.  
  66. set(key, value) {
  67. this.data[key] = value;
  68. GM_setValue(this.storageKey, this.data);
  69. }
  70.  
  71. delete(key) {
  72. delete this.data[key];
  73. GM_setValue(this.storageKey, this.data);
  74. }
  75.  
  76. getDictionary() {
  77. return this.data;
  78. }
  79. }
  80.  
  81. const hiddenCompanies = new StoredDictionary("hidden_companies");
  82. const hiddenPosts = new StoredDictionary("hidden_posts");
  83. const readPosts = new StoredDictionary("read_posts");
  84.  
  85. /** Install key handlers to allow for keyboard interactions */
  86. const KEY_HANDLER = {
  87. "e": handleMarkRead, // toggle marking the active post as read
  88. "j": goToNext, // open the next visible job post
  89. "k": goToPrevious, // open the previous visible job post
  90. "h": toggleHidden, // toggle showing the hidden posts
  91. "n": handleNextPage, // go to the next page
  92. "p": handlePrevPage, // go to the previous page
  93. "x": handleHidePost, // hide post forever,
  94. "X": handleShowPost, // show post again
  95. "y": handleHideCompany, // hide company forever
  96. "Y": handleShowCompany, // show company again
  97. "?": handlePrintDebug, // print debug information to the console
  98. }
  99.  
  100. window.addEventListener("keydown", function(e) {
  101. const handler = KEY_HANDLER[e.key]
  102. if(handler) handler();
  103. });
  104.  
  105. /** Event handler functions */
  106. const FEEDBACK_DELAY = 300;
  107.  
  108. // Toggle whether to hide posts
  109. var showHidden = false;
  110. function toggleHidden() {
  111. showHidden = !showHidden;
  112. queueUpdate();
  113. }
  114.  
  115. // Handle a request to hide a post forever
  116. function handleHidePost() {
  117. const activeJob = getActive();
  118. const data = getCardData(activeJob);
  119.  
  120. // Show feedback
  121. activeJob.style.opacity = 0.6;
  122. const postTitle = getPostNode(activeJob);
  123. postTitle.style.textDecoration = "line-through";
  124.  
  125. const detailPostTitle = document.querySelector(DETAIL_POST_TITLE_SELECTOR);
  126. detailPostTitle.style.textDecoration = "line-through";
  127.  
  128. // Wait a little and then hide post
  129. setTimeout(() => {
  130. goToNext();
  131. detailPostTitle.style.textDecoration = "none";
  132. hiddenPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
  133. updateDisplay();
  134. }, FEEDBACK_DELAY);
  135. }
  136.  
  137. // Handle a request to hide a post forever
  138. function handleShowPost() {
  139. const activeJob = getActive();
  140. const data = getCardData(activeJob);
  141.  
  142. goToNext();
  143. hiddenPosts.delete(data.postUrl);
  144. updateDisplay();
  145. }
  146.  
  147. // Handle request to hide all posts from a company, forever
  148. function handleHideCompany() {
  149. const activeJob = getActive();
  150. const data = getCardData(activeJob);
  151.  
  152. // show feedback
  153. activeJob.style.opacity = 0.6;
  154. const company = getCompanyNode(activeJob);
  155. company.style.textDecoration = "line-through";
  156.  
  157. const detailCompany = document.querySelector(DETAIL_COMPANY_SELECTOR);
  158. detailCompany.style.textDecoration = "line-through";
  159.  
  160. // Wait a little and then hide company
  161. setTimeout(() => {
  162. // go to next post and hide the company
  163. goToNext();
  164. detailCompany.style.textDecoration = "none";
  165. hiddenCompanies.set(data.companyUrl, data.companyName);
  166. updateDisplay();
  167. }, FEEDBACK_DELAY);
  168. }
  169.  
  170. // Handle request to hide all posts from a company, forever
  171. function handleShowCompany() {
  172. const activeJob = getActive();
  173. const data = getCardData(activeJob);
  174.  
  175. activeJob.style.opacity = 1.0;
  176. const company = getCompanyNode(activeJob);
  177. company.style.textDecoration = "none";
  178.  
  179. const detailCompany = document.querySelector(DETAIL_COMPANY_SELECTOR);
  180. detailCompany.style.textDecoration = "none";
  181.  
  182. goToNext();
  183. hiddenCompanies.delete(data.companyUrl);
  184. updateDisplay();
  185. }
  186.  
  187.  
  188. const PAGE_DELAY = 300; // delay after loading new page to go to the first element
  189. function handleNextPage() {
  190. const activePage = document.querySelector(NEXT_PAGE_SELECTOR);
  191. if(!activePage) return;
  192. const nextPage = activePage.nextElementSibling.firstElementChild;
  193. triggerClick(nextPage);
  194. }
  195.  
  196. function handlePrevPage() {
  197. const activePage = document.querySelector(PREV_PAGE_SELECTOR);
  198. if(!activePage) return;
  199. const prevPage = activePage.previousElementSibling.firstElementChild;
  200. triggerClick(prevPage);
  201. }
  202.  
  203.  
  204. // Handl request to mark a post as read (
  205. function handleMarkRead() {
  206. console.log('handleMarkRead')
  207. // @TODO implement this in a useful way
  208. const activeJob = getActive();
  209. console.log(activeJob)
  210. const data = getCardData(activeJob);
  211. console.log(data)
  212. const previouslyMarkedRead = !!readPosts.get(data.postUrl);
  213.  
  214. goToNext();
  215. if(previouslyMarkedRead) {
  216. console.log('mark unread', data.postUrl)
  217. readPosts.delete(data.postUrl);
  218. } else {
  219. console.log('mark read', data.postUrl)
  220. readPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
  221. }
  222. updateDisplay();
  223. }
  224.  
  225. // Handle requests to print debug information
  226. function handlePrintDebug() {
  227.  
  228. const companies = hiddenCompanies.getDictionary();
  229. console.log("Hidden companies", Object.keys(companies).length);
  230. console.log(companies);
  231.  
  232. const posts = hiddenPosts.getDictionary();
  233. console.log("Hidden posts", Object.keys(posts).length);
  234. console.log(posts);
  235.  
  236. const read = readPosts.getDictionary();
  237. console.log("Read posts", Object.keys(read).length);
  238. console.log(read);
  239. }
  240.  
  241. /** Functions to adjust jobs list display, based on which companies, posts are hidden and which posts are read */
  242. function getJobsList() {
  243. return document.querySelector(JOBS_LIST_SELECTOR);
  244. }
  245. var updateQueued = false;
  246. var updateTimer = null;
  247. function queueUpdate() {
  248. if(updateTimer) {
  249. clearTimeout(updateTimer);
  250. }
  251. updateTimer = setTimeout(function() {
  252. updateTimer = null;
  253. updateDisplay()
  254. }, 30);
  255. }
  256. function updateDisplay() {
  257. const start = +new Date();
  258. const jobsList = getJobsList();
  259. for(var job = jobsList.firstElementChild; job && job.nextSibling; job = nextJobEl(job)) {
  260. try {
  261. const data = getCardData(job);
  262. const jobDiv = job.firstElementChild;
  263.  
  264. if(showHidden) {
  265. jobDiv.classList.remove("hidden");
  266. continue;
  267. }
  268.  
  269. if(hiddenCompanies.get(data.companyUrl)) {
  270. jobDiv.classList.add("hidden");
  271. } else if(hiddenPosts.get(data.postUrl)) {
  272. jobDiv.classList.add("hidden");
  273. } else if(readPosts.get(data.postUrl)) {
  274. jobDiv.classList.add("read");
  275. } else {
  276. jobDiv.classList.remove("read");
  277. }
  278.  
  279. } catch(e) {
  280. }
  281. }
  282. const elapsed = +new Date() - start;
  283. console.log("Updated display on jobs list in", elapsed, "ms");
  284. }
  285.  
  286. function triggerMouseEvent (node, eventType) {
  287. var clickEvent = document.createEvent ('MouseEvents');
  288. clickEvent.initEvent (eventType, true, true);
  289. node.dispatchEvent (clickEvent);
  290. }
  291.  
  292. /** Get active job card */
  293. function getActive() {
  294. const active = document.querySelector(ACTIVE_JOB_SELECTOR);
  295. return active ? active.parentNode.parentNode : undefined;
  296. }
  297.  
  298. /** Select first card in the list */
  299. function goToFirst() {
  300. const jobsList = getJobsList();
  301. const firstPost = jobsList.firstElementChild;
  302. const clickableDiv = jobClickTarget(firstPost);
  303. triggerClick(clickableDiv);
  304. }
  305.  
  306. function goToNext() {
  307. const active = getActive();
  308. if(active) {
  309. var next = nextJobEl(active)
  310. while(isHidden(next)) {
  311. next = nextJobEl(next);
  312. }
  313. if(next.firstElementChild) {
  314. triggerClick(jobClickTarget(next));
  315. } else { // no next job, try for the next page
  316. handleNextPage();
  317. }
  318. } else {
  319. goToFirst();
  320. }
  321. }
  322.  
  323. function goToPrevious() {
  324. const active = getActive();
  325. if(active) {
  326. var prev = prevJobEl(active);
  327. while(isHidden(prev)) {
  328. prev = prevJobEl(prev);
  329. }
  330. if(prev.firstElementChild) {
  331. triggerClick(jobClickTarget(prev));
  332. } else { // no previous job, try to go to the previous page
  333. handlePrevPage();
  334. }
  335. } else {
  336. goToFirst();
  337. }
  338. }
  339.  
  340. function triggerClick (node) {
  341. triggerMouseEvent (node, "mouseover");
  342. triggerMouseEvent (node, "mousedown");
  343. triggerMouseEvent (node, "mouseup");
  344. triggerMouseEvent (node, "click");
  345. }
  346.  
  347.  
  348. /** Extracts card data from a card */
  349. function getCompanyNode (node) {
  350. return node.querySelector(JOB_CARD_COMPANY_NAME_SELECTOR)
  351. }
  352. function getPostNode (node) {
  353. return node.querySelector(JOB_CARD_POST_TITLE_SELECTOR)
  354. }
  355. function getCardData (node) {
  356. var companyUrl, companyName, postUrl, postTitle;
  357. const company = getCompanyNode(node);
  358. if(company) {
  359. companyUrl = company.getAttribute("href").split('?')[0];
  360. companyName = company.text.trim(" ");
  361. }
  362.  
  363. const post = getPostNode(node);
  364. if(post) {
  365. postUrl = post.getAttribute("href").split("/?")[0];
  366. postTitle = post.text.replace("Promoted","").trim(" \n");
  367. }
  368. return {
  369. companyUrl,
  370. companyName,
  371. postUrl,
  372. postTitle
  373. };
  374. }
  375.  
  376. /** Add styles to handle hiding */
  377. GM_addStyle(`.${JOB_SEARCH_RESULTS_FEEDBACK_CLASS} { display: none }`);
  378. GM_addStyle(".hidden { display: none }");
  379. GM_addStyle(".read { opacity: 0.3 }");
  380.  
  381.  
  382. console.log("Adding mutation observer");
  383.  
  384. // Options for the observer (which mutations to observe)
  385. const config = { attributes: true, childList: true, subtree: true };
  386.  
  387. // Callback function to execute when mutations are observed
  388. const callback = function(mutationsList, observer) {
  389. // Use traditional 'for loops' for IE 11
  390. for(let mutation of mutationsList) {
  391. const target = mutation.target;
  392. if (mutation.type === 'childList') {
  393. queueUpdate();
  394. }
  395. else if (mutation.type === 'attributes') {
  396. //console.log('The ' + mutation.attributeName + ' attribute was modified.', target);
  397. }
  398. }
  399. };
  400.  
  401.  
  402. // Create an observer instance linked to the callback function
  403. const observer = new MutationObserver(callback);
  404.  
  405. // Start observing the target node for configured mutations
  406. observer.observe(getJobsList(), config);
  407. }());
  408.