From 8c52b1a328f8923f96f479f48d30137c9190d51e Mon Sep 17 00:00:00 2001 From: Cameron Kaiser Date: Thu, 25 Mar 2021 15:36:48 -0700 Subject: [PATCH] #640: update Readability to tip --- .../reader/Readability-readerable.js | 31 +-- toolkit/components/reader/Readability.js | 186 +++++++++++------- 2 files changed, 140 insertions(+), 77 deletions(-) diff --git a/toolkit/components/reader/Readability-readerable.js b/toolkit/components/reader/Readability-readerable.js index f5df709a8..e4dba5340 100644 --- a/toolkit/components/reader/Readability-readerable.js +++ b/toolkit/components/reader/Readability-readerable.js @@ -37,14 +37,22 @@ function isNodeVisible(node) { /** * Decides whether or not the document is reader-able without parsing the whole thing. - * - * @return boolean Whether or not we suspect Readability.parse() will suceeed at returning an article object. + * @param {Object} options Configuration object. + * @param {number} [options.minContentLength=140] The minimum node content length used to decide if the document is readerable. + * @param {number} [options.minScore=20] The minumum cumulated 'score' used to determine if the document is readerable. + * @param {Function} [options.visibilityChecker=isNodeVisible] The function used to determine if a node is visible. + * @return {boolean} Whether or not we suspect Readability.parse() will suceeed at returning an article object. */ -function isProbablyReaderable(doc, isVisible) { - if (!isVisible) { - isVisible = isNodeVisible; +function isProbablyReaderable(doc, options = {}) { + // For backward compatibility reasons 'options' can either be a configuration object or the function used + // to determine if a node is visible. + if (typeof options == "function") { + options = { visibilityChecker: options }; } + var defaultOptions = { minScore: 20, minContentLength: 140, visibilityChecker: isNodeVisible }; + options = Object.assign(defaultOptions, options); + var nodes = doc.querySelectorAll("p, pre"); // Get
nodes which have
node(s) and append them into the `nodes` variable. @@ -57,7 +65,7 @@ function isProbablyReaderable(doc, isVisible) { var brNodes = doc.querySelectorAll("div > br"); if (brNodes.length) { var set = new Set(nodes); - [].forEach.call(brNodes, function(node) { + [].forEach.call(brNodes, function (node) { set.add(node.parentNode); }); nodes = Array.from(set); @@ -66,9 +74,10 @@ function isProbablyReaderable(doc, isVisible) { var score = 0; // This is a little cheeky, we use the accumulator 'score' to decide what to return from // this callback: - return [].some.call(nodes, function(node) { - if (!isVisible(node)) + return [].some.call(nodes, function (node) { + if (!options.visibilityChecker(node)) { return false; + } var matchString = node.className + " " + node.id; if (REGEXPS.unlikelyCandidates.test(matchString) && @@ -81,13 +90,13 @@ function isProbablyReaderable(doc, isVisible) { } var textContentLength = node.textContent.trim().length; - if (textContentLength < 140) { + if (textContentLength < options.minContentLength) { return false; } - score += Math.sqrt(textContentLength - 140); + score += Math.sqrt(textContentLength - options.minContentLength); - if (score > 20) { + if (score > options.minScore) { return true; } return false; diff --git a/toolkit/components/reader/Readability.js b/toolkit/components/reader/Readability.js index e4a9067f5..3518884db 100644 --- a/toolkit/components/reader/Readability.js +++ b/toolkit/components/reader/Readability.js @@ -60,31 +60,32 @@ function Readability(doc, options) { this.FLAG_WEIGHT_CLASSES | this.FLAG_CLEAN_CONDITIONALLY; - var logEl; // Control whether log messages are sent to the console if (this._debug) { - logEl = function(e) { - var rv = e.nodeName + " "; - if (e.nodeType == e.TEXT_NODE) { - return rv + '("' + e.textContent + '")'; + let logNode = function(node) { + if (node.nodeType == node.TEXT_NODE) { + return `${node.nodeName} ("${node.textContent}")`; } - var classDesc = e.className && ("." + e.className.replace(/ /g, ".")); - var elDesc = ""; - if (e.id) - elDesc = "(#" + e.id + classDesc + ")"; - else if (classDesc) - elDesc = "(" + classDesc + ")"; - return rv + elDesc; + let attrPairs = Array.from(node.attributes || [], function(attr) { + return `${attr.name}="${attr.value}"`; + }).join(" "); + return `<${node.localName} ${attrPairs}>`; }; this.log = function () { if (typeof dump !== "undefined") { var msg = Array.prototype.map.call(arguments, function(x) { - return (x && x.nodeName) ? logEl(x) : x; + return (x && x.nodeName) ? logNode(x) : x; }).join(" "); dump("Reader: (Readability) " + msg + "\n"); } else if (typeof console !== "undefined") { - var args = ["Reader: (Readability) "].concat(arguments); + let args = Array.from(arguments, arg => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift("Reader: (Readability)"); console.log.apply(console, args); } }; @@ -124,7 +125,7 @@ Readability.prototype = { okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, positive: /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, - negative: /hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget/i, + negative: /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget/i, extraneous: /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, byline: /byline|author|dateline|writtenby|p-author/i, replaceFonts: /<(\/?)font[^>]*>/gi, @@ -133,8 +134,10 @@ Readability.prototype = { shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\W+/g, whitespace: /^\s*$/, hasContent: /\S$/, + hashUrl: /^#.+/, srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, // See: https://schema.org/Article @@ -143,7 +146,7 @@ Readability.prototype = { UNLIKELY_ROLES: [ "menu", "menubar", "complementary", "navigation", "alert", "alertdialog", "dialog" ], - DIV_TO_P_ELEMS: [ "A", "BLOCKQUOTE", "DL", "DIV", "IMG", "OL", "P", "PRE", "TABLE", "UL", "SELECT" ], + DIV_TO_P_ELEMS: new Set([ "BLOCKQUOTE", "DL", "DIV", "IMG", "OL", "P", "PRE", "TABLE", "UL" ]), ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P"], @@ -230,8 +233,7 @@ Readability.prototype = { if (this._docJSDOMParser && nodeList._isLiveNodeList) { throw new Error("Do not pass live node lists to _replaceNodeTags"); } - for (var i = nodeList.length - 1; i >= 0; i--) { - var node = nodeList[i]; + for (const node of nodeList) { this._setNodeTag(node, newTagName); } }, @@ -452,7 +454,7 @@ Readability.prototype = { /** * Get the article title as an H1. * - * @return void + * @return string **/ _getArticleTitle: function() { var doc = this._doc; @@ -548,11 +550,11 @@ Readability.prototype = { }, /** - * Finds the next element, starting from the given node, and ignoring + * Finds the next node, starting from the given node, and ignoring * whitespace in between. If the given node is an element, the same node is * returned. */ - _nextElement: function (node) { + _nextNode: function (node) { var next = node; while (next && (next.nodeType != this.ELEMENT_NODE) @@ -577,10 +579,10 @@ Readability.prototype = { //

block. var replaced = false; - // If we find a
chain, remove the
s until we hit another element + // If we find a
chain, remove the
s until we hit another node // or non-whitespace. This leaves behind the first
in the chain // (which will be replaced with a

later). - while ((next = this._nextElement(next)) && (next.tagName == "BR")) { + while ((next = this._nextNode(next)) && (next.tagName == "BR")) { replaced = true; var brSibling = next.nextSibling; next.parentNode.removeChild(next); @@ -598,7 +600,7 @@ Readability.prototype = { while (next) { // If we've hit another

, we're done adding children to this

. if (next.tagName == "BR") { - var nextElem = this._nextElement(next.nextSibling); + var nextElem = this._nextNode(next.nextSibling); if (nextElem && nextElem.tagName == "BR") break; } @@ -675,7 +677,6 @@ Readability.prototype = { this._cleanConditionally(articleContent, "fieldset"); this._clean(articleContent, "object"); this._clean(articleContent, "embed"); - this._clean(articleContent, "h1"); this._clean(articleContent, "footer"); this._clean(articleContent, "link"); this._clean(articleContent, "aside"); @@ -691,25 +692,6 @@ Readability.prototype = { }); }); - // If there is only one h2 and its text content substantially equals article title, - // they are probably using it as a header and not a subheader, - // so remove it since we already extract the title separately. - var h2 = articleContent.getElementsByTagName("h2"); - if (h2.length === 1) { - var lengthSimilarRate = (h2[0].textContent.length - this._articleTitle.length) / this._articleTitle.length; - if (Math.abs(lengthSimilarRate) < 0.5) { - var titlesMatch = false; - if (lengthSimilarRate > 0) { - titlesMatch = h2[0].textContent.includes(this._articleTitle); - } else { - titlesMatch = this._articleTitle.includes(h2[0].textContent); - } - if (titlesMatch) { - this._clean(articleContent, "h2"); - } - } - } - this._clean(articleContent, "iframe"); this._clean(articleContent, "input"); this._clean(articleContent, "textarea"); @@ -723,6 +705,9 @@ Readability.prototype = { this._cleanConditionally(articleContent, "ul"); this._cleanConditionally(articleContent, "div"); + // replace H1 with H2 as H1 should be only title that is displayed separately + this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ["h1"]), "h2"); + // Remove extra paragraphs this._removeNodes(this._getAllNodesWithTag(articleContent, ["p"]), function (paragraph) { var imgCount = paragraph.getElementsByTagName("img").length; @@ -736,7 +721,7 @@ Readability.prototype = { }); this._forEachNode(this._getAllNodesWithTag(articleContent, ["br"]), function(br) { - var next = this._nextElement(br.nextSibling); + var next = this._nextNode(br.nextSibling); if (next && next.tagName == "P") br.parentNode.removeChild(br); }); @@ -832,6 +817,21 @@ Readability.prototype = { return node && node.nextElementSibling; }, + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity: function(textA, textB) { + var tokensA = textA.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + var tokensB = textB.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter(token => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; + return 1 - distanceB; + }, + _checkByline: function(node, matchString) { if (this._articleByline) { return false; @@ -872,7 +872,7 @@ Readability.prototype = { _grabArticle: function (page) { this.log("**** grabArticle ****"); var doc = this._doc; - var isPaging = (page !== null ? true: false); + var isPaging = page !== null; page = page ? page : this._doc.body; // We can't grab an article if we don't have a page! @@ -884,6 +884,7 @@ Readability.prototype = { var pageCacheHtml = page.innerHTML; while (true) { + this.log("Starting grabArticle loop"); var stripUnlikelyCandidates = this._flagIsActive(this.FLAG_STRIP_UNLIKELYS); // First, node prepping. Trash nodes that look cruddy (like ones with the @@ -892,6 +893,8 @@ Readability.prototype = { var elementsToScore = []; var node = this._doc.documentElement; + let shouldRemoveTitleHeader = true; + while (node) { var matchString = node.className + " " + node.id; @@ -907,11 +910,19 @@ Readability.prototype = { continue; } + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log("Removing header: ", node.textContent.trim(), this._articleTitle.trim()); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + // Remove unlikely candidates if (stripUnlikelyCandidates) { if (this.REGEXPS.unlikelyCandidates.test(matchString) && !this.REGEXPS.okMaybeItsACandidate.test(matchString) && !this._hasAncestorTag(node, "table") && + !this._hasAncestorTag(node, "code") && node.tagName !== "BODY" && node.tagName !== "A") { this.log("Removing unlikely candidate - " + matchString); @@ -1235,9 +1246,8 @@ Readability.prototype = { var div = doc.createElement("DIV"); div.id = "readability-page-1"; div.className = "page"; - var children = articleContent.childNodes; - while (children.length) { - div.appendChild(children[0]); + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); } articleContent.appendChild(div); } @@ -1446,13 +1456,11 @@ Readability.prototype = { if (elementProperty) { matches = elementProperty.match(propertyPattern); if (matches) { - for (var i = matches.length - 1; i >= 0; i--) { - // Convert to lowercase, and remove any whitespace - // so we can match below. - name = matches[i].toLowerCase().replace(/\s/g, ""); - // multiple authors - values[name] = content.trim(); - } + // Convert to lowercase, and remove any whitespace + // so we can match below. + name = matches[0].toLowerCase().replace(/\s/g, ""); + // multiple authors + values[name] = content.trim(); } } if (!matches && elementName && namePattern.test(elementName)) { @@ -1654,7 +1662,7 @@ Readability.prototype = { */ _hasChildBlockElement: function (element) { return this._someNode(element.childNodes, function(node) { - return this.DIV_TO_P_ELEMS.indexOf(node.tagName) !== -1 || + return this.DIV_TO_P_ELEMS.has(node.tagName) || this._hasChildBlockElement(node); }); }, @@ -1748,7 +1756,9 @@ Readability.prototype = { // XXX implement _reduceNodeList? this._forEachNode(element.getElementsByTagName("a"), function(linkNode) { - linkLength += this._getInnerText(linkNode).length; + var href = linkNode.getAttribute("href"); + var coefficient = href && this.REGEXPS.hashUrl.test(href) ? 0.3 : 1; + linkLength += this._getInnerText(linkNode).length * coefficient; }); return linkLength / textLength; @@ -1875,7 +1885,7 @@ Readability.prototype = { /** * Look for 'data' (as opposed to 'layout') tables, for which we use * similar checks as - * https://dxr.mozilla.org/mozilla-central/rev/71224049c0b52ab190564d3ea0eab089a159a4cf/accessible/html/HTMLTableAccessible.cpp#920 + * https://searchfox.org/mozilla-central/rev/f82d5c549f046cb64ce5602bfd894b7ae807c8f8/accessible/generic/TableAccessible.cpp#19 */ _markDataTables: function(root) { var tables = root.getElementsByTagName("table"); @@ -2000,6 +2010,17 @@ Readability.prototype = { }); }, + _getTextDensity: function(e, tags) { + var textLength = this._getInnerText(e, true).length; + if (textLength === 0) { + return 0; + } + var childrenLength = 0; + var children = this._getAllNodesWithTag(e, tags); + this._forEachNode(children, (child) => childrenLength += this._getInnerText(child, true).length); + return childrenLength / textLength; + }, + /** * Clean an element of all tags of type "tag" if they look fishy. * "Fishy" is an algorithm based on content length, classnames, link density, number of images & embeds, etc. @@ -2010,8 +2031,6 @@ Readability.prototype = { if (!this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) return; - var isList = tag === "ul" || tag === "ol"; - // Gather counts for other typical elements embedded within. // Traverse backwards so we can remove nodes at the same time // without effecting the traversal. @@ -2023,6 +2042,14 @@ Readability.prototype = { return t._readabilityDataTable; }; + var isList = tag === "ul" || tag === "ol"; + if (!isList) { + var listLength = 0; + var listNodes = this._getAllNodesWithTag(node, ["ul", "ol"]); + this._forEachNode(listNodes, (list) => listLength += this._getInnerText(list).length); + isList = listLength / this._getInnerText(node).length > 0.9; + } + if (tag === "table" && isDataTable(node)) { return false; } @@ -2032,11 +2059,16 @@ Readability.prototype = { return false; } + if (this._hasAncestorTag(node, "code")) { + return false; + } + var weight = this._getClassWeight(node); - var contentScore = 0; this.log("Cleaning Conditionally", node); + var contentScore = 0; + if (weight + contentScore < 0) { return true; } @@ -2049,6 +2081,7 @@ Readability.prototype = { var img = node.getElementsByTagName("img").length; var li = node.getElementsByTagName("li").length - 100; var input = node.getElementsByTagName("input").length; + var headingDensity = this._getTextDensity(node, ["h1", "h2", "h3", "h4", "h5", "h6"]); var embedCount = 0; var embeds = this._getAllNodesWithTag(node, ["object", "embed", "iframe"]); @@ -2076,7 +2109,7 @@ Readability.prototype = { (img > 1 && p / img < 0.5 && !this._hasAncestorTag(node, "figure")) || (!isList && li > p) || (input > Math.floor(p/3)) || - (!isList && contentLength < 25 && (img === 0 || img > 2) && !this._hasAncestorTag(node, "figure")) || + (!isList && headingDensity < 0.9 && contentLength < 25 && (img === 0 || img > 2) && !this._hasAncestorTag(node, "figure")) || (!isList && weight < 25 && linkDensity > 0.2) || (weight >= 25 && linkDensity > 0.5) || ((embedCount === 1 && contentLength < 75) || embedCount > 1); @@ -2106,17 +2139,38 @@ Readability.prototype = { }, /** - * Clean out spurious headers from an Element. Checks things like classnames and link density. + * Clean out spurious headers from an Element. * * @param Element * @return void **/ _cleanHeaders: function(e) { - this._removeNodes(this._getAllNodesWithTag(e, ["h1", "h2"]), function (header) { - return this._getClassWeight(header) < 0; + let headingNodes = this._getAllNodesWithTag(e, ["h1", "h2"]); + this._removeNodes(headingNodes, function(node) { + let shouldRemove = this._getClassWeight(node) < 0; + if (shouldRemove) { + this.log("Removing header with low class weight:", node); + } + return shouldRemove; }); }, + /** + * Check if this node is an H1 or H2 element whose content is mostly + * the same as the article title. + * + * @param Element the node to check. + * @return boolean indicating whether this is a title-like header. + */ + _headerDuplicatesTitle: function(node) { + if (node.tagName != "H1" && node.tagName != "H2") { + return false; + } + var heading = this._getInnerText(node, false); + this.log("Evaluating similarity of header:", heading, this._articleTitle); + return this._textSimilarity(this._articleTitle, heading) > 0.75; + }, + _flagIsActive: function(flag) { return (this._flags & flag) > 0; },