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
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; },