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

当前为 2020-01-18 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name LinkedIn Job Search Usability Improvements
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.6
  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. console.log("Starting LinkedIn Job Search Usability Improvements");
  18.  
  19. // Setup dictionaries to persist useful information across sessions
  20. class StoredDictionary {
  21. constructor(storageKey) {
  22. this.storageKey = storageKey;
  23. this.data = GM_getValue(storageKey) || {};
  24. console.log("Initial data read from", this.storageKey, this.data);
  25. }
  26.  
  27. get(key) {
  28. return this.data[key];
  29. }
  30.  
  31. set(key, value) {
  32. this.data[key] = value;
  33. GM_setValue(this.storageKey, this.data);
  34. console.log("Updated data", this.storageKey, this.data);
  35. }
  36.  
  37. getDictionary() {
  38. return this.data;
  39. }
  40. }
  41.  
  42. const hiddenCompanies = new StoredDictionary("hidden_companies");
  43. const hiddenPosts = new StoredDictionary("hidden_posts");
  44. const readPosts = new StoredDictionary("read_posts");
  45.  
  46. /** Install key handlers to allow for keyboard interactions */
  47. const KEY_HANDLER = {
  48. "e": handleMarkRead, // mark the active post as read
  49. "j": goToNext, // open the next visible job post
  50. "k": goToPrevious, // open the previous visible job post
  51. "h": toggleHidden, // toggle showing the hidden posts
  52. "n": handleNextPage, // go to the next page
  53. "p": handlePrevPage, // go to the previous page
  54. "x": handleHidePost, // hide post forever
  55. "y": handleHideCompany, // hide company forever
  56. "?": handlePrintDebug, // print debug information to the console
  57. }
  58.  
  59. window.addEventListener("keydown", function(e) {
  60. const handler = KEY_HANDLER[e.key]
  61. if(handler) handler();
  62. });
  63.  
  64. /** Event handler functions */
  65. const FEEDBACK_DELAY = 300;
  66.  
  67. // Toggle whether to hide posts
  68. var showHidden = false;
  69. function toggleHidden() {
  70. showHidden = !showHidden;
  71. queueUpdate();
  72. }
  73.  
  74. // Handle a request to hide a post forever
  75. function handleHidePost() {
  76. const activeJob = getActive();
  77. const data = getCardData(activeJob);
  78.  
  79. // Show feedback
  80. activeJob.style.opacity = 0.6;
  81. const postTitle = getPostNode(activeJob);
  82. postTitle.style.textDecoration = "line-through";
  83.  
  84. const detailPostTitle = document.querySelector(".jobs-details-top-card__job-title");
  85. detailPostTitle.style.textDecoration = "line-through";
  86.  
  87. // Wait a little and then hide post
  88. setTimeout(() => {
  89. goToNext();
  90. detailPostTitle.style.textDecoration = "none";
  91. hiddenPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
  92. updateDisplay();
  93. }, FEEDBACK_DELAY);
  94. }
  95.  
  96. // Handle request to hide all posts from a company, forever
  97. function handleHideCompany() {
  98. const activeJob = getActive();
  99. const data = getCardData(activeJob);
  100.  
  101. // show feedback
  102. activeJob.style.opacity = 0.6;
  103. const company = getCompanyNode(activeJob);
  104. company.style.textDecoration = "line-through";
  105.  
  106. const detailCompany = document.querySelector(".jobs-details-top-card__company-url");
  107. detailCompany.style.textDecoration = "line-through";
  108.  
  109. // Wait a little and then hide company
  110. setTimeout(() => {
  111. // go to next post and hide the company
  112. goToNext();
  113. detailCompany.style.textDecoration = "none";
  114. hiddenCompanies.set(data.companyUrl, data.companyName);
  115. updateDisplay();
  116. }, FEEDBACK_DELAY);
  117. }
  118.  
  119. function handleNextPage() {
  120. const activePage = document.querySelector(".artdeco-pagination__indicator--number.active");
  121. const nextPage = activePage.nextElementSibling.firstElementChild;
  122. console.log(activePage);
  123. console.log(nextPage);
  124. triggerClick(nextPage);
  125. }
  126.  
  127. function handlePrevPage() {
  128. const activePage = document.querySelector(".artdeco-pagination__indicator--number.active");
  129. const prevPage = activePage.previousElementSibling.firstElementChild;
  130. triggerClick(prevPage);
  131. }
  132.  
  133.  
  134. // Handl request to mark a post as read (
  135. function handleMarkRead() {
  136. // @TODO implement this in a useful way
  137. const activeJob = getActive();
  138. const data = getCardData(activeJob);
  139. goToNext();
  140. readPosts.set(data.postUrl, `${data.companyName}: ${data.postTitle}`);
  141. updateDisplay();
  142. }
  143.  
  144. // Handle requests to print debug information
  145. function handlePrintDebug() {
  146.  
  147. console.log("Hidden companies");
  148. console.log(hiddenCompanies.getDictionary());
  149.  
  150. console.log("Hidden posts");
  151. console.log(hiddenPosts.getDictionary());
  152.  
  153. console.log("Read posts");
  154. console.log(readPosts.getDictionary());
  155. }
  156.  
  157. /** Functions to adjust jobs list display, based on which companies, posts are hidden and which posts are read */
  158. const jobsList = document.querySelector("ul.jobs-search-results__list");
  159. var updateQueued = false;
  160. var updateTimer = null;
  161. function queueUpdate() {
  162. if(updateTimer) {
  163. clearTimeout(updateTimer);
  164. }
  165. updateTimer = setTimeout(function() {
  166. updateTimer = null;
  167. updateDisplay()
  168. }, 30);
  169. }
  170. function updateDisplay() {
  171. const start = +new Date();
  172. for(var job = jobsList.firstElementChild; job.nextSibling; job = job.nextSibling.nextSibling) {
  173. try {
  174. const data = getCardData(job);
  175. const jobDiv = job.firstElementChild;
  176.  
  177. if(showHidden) {
  178. jobDiv.classList.remove("hidden");
  179. continue;
  180. }
  181.  
  182. if(hiddenCompanies.get(data.companyUrl)) {
  183. jobDiv.classList.add("hidden");
  184. } else if(hiddenPosts.get(data.postUrl)) {
  185. jobDiv.classList.add("hidden");
  186. } else if(readPosts.get(data.postUrl)) {
  187. jobDiv.classList.add("read");
  188. }
  189.  
  190. } catch(e) {
  191. }
  192. }
  193. const elapsed = +new Date() - start;
  194. console.log("Updated display on jobs list in", elapsed, "ms");
  195. }
  196.  
  197. function triggerMouseEvent (node, eventType) {
  198. var clickEvent = document.createEvent ('MouseEvents');
  199. clickEvent.initEvent (eventType, true, true);
  200. node.dispatchEvent (clickEvent);
  201. }
  202.  
  203. /** Get active job card */
  204. function getActive() {
  205. const active = document.querySelector(".job-card-search--is-active");
  206. return active ? active.parentNode : undefined;
  207. }
  208.  
  209. /** Select first card in the list */
  210. function goToFirst() {
  211. const firstPost = jobsList.firstElementChild;
  212. const clickableDiv = firstPost.firstElementChild;
  213. triggerClick(clickableDiv);
  214. }
  215.  
  216. function goToNext() {
  217. const active = getActive();
  218. if(active) {
  219. var next = active.nextSibling.nextSibling;
  220. while(next.firstElementChild && isHidden(next.firstElementChild)) {
  221. next = next.nextSibling.nextSibling;
  222. }
  223. if(next.firstElementChild) {
  224. triggerClick(next.firstElementChild);
  225. } else { // no next job, try for the next page
  226. handleNextPage();
  227. }
  228. } else {
  229. goToFirst();
  230. }
  231. }
  232.  
  233. function goToPrevious() {
  234. const active = getActive();
  235. if(active) {
  236. var prev = active.previousSibling.previousSibling;
  237. while(prev.firstElementChild && isHidden(prev.firstElementChild)) {
  238. prev = prev.previousSibling.previousSibling;
  239. }
  240. if(prev.firstElementChild) {
  241. triggerClick(prev.firstElementChild);
  242. } else { // no previous job, try to go to the previous page
  243. handlePrevPage();
  244. }
  245. } else {
  246. goToFirst();
  247. }
  248. }
  249.  
  250. function triggerClick (node) {
  251. triggerMouseEvent (node, "mouseover");
  252. triggerMouseEvent (node, "mousedown");
  253. triggerMouseEvent (node, "mouseup");
  254. triggerMouseEvent (node, "click");
  255. }
  256.  
  257. /** Check if a card is hidden */
  258. function isHidden (node) {
  259. return node.classList.contains("jobs-search-results-feedback") ||
  260. node.classList.contains("hidden");
  261. }
  262.  
  263. /** Extracts card data from a card */
  264. function getCompanyNode (node) {
  265. return node.querySelector("a.job-card-search__company-name-link")
  266. }
  267. function getPostNode (node) {
  268. return node.querySelector(".job-card-search__title a.job-card-search__link-wrapper")
  269. }
  270. function getCardData (node) {
  271. var companyUrl, companyName, postUrl, postTitle;
  272. const company = getCompanyNode(node);
  273. if(company) {
  274. companyUrl = company.getAttribute("href");
  275. companyName = company.text.trim(" ");
  276. }
  277.  
  278. const post = getPostNode(node);
  279. if(post) {
  280. postUrl = post.getAttribute("href").split("/?")[0];
  281. postTitle = post.text.replace("Promoted","").trim(" \n");
  282. }
  283. return {
  284. companyUrl,
  285. companyName,
  286. postUrl,
  287. postTitle
  288. };
  289. }
  290.  
  291. /** Add styles to handle hiding */
  292. GM_addStyle(".jobs-search-results-feedback { display: none }");
  293. GM_addStyle(".hidden { display: none }");
  294. GM_addStyle(".read { opacity: 0.3 }");
  295.  
  296.  
  297. console.log("Adding mutation observer");
  298.  
  299. // Options for the observer (which mutations to observe)
  300. const config = { attributes: true, childList: true, subtree: true };
  301.  
  302. // Callback function to execute when mutations are observed
  303. const callback = function(mutationsList, observer) {
  304. // Use traditional 'for loops' for IE 11
  305. for(let mutation of mutationsList) {
  306. const target = mutation.target;
  307. if (mutation.type === 'childList') {
  308. queueUpdate();
  309. }
  310. else if (mutation.type === 'attributes') {
  311. //console.log('The ' + mutation.attributeName + ' attribute was modified.', target);
  312. }
  313. }
  314. };
  315.  
  316.  
  317. // Create an observer instance linked to the callback function
  318. const observer = new MutationObserver(callback);
  319.  
  320. // Start observing the target node for configured mutations
  321. console.log("Jobs List element", jobsList);
  322. observer.observe(jobsList, config);
  323. }());
  324.