LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

当前为 2023-08-02 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name LinkedIn Tool
  3. // @namespace dalgoda@gmail.com
  4. // @match https://www.linkedin.com/*
  5. // @version 1.0.2
  6. // @author Mike Castle
  7. // @description Minor enhancements to LinkedIn. Mostly just hotkeys.
  8. // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt
  9. // @supportURL https://github.com/nexushoratio/userscripts/blob/main/linkedin-tool.md
  10. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
  11. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
  12. // ==/UserScript==
  13.  
  14. /* global VM */
  15.  
  16. (function () {
  17. 'use strict';
  18.  
  19. console.debug('Parsing successful.');
  20.  
  21. // I'm lazy. The version of emacs I'm using does not support
  22. // #private variables out of the box, so using underscores until I
  23. // get a working configuration.
  24. class Page {
  25. // The immediate following can be set if derived classes
  26.  
  27. // What pathname part of the URL this page should handle. The
  28. // special case of null is used by the Pages class to represent
  29. // global keys.
  30. _pathname;
  31.  
  32. // CSS selector for capturing clicks on this page. If overridden,
  33. // then the class should also provide a _clickHandler() method.
  34. _click_handler_selector = null;
  35.  
  36. // List of keystrokes to register automatically. They are objects
  37. // with keys of `seq`, `desc`, and `func`. The `seq` is used to
  38. // define they keystroke sequence to trigger the function. The
  39. // `desc` is used to create the help screen. The `func` is a
  40. // function, usually in the form of `this.methodName`. The
  41. // function is bound to `this` before registering it with
  42. // VM.shortcut.
  43. _auto_keys = [];
  44.  
  45. // Private members.
  46.  
  47. _keyboard = new VM.shortcut.KeyboardService();
  48.  
  49. // Tracks which HTMLElement holds the `onclick` function.
  50. _click_handler_element = null;
  51.  
  52. // Magic for VM.shortcut. This disables keys when focus is on an
  53. // input type field.
  54. static _navOption = {
  55. caseSensitive: true,
  56. condition: '!inputFocus',
  57. };
  58.  
  59. constructor() {
  60. this._boundClickHandler = this._clickHandler.bind(this);
  61. }
  62.  
  63. start() {
  64. for (const {seq, func} of this._auto_keys) {
  65. this._addKey(seq, func.bind(this));
  66. }
  67. }
  68.  
  69. get pathname() {
  70. return this._pathname;
  71. }
  72.  
  73. get keyboard() {
  74. return this._keyboard;
  75. }
  76.  
  77. activate() {
  78. this._keyboard.enable();
  79. this._enableClickHandler();
  80. }
  81.  
  82. deactivate() {
  83. this._keyboard.disable();
  84. this._disableClickHandler();
  85. }
  86.  
  87. get helpHeader() {
  88. return this.constructor.name;
  89. }
  90.  
  91. get helpContent() {
  92. return this._auto_keys;
  93. }
  94.  
  95. _addKey(seq, func) {
  96. this._keyboard.register(seq, func, Page._navOption);
  97. }
  98.  
  99. _enableClickHandler() {
  100. if (this._click_handler_selector) {
  101. // Page is dynamically building, so keep watching it until the
  102. // element shows up.
  103. VM.observe(document.body, () => {
  104. const element = document.querySelector(this._click_handler_selector);
  105. if (element) {
  106. this._click_handler_element = element;
  107. this._click_handler_element.addEventListener('click', this._boundClickHandler);
  108.  
  109. return true;
  110. }
  111. });
  112. }
  113. }
  114.  
  115. _disableClickHandler() {
  116. if (this._click_handler_element) {
  117. this._click_handler_element.removeEventListener('click', this._boundClickHandler);
  118. this._click_handler_element = null
  119. }
  120. }
  121.  
  122. // Override this function in derived classes that want to react to
  123. // random clicks on a page, say to update current element in
  124. // focus.
  125. _clickHandler(evt) {
  126. alert(`Found a bug! ${this.constructor.name} wants to handle clicks, but forgot to create a handler.`);
  127. }
  128.  
  129. }
  130.  
  131. class Global extends Page {
  132. _pathname = null;
  133. _auto_keys = [
  134. {seq: '?', desc: 'Show keyboard help', func: this._help},
  135. {seq: '/', desc: 'Go to Search box', func: this._gotoSearch},
  136. {seq: 'g h', desc: 'Go Home (aka, Feed)', func: this._goHome},
  137. {seq: 'g m', desc: 'Go to My Network', func: this._gotoMyNetwork},
  138. {seq: 'g j', desc: 'Go to Jobs', func: this._gotoJobs},
  139. {seq: 'g g', desc: 'Go to Messaging', func: this._gotoMessaging},
  140. {seq: 'g n', desc: 'Go to Notifications', func: this._gotoNotifications},
  141. {seq: 'g p', desc: 'Go to Profile (aka, Me)', func: this._gotoProfile},
  142. {seq: 'g b', desc: 'Go to Business', func: this._gotoBusiness},
  143. {seq: 'g l', desc: 'Go to Learning', func: this._gotoLearning},
  144. ];
  145.  
  146. get helpId() {
  147. return this._helpId;
  148. }
  149.  
  150. set helpId(val) {
  151. this._helpId = val;
  152. }
  153.  
  154. _gotoNavLink(item) {
  155. document.querySelector(`#global-nav a[href*="/${item}"`).click();
  156. }
  157.  
  158. _gotoNavButton(item) {
  159. const buttons = Array.from(document.querySelectorAll('#global-nav button'));
  160. const button = buttons.find(el => el.textContent.includes(item));
  161. if (button) {
  162. button.click();
  163. }
  164. }
  165.  
  166. _help() {
  167. const help = document.querySelector(`#${this.helpId}`);
  168. help.showModal();
  169. help.focus();
  170. }
  171.  
  172. _gotoSearch() {
  173. document.querySelector('#global-nav-search button').click();
  174. }
  175.  
  176. _goHome() {
  177. this._gotoNavLink('feed');
  178. }
  179.  
  180. _gotoMyNetwork() {
  181. this._gotoNavLink('mynetwork');
  182. }
  183.  
  184. _gotoJobs() {
  185. this._gotoNavLink('jobs');
  186. }
  187.  
  188. _gotoMessaging() {
  189. this._gotoNavLink('messaging');
  190. }
  191.  
  192. _gotoNotifications() {
  193. this._gotoNavLink('notifications');
  194. }
  195.  
  196. _gotoProfile() {
  197. this._gotoNavButton('Me');
  198. }
  199.  
  200. _gotoBusiness() {
  201. this._gotoNavButton('Business');
  202. }
  203.  
  204. _gotoLearning() {
  205. this._gotoNavLink('learning');
  206. }
  207.  
  208. }
  209.  
  210. class Feed extends Page {
  211. _pathname = '/feed/';
  212. _click_handler_selector = 'main';
  213. _auto_keys = [
  214. {seq: 'X', desc: 'Toggle hiding current post', func: this._togglePost},
  215. {seq: 'j', desc: 'Next post', func: this._nextPost},
  216. {seq: 'J', desc: 'Toggle hiding then next post', func: this._nextPostPlus},
  217. {seq: 'k', desc: 'Previous post', func: this._prevPost},
  218. {seq: 'K', desc: 'Toggle hiding then previous post', func: this._prevPostPlus},
  219. {seq: 'm', desc: 'Show more of the post or comment', func: this._seeMore},
  220. {seq: 'c', desc: 'Show comments', func: this._showComments},
  221. {seq: 'n', desc: 'Next comment', func: this._nextComment},
  222. {seq: 'p', desc: 'Previous comment', func: this._prevComment},
  223. {seq: 'l', desc: 'Load more posts (if the <button>New Posts</button> button is available, load those)', func: this._loadMorePosts},
  224. {seq: 'L', desc: 'Like post or comment', func: this._likePostOrComment},
  225. {seq: 'f', desc: 'Focus on current post or comment (causes browser to change focus)', func: this._focusBrowser},
  226. {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
  227. ];
  228.  
  229. _currentPostElement = null;
  230. _currentCommentElement = null;
  231.  
  232. _clickHandler(evt) {
  233. const post = evt.target.closest('div[data-id]');
  234. if (post) {
  235. this._post = post;
  236. }
  237. }
  238.  
  239. get _post() {
  240. return this._currentPostElement;
  241. }
  242.  
  243. set _post(val) {
  244. if (val === this._currentPostElement) {
  245. return;
  246. }
  247. if (this._currentPostElement) {
  248. this._currentPostElement.classList.remove('tom');
  249. }
  250. this._currentPostElement = val;
  251. this._comment = null;
  252. if (val) {
  253. val.classList.add('tom');
  254. this._scrollToCurrentPost();
  255. }
  256. }
  257.  
  258. get _comment() {
  259. return this._currentCommentElement;
  260. }
  261.  
  262. set _comment(val) {
  263. if (this._currentCommentElement) {
  264. this._currentCommentElement.classList.remove('dick');
  265. }
  266. this._currentCommentElement = val;
  267. if (val) {
  268. val.classList.add('dick');
  269. this._scrollToCurrentComment();
  270. }
  271. }
  272.  
  273. _getPosts() {
  274. return Array.from(document.querySelectorAll('main div[data-id]'));
  275. }
  276.  
  277. _getComments() {
  278. if (this._post) {
  279. return Array.from(this._post.querySelectorAll('article.comments-comment-item'));
  280. } else {
  281. return [];
  282. }
  283. }
  284.  
  285. _scrollToCurrentPost() {
  286. this._post.style.scrollMarginTop = navBarHeightCss;
  287. this._post.scrollIntoView();
  288. }
  289.  
  290. _scrollToCurrentComment() {
  291. const rect = this._comment.getBoundingClientRect();
  292. this._comment.style.scrollMarginTop = navBarHeightCss;
  293. this._comment.style.scrollMarginBottom = '3em';
  294. // If both scrolling happens, that means the comment is too long
  295. // to fit on the page, so the top is preferred.
  296. if (rect.bottom > document.documentElement.clientHeight) {
  297. this._comment.scrollIntoView(false);
  298. }
  299. if (rect.top < navBarHeightPixels) {
  300. this._comment.scrollIntoView();
  301. }
  302. }
  303.  
  304. _scrollBy(n) {
  305. const posts = this._getPosts();
  306. if (posts.length) {
  307. let idx = posts.indexOf(this._post);
  308. let post = null;
  309. // Some posts are hidden (ads, suggestions). Skip over thoses.
  310. do {
  311. idx = Math.max(Math.min(idx + n, posts.length - 1), 0);
  312. post = posts[idx];
  313. } while (!post.clientHeight);
  314. this._post = post;
  315. }
  316. }
  317.  
  318. _scrollCommentsBy(n) {
  319. const comments = this._getComments();
  320. if (comments.length) {
  321. let idx = comments.indexOf(this._comment);
  322. idx = Math.min(idx + n, comments.length - 1);
  323. if (idx < 0) {
  324. // focus back to post
  325. this._comment = null;
  326. this._post = this._post;
  327. } else {
  328. this._comment = comments[idx];
  329. }
  330. }
  331. }
  332.  
  333. _nextPost() {
  334. this._scrollBy(1);
  335. }
  336.  
  337. _nextPostPlus() {
  338. this._togglePost();
  339. this._nextPost();
  340. }
  341.  
  342. _prevPost() {
  343. this._scrollBy(-1);
  344. }
  345.  
  346. _prevPostPlus() {
  347. this._togglePost();
  348. this._prevPost();
  349. }
  350.  
  351. _nextComment() {
  352. this._scrollCommentsBy(1);
  353. }
  354.  
  355. _prevComment() {
  356. this._scrollCommentsBy(-1);
  357. }
  358.  
  359. _togglePost() {
  360. if (this._post) {
  361. const dismiss = this._post.querySelector('button[aria-label^="Dismiss post"]');
  362. if (dismiss) {
  363. dismiss.click();
  364. } else {
  365. const undo = this._post.querySelector('button[aria-label^="Undo and show"]');
  366. if (undo) {
  367. undo.click();
  368. }
  369. }
  370. }
  371. }
  372.  
  373. _showComments() {
  374. if (this._post) {
  375. const comments = this._post.querySelector('button[aria-label*="comment"]');
  376. if (comments) {
  377. comments.click();
  378. }
  379. }
  380. }
  381.  
  382. _seeMore() {
  383. const el = this._comment ? this._comment : this._post;
  384. if (el) {
  385. const see_more = el.querySelector('button[aria-label^="see more"]');
  386. if (see_more) {
  387. see_more.click();
  388. }
  389. }
  390. }
  391.  
  392. _likePostOrComment() {
  393. const el = this._comment ? this._comment : this._post;
  394. if (el) {
  395. const like_button = el.querySelector('button[aria-label^="Open reactions menu"]');
  396. like_button.click();
  397. }
  398. }
  399.  
  400. _loadMorePosts() {
  401. const posts = this._getPosts();
  402. const new_updates = posts[0].querySelector('div.feed-new-update-pill button');
  403. if (new_updates) {
  404. new_updates.click();
  405. this._post = posts[0];
  406. this._scrollToCurrentPost();
  407. } else {
  408. const show_more = document.querySelector('main button.scaffold-finite-scroll__load-button');
  409. if (show_more) {
  410. show_more.click();
  411. this._scrollToCurrentPost();
  412. }
  413. }
  414. }
  415.  
  416. _openMeatballMenu() {
  417. if (this._comment) {
  418. // XXX In this case, the aria-label is on the svg element, not
  419. // the button, so use the parentElement.
  420. const button = this._comment.querySelector('[aria-label^="Open options"]').parentElement;
  421. button.click();
  422. } else if (this._post) {
  423. // Yeah, I don't get it. This one isn't the button either,
  424. // but the click works.
  425. const button = this._post.querySelector('[aria-label^="Open control menu"]');
  426. button.click();
  427. }
  428. }
  429.  
  430. _focusBrowser() {
  431. const el = this._comment ? this._comment : this._post;
  432. if (el) {
  433. const tabIndex = el.getAttribute('tabindex');
  434. el.setAttribute('tabindex', 0);
  435. el.focus();
  436. if (tabIndex) {
  437. el.setAttribute('tabindex', tabIndex);
  438. } else {
  439. el.removeAttribute('tabindex');
  440. }
  441. }
  442. }
  443.  
  444. }
  445.  
  446. class Jobs extends Page {
  447. _pathname = '/jobs/';
  448. }
  449.  
  450. class JobsCollections extends Page {
  451. _pathname = '/jobs/collections/';
  452. }
  453.  
  454. class Notifications extends Page {
  455. _pathname = '/notifications/';
  456. _auto_keys = [
  457. {seq: 'j', desc: 'Next notification', func: this._nextNotification},
  458. {seq: 'k', desc: 'Previous notification', func: this._prevNotification},
  459. {seq: 'a', desc: 'Activate the notification (click on it)', func: this._activateNotification},
  460. {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
  461. ];
  462.  
  463. // Ugh. When notifications are deleted, the entire element, and
  464. // parent elements, are deleted and replaced by new elements. So
  465. // the only way to track them is by array position.
  466. _currentNotificationIndex = -1;
  467.  
  468. get _notification() {
  469. if (this._currentNotificationIndex >= 0) {
  470. return this._getNotifications()[this._currentNotificationIndex];
  471. } else {
  472. return null;
  473. }
  474. }
  475.  
  476. set _notification(val) {
  477. if (this._notification) {
  478. this._notification.classList.remove('tom');
  479. }
  480. if (val) {
  481. const notifications = this._getNotifications();
  482. this._currentNotificationIndex = notifications.indexOf(val);
  483. val.classList.add('tom');
  484. this._scrollToCurrentNotification();
  485. }
  486. }
  487.  
  488. _getNotifications() {
  489. return Array.from(document.querySelectorAll('main section div.nt-card-list article'));
  490. }
  491.  
  492. _scrollToCurrentNotification() {
  493. const rect = this._notification.getBoundingClientRect();
  494. this._notification.style.scrollMarginTop = navBarHeightCss;
  495. this._notification.style.scrollMarginBottom = '3em';
  496. if (rect.bottom > document.documentElement.clientHeight) {
  497. this._notification.scrollIntoView(false);
  498. }
  499. if (rect.top < navBarHeightPixels) {
  500. this._notification.scrollIntoView();
  501. }
  502. }
  503.  
  504. _scrollBy(n) {
  505. const notifications = this._getNotifications();
  506. if (notifications.length) {
  507. const idx = Math.max(Math.min(this._currentNotificationIndex + n, notifications.length - 1), 0);
  508. this._notification = notifications[idx];
  509. }
  510. }
  511.  
  512. _nextNotification() {
  513. this._scrollBy(1);
  514. }
  515.  
  516. _prevNotification() {
  517. this._scrollBy(-1);
  518. }
  519.  
  520. _openMeatballMenu() {
  521. if (this._notification) {
  522. const button = this._notification.querySelector('button[aria-label^="Settings menu"]');
  523. if (button) {
  524. button.click();
  525. } else {
  526. const undo = this._notification.querySelector('button[aria-label^="Undo notification deletion"]');
  527. if (undo) {
  528. undo.click();
  529. }
  530. }
  531. }
  532. }
  533.  
  534. _activateNotification() {
  535. if (this._notification) {
  536. // Every notification is different.
  537. function matchesKnownText(el) {
  538. if (el.innerText === 'Apply early') return true;
  539. if (el.innerText.match(/View \d+ Job/)) return true;
  540. return false;
  541. }
  542.  
  543. let button = this._notification.querySelector('button.message-anywhere-button');
  544. if (button) {
  545. button.click();
  546. } else {
  547. const buttons = Array.from(this._notification.querySelectorAll('button'));
  548. const button = buttons.find(matchesKnownText);
  549. if (button) {
  550. button.click();
  551. } else {
  552. const links = this._notification.querySelectorAll('a');
  553. if (links.length === 1) {
  554. links[0].click();
  555. } else {
  556. console.debug(this._notification);
  557. console.debug(this._notification.querySelectorAll('*'));
  558. const msg = [
  559. 'You tried to activate an unsupported notification',
  560. 'element. Please file a bug. If you are comfortable',
  561. 'with using the browser\'s Developer Tools (often the',
  562. 'F12 key), consider sharing the information just logged',
  563. 'in the console / debug view.',
  564. ];
  565. alert(msg.join(' '));
  566. }
  567. }
  568. }
  569. }
  570. }
  571.  
  572. }
  573.  
  574. class Pages {
  575. _global = null;
  576. _page = null;
  577. _pages = new Map();
  578.  
  579. _lastInputElement = null;
  580.  
  581. constructor() {
  582. this._id = crypto.randomUUID();
  583. this._installNavStyle();
  584. this._initializeHelpMenu();
  585. document.addEventListener('focus', this._onFocus.bind(this), true);
  586. document.addEventListener('href', this._onHref.bind(this), true);
  587. }
  588.  
  589. _setInputFocus(state) {
  590. const pages = Array.from(this._pages.values());
  591. pages.push(this._global);
  592. for (const page of pages) {
  593. if (page) {
  594. page.keyboard.setContext('inputFocus', state);
  595. }
  596. }
  597. }
  598.  
  599. _onFocus(evt) {
  600. if (this._lastInputElement && evt.target !== this._lastInputElement) {
  601. this._lastInputElement = null
  602. this._setInputFocus(false);
  603. }
  604. if (isInput(evt.target)) {
  605. this._setInputFocus(true);
  606. this._lastInputElement = evt.target;
  607. }
  608. }
  609.  
  610. _onHref(evt) {
  611. this.activate(evt.detail.url.pathname);
  612. }
  613.  
  614. _installNavStyle() {
  615. const style = document.createElement('style');
  616. style.textContent += '.tom { border-color: orange !important; border-style: solid !important; border-width: medium !important; }';
  617. style.textContent += '.dick { border-color: red !important; border-style: solid !important; border-width: thin !important; }';
  618. document.head.append(style);
  619. }
  620.  
  621. _initializeHelpMenu() {
  622. this._helpId = `help-${this._id}`;
  623. const style = document.createElement('style');
  624. style.textContent += `#${this._helpId} kbd {font-size: 0.85em; padding: 0.07em; border-width: 1px; border-style: solid; }`;
  625. style.textContent += `#${this._helpId} th { padding-top: 1em; text-align: left; }`;
  626. style.textContent += `#${this._helpId} td:first-child { white-space: nowrap; text-align: right; padding-right: 0.5em; }`;
  627. style.textContent += `#${this._helpId} button { border-width: 1px; border-style: solid; border-radius: 0.25em; }`;
  628. document.head.prepend(style);
  629. const dialog = document.createElement('dialog');
  630. dialog.id = this._helpId
  631. dialog.innerHTML = '<table><caption>' +
  632. '<span style="float: left">Keyboard shortcuts</span>' +
  633. '<span style="float: right">Hit <kbd>ESC</kbd> to close</span>' +
  634. '</caption><tbody></tbody></table>';
  635. document.body.prepend(dialog);
  636. }
  637.  
  638. // ThisPage -> This Page
  639. _parseHeader(text) {
  640. return text.replace(/([A-Z])/g, ' $1').trim();
  641. }
  642.  
  643. // 'a b' -> '<kbd>a</kbd> then <kbd>b</kbd>'
  644. _parseSeq(seq) {
  645. const letters = seq.split(' ').map(w => `<kbd>${w}</kbd>`);
  646. const s = letters.join(' then ');
  647. return s;
  648. }
  649.  
  650. _addHelp(page) {
  651. const help = document.querySelector(`#${this._helpId} tbody`);
  652. const section = this._parseHeader(page.helpHeader);
  653. let s = `<tr><th></th><th>${section}</th></tr>`;
  654. for (const {seq, desc} of page.helpContent) {
  655. const keys = this._parseSeq(seq);
  656. s += `<tr><td>${keys}:</td><td>${desc}</td></tr>`;
  657. }
  658. // Don't include works in progress that have no keys yet.
  659. if (page.helpContent.length) {
  660. help.innerHTML += s;
  661. }
  662. }
  663.  
  664. register(page) {
  665. page.start();
  666. this._addHelp(page);
  667. if (page.pathname === null) {
  668. page.helpId = this._helpId
  669. this._global = page;
  670. this._global.activate();
  671. } else {
  672. this._pages.set(page.pathname, page);
  673. }
  674. }
  675.  
  676. _findPage(pathname) {
  677. const pathnames = Array.from(this._pages.keys());
  678. const candidates = pathnames.filter(p => pathname.startsWith(p));
  679. const candidate = candidates.reduce((a, b) => {
  680. return a.length > b.length ? a : b;
  681. }, '');
  682. return this._pages.get(pathname) || null;
  683. }
  684.  
  685. activate(pathname) {
  686. if (this._page) {
  687. this._page.deactivate();
  688. }
  689. const page = this._findPage(pathname);
  690. this._page = page;
  691. if (page) {
  692. page.activate();
  693. }
  694. }
  695. }
  696.  
  697. const pages = new Pages();
  698. pages.register(new Global());
  699. pages.register(new Feed());
  700. pages.register(new Jobs());
  701. pages.register(new JobsCollections());
  702. pages.register(new Notifications());
  703. pages.activate(window.location.pathname);
  704.  
  705. function isInput(element) {
  706. let tagName = '';
  707. if ('tagName' in element) {
  708. tagName = element.tagName.toLowerCase();
  709. }
  710. return (element.isContentEditable || ['input', 'textarea'].includes(tagName));
  711. }
  712.  
  713. let navBarHeightPixels = 0;
  714. let navBarHeightCss = '0';
  715. VM.observe(document.body, () => {
  716. const navbar = document.querySelector('#global-nav');
  717.  
  718. if (navbar) {
  719. navBarHeightPixels = navbar.clientHeight + 4;
  720. navBarHeightCss = `${navBarHeightPixels}px`;
  721.  
  722. return true;
  723. }
  724. });
  725.  
  726. let oldUrl = new URL(window.location);
  727. VM.observe(document.body, () => {
  728. const newUrl = new URL(window.location);
  729. if (oldUrl.href !== newUrl.href) {
  730. const evt = new CustomEvent('href', {detail: {url: newUrl}})
  731. oldUrl = newUrl;
  732. document.dispatchEvent(evt);
  733. }
  734. });
  735.  
  736. })();