您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Highlight new Hacker News comments, block users and other UX tweaks
当前为
// ==UserScript== // @name HN Comments Owl // @description Highlight new Hacker News comments, block users and other UX tweaks // @namespace https://github.com/insin/hn-comments-owl/ // @match https://news.ycombinator.com/* // @version 39 // ==/UserScript== const HIGHLIGHT_COLOR = '#ffffde' const TOGGLE_HIDE = '[–]' const TOGGLE_SHOW = '[+]' let config = { addUpvotedToHeader: true, autoHighlightNew: true, hideReplyLinks: false, } config.enableDebugLogging = false //#region Storage class Visit { constructor({commentCount, maxCommentId, time}) { /** @type {number} */ this.commentCount = commentCount /** @type {number} */ this.maxCommentId = maxCommentId /** @type {Date} */ this.time = time } toJSON() { return { c: this.commentCount, m: this.maxCommentId, t: this.time.getTime(), } } } Visit.fromJSON = function(obj) { return new Visit({ commentCount: obj.c, maxCommentId: obj.m, time: new Date(obj.t), }) } function getLastVisit(itemId) { let json = localStorage.getItem(itemId) if (json == null) { return null } return Visit.fromJSON(JSON.parse(json)) } function storeVisit(itemId, visit) { log('storing visit', visit) localStorage.setItem(itemId, JSON.stringify(visit)) } function getIgnoredUsers() { return new Set(JSON.parse(localStorage.ignoredUsers || '[]')) } function setIgnoredUsers(ignoredUsers) { localStorage.ignoredUsers = JSON.stringify(Array.from(ignoredUsers)) } //#endregion //#region Utility functions function addStyle(css = '') { let $style = document.createElement('style') if (css) { $style.textContent = css } document.querySelector('head').appendChild($style) return $style } function checkbox(attributes, label) { return h('label', null, h('input', { style: {verticalAlign: 'middle'}, type: 'checkbox', ...attributes, }), ' ', label, ) } function h(tagName, attributes, ...children) { let $el = document.createElement(tagName) if (attributes) { for (let [prop, value] of Object.entries(attributes)) { if (prop.indexOf('on') === 0) { $el.addEventListener(prop.slice(2).toLowerCase(), value) } else if (prop.toLowerCase() == 'style') { for (let [styleProp, styleValue] of Object.entries(value)) { $el.style[styleProp] = styleValue } } else { $el[prop] = value } } } for (let child of children) { if (child == null || child === false) { continue } if (child instanceof Node) { $el.appendChild(child) } else { $el.insertAdjacentText('beforeend', String(child)) } } return $el } function log(...args) { if (config.enableDebugLogging) { console.log('🦉', ...args) } } function s(count, suffixes = ',s') { if (!suffixes.includes(',')) { suffixes = `,${suffixes}` } return suffixes.split(',')[count === 1 ? 0 : 1] } function toggleDisplay($el, hidden) { $el.classList.toggle('noshow', hidden) // We need to enforce display setting as the page's own script expands all // comments on page load. $el.style.display = hidden ? 'none' : '' } function toggleVisibility($el, hidden) { $el.classList.toggle('nosee', hidden) // We need to enforce visibility setting as the page's own script expands // all comments on page load. $el.style.visibility = hidden ? 'hidden' : 'visible' } //#endregion //#region Feature: add upvoted link to header function addUpvotedLinkToHeader() { if (window.location.pathname == '/upvoted') { return } let $userLink = document.querySelector('span.pagetop a[href^="user?id"]') if (!$userLink) { return } let $pageTop = document.querySelector('span.pagetop') $pageTop.insertAdjacentText('beforeend', ' | ') $pageTop.appendChild(h('a', { href: `/upvoted?id=${$userLink.textContent}`, }, 'upvoted')) } //#endregion //#region Feature: new comment highlighting on comment pages /** * Each comment on a comment page has the following structure: * * ```html * <tr class="athing"> (wrapper) * <td> * <table> * <tr> * <td class="ind"> * <img src="s.gif" height="1" width="123"> (indentation) * </td> * <td class="votelinks">…</td> (vote up/down controls) * <td class="default"> * <div> (meta bar: user, age and folding control) * … * <div class="comment"> * <span class="comtext"> (text and reply link) * ``` * * We want to be able to collapse comment trees which don't contain new comments * and highlight new comments, so for each wrapper we'll create a `HNComment` * object to manage this. * * Comments are rendered as a flat list of table rows, so we'll use the width of * the indentation spacer to determine which comments are descendants of a given * comment. * * Since we have to reimplement our own comment folding, we'll hide the built-in * folding controls and create new ones in a better position (on the left), with * a larger hitbox (larger font and an en dash [–] instead of a hyphen [-]). * * On each comment page view, we store the current comment count, the max * comment id on the page and the current time as the last visit time. */ function commentPage() { log('comment page') /** @type {boolean} */ let autoHighlightNew = config.autoHighlightNew || location.search.includes('?shownew') /** @type {number} */ let commentCount = 0 /** @type {HNComment[]} */ let comments = [] /** @type {Object.<string, HNComment>} */ let commentsById = {} /** @type {boolean} */ let hasNewComments = false /** @type {string} */ let itemId = /id=(\d+)/.exec(location.search)[1] /** @type {Visit} */ let lastVisit /** @type {number} */ let maxCommentId = -1 /** @type {number} */ let newCommentCount = 0 /** @type {Set<string>} */ let ignoredUsers = getIgnoredUsers() class HNComment { /** * @param $wrapper {Element} * @param index {number} */ constructor($wrapper, index) { /** @type {number} */ this.indent = Number($wrapper.querySelector('img[src="s.gif"]').width) /** @type {number} */ this.index = index let $user = $wrapper.querySelector('a.hnuser') /** @type {string} */ this.user = $user ? $user.innerText : null /** @type {Element} */ this.$comment = $wrapper.querySelector('div.comment') /** @type {Element} */ this.$topBar = $wrapper.querySelector('td.default > div') /** @type {Element} */ this.$vote = $wrapper.querySelector('td[valign="top"] > center') /** @type {Element} */ this.$wrapper = $wrapper /** @private @type {HNComment[]} */ this._childComments = null /** * The comment's id. * Will be `-1` for deleted comments. * @type {number} */ this.id = -1 /** * Some flagged comments are collapsed by default. * @type {boolean} */ this.isCollapsed = $wrapper.classList.contains('coll') /** * Comments whose text has been removed but are still displayed may have * their text replaced with [flagged], [dead] or similar - we'll take any * word in square brackets as indication of this. * @type {boolean} */ this.isDeleted = /^\s*\[\w+]\s*$/.test(this.$comment.firstChild.nodeValue) /** * The displayed age of the comment; `${n} minutes/hours/days ago`, or * `on ${date}` for older comments. * Will be blank for deleted comments. * @type {string} */ this.when = '' /** @type {Element} */ this.$collapsedChildCount = null /** @type {Element} */ this.$comhead = this.$topBar.querySelector('span.comhead') /** @type {Element} */ this.$toggleControl = h('span', { onclick: () => this.toggleCollapsed(), style: {cursor: 'pointer'}, }, this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE) if (!this.isDeleted) { let $permalink = this.$topBar.querySelector('a[href^=item]') this.id = Number($permalink.href.split('=').pop()) this.when = $permalink.textContent } this.initDOM() } initDOM() { // We want to use the comment meta bar for the folding control, so put // it back above the deleted comment placeholder. if (this.isDeleted) { this.$topBar.style.marginBottom = '4px' } this.$topBar.insertAdjacentText('afterbegin', ' ') this.$topBar.insertAdjacentElement('afterbegin', this.$toggleControl) } /** * @param updateChildren {boolean=} */ updateDisplay(updateChildren = true) { // Show/hide this comment, preserving display of the meta bar toggleDisplay(this.$comment, this.isCollapsed) if (this.$vote) { toggleVisibility(this.$vote, this.isCollapsed) } this.$toggleControl.textContent = this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE // Show/hide the number of child comments when collapsed if (this.isCollapsed && this.$collapsedChildCount == null) { let collapsedCommentCount = [ this.isDeleted ? '(' : ' | (', this.childComments.length, ` child${s(this.childComments.length, 'ren')})`, ].join('') this.$collapsedChildCount = h('span', null, collapsedCommentCount) this.$comhead.appendChild(this.$collapsedChildCount) } toggleDisplay(this.$collapsedChildCount, !this.isCollapsed) // Completely show/hide any child comments if (updateChildren) { this.childComments.forEach((child) => toggleDisplay(child.$wrapper, this.isCollapsed)) } } /** * @returns {HNComment[]} */ get childComments() { if (this._childComments == null) { this._childComments = [] for (let i = this.index + 1; i < comments.length; i++) { if (comments[i].indent <= this.indent) { break } this._childComments.push(comments[i]) } } return this._childComments } /** * @param commentId {number} * @returns {boolean} */ hasChildCommentsNewerThan(commentId) { return this.childComments.some((comment) => comment.isNewerThan(commentId)) } /** * @param commentId {number} * @returns {boolean} */ isNewerThan(commentId) { return this.id > commentId } /** * @param isCollapsed {boolean=} */ toggleCollapsed(isCollapsed = !this.isCollapsed) { this.isCollapsed = isCollapsed this.updateDisplay() } /** * @param highlight {boolean} */ toggleHighlighted(highlight) { this.$wrapper.style.backgroundColor = highlight ? HIGHLIGHT_COLOR : 'transparent' } } /** * Adds checkboxes to toggle folding and highlighting when there are new * comments on a comment page. * @param $container {Element} */ function addNewCommentControls($container) { $container.appendChild( h('div', null, h('p', null, `${newCommentCount} new comment${s(newCommentCount)} since ${lastVisit.time.toLocaleString()}` ), h('div', null, checkbox({ checked: autoHighlightNew, onclick: (e) => { highlightNewComments(e.target.checked, lastVisit.maxCommentId) }, }, 'highlight new comments'), ' ', checkbox({ checked: autoHighlightNew, onclick: (e) => { collapseThreadsWithoutNewComments(e.target.checked, lastVisit.maxCommentId) }, }, 'collapse threads without new comments'), ), ) ) } /** * Adds a range control and button to show the last X new comments. */ function addTimeTravelCommentControls($container) { let sortedCommentIds = comments.map((comment) => comment.id) .filter(id => id !== -1) .sort((a, b) => a - b) let showNewCommentsAfter = Math.max(0, sortedCommentIds.length - 1) let howMany = sortedCommentIds.length - showNewCommentsAfter function getButtonLabel() { let fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when // Older comments display `on ${date}` instead of a relative time if (fromWhen.startsWith(' on')) { fromWhen = fromWhen.replace(' on', 'since') } else { fromWhen = `from ${fromWhen}` } return `highlight ${howMany} comment${s(howMany)} ${fromWhen}` } let $range = h('input', { max: sortedCommentIds.length - 1, min: 1, oninput(e) { showNewCommentsAfter = Number(e.target.value) howMany = sortedCommentIds.length - showNewCommentsAfter $button.value = getButtonLabel() }, style: {margin: 0, verticalAlign: 'middle'}, type: 'range', value: sortedCommentIds.length - 1, }) let $button = h('input', { onclick() { let referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1] log(`manually highlighting ${howMany} comments since ${referenceCommentId}`) highlightNewComments(true, referenceCommentId) collapseThreadsWithoutNewComments(true, referenceCommentId) $timeTravelControl.remove() }, type: 'button', value: getButtonLabel(), }) let $timeTravelControl = h('div', { style: {marginTop: '1em'}, }, $range, ' ', $button) $container.appendChild($timeTravelControl) } /** * Adds the appropriate page controls depending on whether or not there are * new comments or any comments at all. */ function addPageControls() { let $container = document.querySelector('td.subtext') if (!$container) { log('no container found for page controls') return } if (hasNewComments) { addNewCommentControls($container) } else if (commentCount > 1) { addTimeTravelCommentControls($container) } } /** * Collapses threads which don't have any comments newer than the given * comment id. * @param collapse {boolean} * @param referenceCommentId {number} */ function collapseThreadsWithoutNewComments(collapse, referenceCommentId) { for (let i = 0; i < comments.length; i++) { let comment = comments[i] if (!comment.isNewerThan(referenceCommentId) && !comment.hasChildCommentsNewerThan(referenceCommentId)) { comment.toggleCollapsed(collapse) // Skip over child comments i += comment.childComments.length } } } function hideBuiltInCommentFoldingControls() { addStyle('a.togg { display: none; }') } let toggleHideReplyLinks = (function() { let $style = addStyle() return () => { $style.textContent = config.hideReplyLinks ? ` div.reply { margin-top: 8px; } div.reply p { display: none; } ` : '' } })() /** * Highlights comments newer than the given comment id. * @param highlight {boolean} * @param referenceCommentId {number} */ function highlightNewComments(highlight, referenceCommentId) { comments.forEach((comment) => { if (comment.isNewerThan(referenceCommentId)) { comment.toggleHighlighted(highlight) } }) } function initComments() { let commentWrappers = document.querySelectorAll('table.comment-tree tr.athing') log('number of comment wrappers', commentWrappers.length) let index = 0 let lastMaxCommentId = lastVisit != null ? lastVisit.maxCommentId : -1 let ignoreIndent = null for (let $wrapper of commentWrappers) { let comment = new HNComment($wrapper, index++) // Remove comments in threads under ignored users if (ignoreIndent != null) { if (comment.indent > ignoreIndent) { toggleDisplay($wrapper, true) continue } ignoreIndent = null } // Remove comment from ignored users if (ignoredUsers.has(comment.user)) { toggleDisplay($wrapper, true) ignoreIndent = comment.indent continue } if (comment.id > maxCommentId) { maxCommentId = comment.id } if (comment.isNewerThan(lastMaxCommentId)) { newCommentCount++ } comments.push(comment) if (comment.id != -1) { commentsById[comment.id] = comment } } hasNewComments = lastVisit != null && newCommentCount > 0 } // TODO Only store visit data when the item header is present (i.e. not a comment permalink) // TODO Only store visit data for commentable items (a reply box / reply links are visible) // TODO Clear any existing stored visit if the item is no longer commentable function storePageViewData() { storeVisit(itemId, new Visit({ commentCount, maxCommentId, time: new Date(), })) } lastVisit = getLastVisit(itemId) let $commentsLink = document.querySelector('td.subtext > a[href^=item]') if ($commentsLink && /^\d+/.test($commentsLink.textContent)) { commentCount = Number($commentsLink.textContent.split(/\s/).shift()) } hideBuiltInCommentFoldingControls() toggleHideReplyLinks() initComments() comments.filter(comment => comment.isCollapsed).forEach(comment => comment.updateDisplay(false)) if (hasNewComments && autoHighlightNew) { highlightNewComments(true, lastVisit.maxCommentId) collapseThreadsWithoutNewComments(true, lastVisit.maxCommentId) } addPageControls() storePageViewData() log('page view data', { autoHighlightNew, commentCount, hasNewComments, itemId, lastVisit, maxCommentId, newCommentCount, }) chrome.storage.onChanged.addListener((changes) => { if ('hideReplyLinks' in changes) { config.hideReplyLinks = changes['hideReplyLinks'].newValue toggleHideReplyLinks() } }) } //#endregion //#region Feature: new comment indicators on link pages /** * Each item on an item list page has the following structure: * * ```html * <tr class="athing">…</td> (rank, upvote control, title/link and domain) * <tr> * <td>…</td> (spacer) * <td class="subtext">…</td> (item meta info) * </tr> * <tr class="spacer">…</tr> * ``` * * Using the comment count stored when you visit a comment page, we'll display * the number of new comments in the subtext section and provide a link which * will automatically highlight new comments and collapse comment trees without * new comments. * * For regular stories, the subtext element contains points, user, age (in * a link to the comments page), flag/hide controls and finally the number of * comments (in another link to the comments page). We'll look for the latter * to detemine the current number of comments and the item id. * * For job postings, the subtext element only contains age (in * a link to the comments page) and a hide control, so we'll try to ignore * those. */ function itemListPage() { log('item list page') let commentLinks = document.querySelectorAll('td.subtext > a[href^="item?id="]:last-child') log('number of comments/discuss links', commentLinks.length) let noCommentsCount = 0 let noLastVisitCount = 0 for (let $commentLink of commentLinks) { let id = $commentLink.href.split('=').pop() let commentCountMatch = /^(\d+)/.exec($commentLink.textContent) if (commentCountMatch == null) { noCommentsCount++ continue } let lastVisit = getLastVisit(id) if (lastVisit == null) { noLastVisitCount++ continue } let commentCount = Number(commentCountMatch[1]) if (commentCount <= lastVisit.commentCount) { log(`${id} doesn't have any new comments`, lastVisit) continue } $commentLink.insertAdjacentElement('afterend', h('span', null, ' (', h('a', { href: `/item?shownew&id=${id}`, style: {fontWeight: 'bold'}, }, commentCount - lastVisit.commentCount, ' new' ), ')', ) ) } if (noCommentsCount > 0) { log(`${noCommentsCount} item${s(noCommentsCount, " doesn't,s don't")} have any comments`) } if (noLastVisitCount > 0) { log(`${noLastVisitCount} item${s(noLastVisitCount, " doesn't,s don't")} have a last visit stored`) } } //#endregion //#region Feature: block/unblock users on profile pages function userProfilePage() { log('user profile page') let $userLink = document.querySelector('a.hnuser') if ($userLink == null) { log('not a valid user') return } let userId = $userLink.innerText let $currentUserLink = document.querySelector('a#me') let currentUser = $currentUserLink ? $currentUserLink.innerText : '' let ignoredUsers = getIgnoredUsers() let $tbody = $userLink.closest('table').querySelector('tbody') if (userId == currentUser) { let first = 0 ignoredUsers.forEach((ignoredUserId) => { $tbody.appendChild( h('tr', null, h('td', {valign: 'top'}, first++ == 0 ? 'blocked:' : ''), h('td', null, h('a', {href: `/user?id=${ignoredUserId}`}, ignoredUserId), h('a', { href: '#', onClick: function(e) { e.preventDefault() if (ignoredUsers.has(ignoredUserId)) { ignoredUsers.delete(ignoredUserId) this.firstElementChild.innerText = 'block' } else { ignoredUsers.add(ignoredUserId) this.firstElementChild.innerText = 'unblock' } setIgnoredUsers(ignoredUsers) } }, ' (', h('u', null, 'unblock'), ')' ) ) ) ) }) } else { $tbody.appendChild( h('tr', null, h('td'), h('td', null, h('a', { href: '#', onClick: function(e) { e.preventDefault() if (ignoredUsers.has(userId)) { ignoredUsers.delete(userId) this.firstElementChild.innerText = 'block' } else { ignoredUsers.add(userId) this.firstElementChild.innerText = 'unblock' } setIgnoredUsers(ignoredUsers) } }, h('u', null, ignoredUsers.has(userId) ? 'unblock' : 'block') ) ) ) ) } } //#endregion //#region Main function main() { log('config', config) if (config.addUpvotedToHeader) { addUpvotedLinkToHeader() } let path = location.pathname.slice(1) if (/^($|active|ask|best|front|news|newest|noobstories|show|submitted|upvoted)/.test(path)) { itemListPage() } else if (/^item/.test(path)) { commentPage() } else if (/^user/.test(path)) { userProfilePage() } } main() //#endregion