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-02-17 提交的版本,查看 最新版本

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