Initial updates for Reader View.

This commit is contained in:
Ascrod 2018-05-07 19:45:46 -04:00 • committed by Roy Tam
commit 1c494e80a6
27 changed files with 1757 additions and 1786 deletions

View file

@ -8,15 +8,18 @@ this.EXPORTED_SYMBOLS = ["ReaderMode"];
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
// Constants for telemetry.
const DOWNLOAD_SUCCESS = 0;
const DOWNLOAD_ERROR_XHR = 1;
const DOWNLOAD_ERROR_NO_DOC = 2;
const PARSE_SUCCESS = 0;
const PARSE_ERROR_TOO_MANY_ELEMENTS = 1;
const PARSE_ERROR_WORKER = 2;
const PARSE_ERROR_NO_ARTICLE = 3;
// Class names to preserve in the readerized output. We preserve these class
// names so that rules in aboutReader.css can match them.
const CLASSES_TO_PRESERVE = [
"caption",
"hidden",
"invisble",
"sr-only",
"visually-hidden",
"visuallyhidden",
"wp-caption",
"wp-caption-text",
];
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
@ -24,17 +27,15 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.importGlobalProperties(["XMLHttpRequest"]);
XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", "resource://services-common/utils.js");
XPCOMUtils.defineLazyModuleGetter(this, "Messaging", "resource://gre/modules/Messaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "EventDispatcher", "resource://gre/modules/Messaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ReaderWorker", "resource://gre/modules/reader/ReaderWorker.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", "resource://gre/modules/TelemetryStopwatch.jsm");
XPCOMUtils.defineLazyGetter(this, "Readability", function() {
let scope = {};
scope.dump = this.dump;
Services.scriptloader.loadSubScript("resource://gre/modules/reader/Readability.js", scope);
return scope["Readability"];
return scope.Readability;
});
this.ReaderMode = {
@ -61,21 +62,13 @@ this.ReaderMode = {
return this.isEnabledForParseOnLoad = this._getStateForParseOnLoad();
},
get isOnLowMemoryPlatform() {
let memory = Cc["@mozilla.org/xpcom/memory-service;1"].getService(Ci.nsIMemory);
delete this.isOnLowMemoryPlatform;
return this.isOnLowMemoryPlatform = memory.isLowMemoryPlatform();
},
_getStateForParseOnLoad: function () {
_getStateForParseOnLoad() {
let isEnabled = Services.prefs.getBoolPref("reader.parse-on-load.enabled");
let isForceEnabled = Services.prefs.getBoolPref("reader.parse-on-load.force-enabled");
// For low-memory devices, don't allow reader mode since it takes up a lot of memory.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=792603 for details.
return isForceEnabled || (isEnabled && !this.isOnLowMemoryPlatform);
return isForceEnabled || isEnabled;
},
observe: function(aMessage, aTopic, aData) {
observe(aMessage, aTopic, aData) {
switch (aTopic) {
case "nsPref:changed":
if (aData.startsWith("reader.parse-on-load.")) {
@ -91,7 +84,7 @@ this.ReaderMode = {
* Enter the reader mode by going forward one step in history if applicable,
* if not, append the about:reader page in the history instead.
*/
enterReaderMode: function(docShell, win) {
enterReaderMode(docShell, win) {
let url = win.document.location.href;
let readerURL = "about:reader?url=" + encodeURIComponent(url);
let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
@ -112,7 +105,7 @@ this.ReaderMode = {
* Exit the reader mode by going back one step in history if applicable,
* if not, append the original page in the history instead.
*/
leaveReaderMode: function(docShell, win) {
leaveReaderMode(docShell, win) {
let url = win.document.location.href;
let originalURL = this.getOriginalUrl(url);
let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
@ -136,14 +129,14 @@ this.ReaderMode = {
* @return The original URL for the article, or null if we did not find
* a properly formatted about:reader URL.
*/
getOriginalUrl: function(url) {
getOriginalUrl(url) {
if (!url.startsWith("about:reader?")) {
return null;
}
let outerHash = "";
try {
let uriObj = Services.io.newURI(url, null, null);
let uriObj = Services.io.newURI(url);
url = uriObj.specIgnoringRef;
outerHash = uriObj.ref;
} catch (ex) { /* ignore, use the raw string */ }
@ -155,27 +148,45 @@ this.ReaderMode = {
let originalUrl = searchParams.get("url");
if (outerHash) {
try {
let uriObj = Services.io.newURI(originalUrl, null, null);
uriObj = Services.io.newURI('#' + outerHash, null, uriObj);
let uriObj = Services.io.newURI(originalUrl);
uriObj = Services.io.newURI("#" + outerHash, null, uriObj);
originalUrl = uriObj.spec;
} catch (ex) {}
}
return originalUrl;
},
getOriginalUrlObjectForDisplay(url) {
let originalUrl = this.getOriginalUrl(url);
if (originalUrl) {
let uriObj;
try {
uriObj = Services.uriFixup.createFixupURI(originalUrl, Services.uriFixup.FIXUP_FLAG_NONE);
} catch (ex) {
return null;
}
try {
return Services.uriFixup.createExposableURI(uriObj);
} catch (ex) {
return null;
}
}
return null;
},
/**
* Decides whether or not a document is reader-able without parsing the whole thing.
*
* @param doc A document to parse.
* @return boolean Whether or not we should show the reader mode button.
*/
isProbablyReaderable: function(doc) {
isProbablyReaderable(doc) {
// Only care about 'real' HTML documents:
if (doc.mozSyntheticDocument || !(doc instanceof doc.defaultView.HTMLDocument)) {
return false;
}
let uri = Services.io.newURI(doc.location.href, null, null);
let uri = Services.io.newURI(doc.location.href);
if (!this._shouldCheckUri(uri)) {
return false;
}
@ -187,12 +198,12 @@ this.ReaderMode = {
return new Readability(uri, doc).isProbablyReaderable(this.isNodeVisible.bind(this, utils));
},
isNodeVisible: function(utils, node) {
isNodeVisible(utils, node) {
let bounds = utils.getBoundsWithoutFlushing(node);
return bounds.height > 0 && bounds.width > 0;
},
getUtilsForWin: function(win) {
getUtilsForWin(win) {
return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
},
@ -204,16 +215,14 @@ this.ReaderMode = {
* @return {Promise}
* @resolves JS object representing the article, or null if no article is found.
*/
parseDocument: Task.async(function* (doc) {
let documentURI = Services.io.newURI(doc.documentURI, null, null);
let baseURI = Services.io.newURI(doc.baseURI, null, null);
if (!this._shouldCheckUri(documentURI) || !this._shouldCheckUri(baseURI, true)) {
parseDocument(doc) {
if (!this._shouldCheckUri(doc.documentURIObject) || !this._shouldCheckUri(doc.baseURIObject, true)) {
this.log("Reader mode disabled for URI");
return null;
}
return yield this._readerParse(baseURI, doc);
}),
return this._readerParse(doc);
},
/**
* Downloads and parses a document from a URL.
@ -222,19 +231,28 @@ this.ReaderMode = {
* @return {Promise}
* @resolves JS object representing the article, or null if no article is found.
*/
downloadAndParseDocument: Task.async(function* (url) {
let doc = yield this._downloadDocument(url);
let uri = Services.io.newURI(doc.baseURI, null, null);
if (!this._shouldCheckUri(uri, true)) {
async downloadAndParseDocument(url) {
let doc = await this._downloadDocument(url);
if (!doc) {
return null;
}
if (!this._shouldCheckUri(doc.documentURIObject) || !this._shouldCheckUri(doc.baseURIObject, true)) {
this.log("Reader mode disabled for URI");
return null;
}
return yield this._readerParse(uri, doc);
}),
return await this._readerParse(doc);
},
_downloadDocument: function (url) {
let histogram = Services.telemetry.getHistogramById("READER_MODE_DOWNLOAD_RESULT");
_downloadDocument(url) {
try {
if (!this._shouldCheckUri(Services.io.newURI(url))) {
return null;
}
} catch (ex) {
Cu.reportError(new Error(`Couldn't create URI from ${url} to download: ${ex}`));
return null;
}
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
@ -243,14 +261,12 @@ this.ReaderMode = {
xhr.onload = evt => {
if (xhr.status !== 200) {
reject("Reader mode XHR failed with status: " + xhr.status);
histogram.add(DOWNLOAD_ERROR_XHR);
return;
}
let doc = xhr.responseXML;
if (!doc) {
reject("Reader mode XHR didn't return a document");
histogram.add(DOWNLOAD_ERROR_NO_DOC);
return;
}
@ -261,7 +277,7 @@ this.ReaderMode = {
if (content) {
let urlIndex = content.toUpperCase().indexOf("URL=");
if (urlIndex > -1) {
let baseURI = Services.io.newURI(url, null, null);
let baseURI = Services.io.newURI(url);
let newURI = Services.io.newURI(content.substring(urlIndex + 4), null, baseURI);
let newURL = newURI.spec;
let ssm = Services.scriptSecurityManager;
@ -290,10 +306,10 @@ this.ReaderMode = {
// Convert these to real URIs to make sure the escaping (or lack
// thereof) is identical:
try {
responseURL = Services.io.newURI(responseURL, null, null).specIgnoringRef;
responseURL = Services.io.newURI(responseURL).specIgnoringRef;
} catch (ex) { /* Ignore errors - we'll use what we had before */ }
try {
givenURL = Services.io.newURI(givenURL, null, null).specIgnoringRef;
givenURL = Services.io.newURI(givenURL).specIgnoringRef;
} catch (ex) { /* Ignore errors - we'll use what we had before */ }
if (responseURL != givenURL) {
@ -303,7 +319,6 @@ this.ReaderMode = {
return;
}
resolve(doc);
histogram.add(DOWNLOAD_SUCCESS);
};
xhr.send();
});
@ -318,17 +333,17 @@ this.ReaderMode = {
* @resolves JS object representing the article, or null if no article is found.
* @rejects OS.File.Error
*/
getArticleFromCache: Task.async(function* (url) {
async getArticleFromCache(url) {
let path = this._toHashedPath(url);
try {
let array = yield OS.File.read(path);
let array = await OS.File.read(path);
return JSON.parse(new TextDecoder().decode(array));
} catch (e) {
if (!(e instanceof OS.File.Error) || !e.becauseNoSuchFile)
throw e;
return null;
}
}),
},
/**
* Stores an article in the cache.
@ -338,14 +353,14 @@ this.ReaderMode = {
* @resolves When the article is stored.
* @rejects OS.File.Error
*/
storeArticleInCache: Task.async(function* (article) {
async storeArticleInCache(article) {
let array = new TextEncoder().encode(JSON.stringify(article));
let path = this._toHashedPath(article.url);
yield this._ensureCacheDir();
await this._ensureCacheDir();
return OS.File.writeAtomic(path, array, { tmpPath: path + ".tmp" })
.then(success => {
OS.File.stat(path).then(info => {
return Messaging.sendRequest({
return EventDispatcher.instance.sendRequest({
type: "Reader:AddedToCache",
url: article.url,
size: info.size,
@ -353,7 +368,7 @@ this.ReaderMode = {
});
});
});
}),
},
/**
* Removes an article from the cache given an article URI.
@ -363,26 +378,29 @@ this.ReaderMode = {
* @resolves When the article is removed.
* @rejects OS.File.Error
*/
removeArticleFromCache: Task.async(function* (url) {
async removeArticleFromCache(url) {
let path = this._toHashedPath(url);
yield OS.File.remove(path);
}),
await OS.File.remove(path);
},
log: function(msg) {
log(msg) {
if (this.DEBUG)
dump("Reader: " + msg);
},
_blockedHosts: [
"mail.google.com",
"amazon.com",
"basilisk-browser.org",
"github.com",
"mail.google.com",
"palemoon.org",
"pinterest.com",
"reddit.com",
"twitter.com",
"youtube.com",
],
_shouldCheckUri: function (uri, isBaseUri = false) {
_shouldCheckUri(uri, isBaseUri = false) {
if (!(uri.schemeIs("http") || uri.schemeIs("https"))) {
this.log("Not parsing URI scheme: " + uri.scheme);
return false;
@ -412,59 +430,77 @@ this.ReaderMode = {
* Attempts to parse a document into an article. Heavy lifting happens
* in readerWorker.js.
*
* @param uri The base URI of the article.
* @param doc The document to parse.
* @return {Promise}
* @resolves JS object representing the article, or null if no article is found.
*/
_readerParse: Task.async(function* (uri, doc) {
let histogram = Services.telemetry.getHistogramById("READER_MODE_PARSE_RESULT");
async _readerParse(doc) {
if (this.parseNodeLimit) {
let numTags = doc.getElementsByTagName("*").length;
if (numTags > this.parseNodeLimit) {
this.log("Aborting parse for " + uri.spec + "; " + numTags + " elements found");
histogram.add(PARSE_ERROR_TOO_MANY_ELEMENTS);
this.log("Aborting parse for " + doc.baseURIObject.spec + "; " + numTags + " elements found");
return null;
}
}
// Fetch this here before we send `doc` off to the worker thread, as later on the
// document might be nuked but we will still want the URI.
let {documentURI} = doc;
let uriParam = {
spec: uri.spec,
host: uri.host,
prePath: uri.prePath,
scheme: uri.scheme,
pathBase: Services.io.newURI(".", null, uri).spec
spec: doc.baseURIObject.spec,
host: doc.baseURIObject.host,
prePath: doc.baseURIObject.prePath,
scheme: doc.baseURIObject.scheme,
pathBase: Services.io.newURI(".", null, doc.baseURIObject).spec
};
let langAttributes = {
charset: doc.characterSet,
lang: doc.documentElement.lang
};
let serializer = Cc["@mozilla.org/xmlextras/xmlserializer;1"].
createInstance(Ci.nsIDOMSerializer);
let serializedDoc = serializer.serializeToString(doc);
let options = {
classesToPreserve: CLASSES_TO_PRESERVE,
};
let article = null;
try {
article = yield ReaderWorker.post("parseDocument", [uriParam, serializedDoc]);
article = await ReaderWorker.post("parseDocument", [uriParam, serializedDoc, options]);
} catch (e) {
Cu.reportError("Error in ReaderWorker: " + e);
histogram.add(PARSE_ERROR_WORKER);
}
// Explicitly null out doc to make it clear it might not be available from this
// point on.
doc = null;
if (!article) {
this.log("Worker did not return an article");
histogram.add(PARSE_ERROR_NO_ARTICLE);
return null;
}
// Readability returns a URI object, but we only care about the URL.
article.url = article.uri.spec;
// Readability returns a URI object based on the baseURI, but we only care
// about the original document's URL from now on. This also avoids spoofing
// attempts where the baseURI doesn't match the domain of the documentURI
article.url = documentURI;
delete article.uri;
let flags = Ci.nsIDocumentEncoder.OutputSelectionOnly | Ci.nsIDocumentEncoder.OutputAbsoluteLinks;
article.title = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils)
.convertToPlainText(article.title, flags, 0);
histogram.add(PARSE_SUCCESS);
await this._assignLanguage(article, langAttributes);
this._maybeAssignTextDirection(article);
this._assignReadTime(article);
return article;
}),
},
get _cryptoHash() {
delete this._cryptoHash;
@ -485,7 +521,7 @@ this.ReaderMode = {
* @param url The article URL. This should have referrers removed.
* @return The file path to the cached article.
*/
_toHashedPath: function (url) {
_toHashedPath(url) {
let value = this._unicodeConverter.convertToByteArray(url);
this._cryptoHash.init(this._cryptoHash.MD5);
this._cryptoHash.update(value, value.length);
@ -502,7 +538,7 @@ this.ReaderMode = {
* @resolves When the cache directory exists.
* @rejects OS.File.Error
*/
_ensureCacheDir: function () {
_ensureCacheDir() {
let dir = OS.Path.join(OS.Constants.Path.profileDir, "readercache");
return OS.File.exists(dir).then(exists => {
if (!exists) {
@ -510,5 +546,107 @@ this.ReaderMode = {
}
return undefined;
});
}
},
/**
* Sets a global language string value if possible. If langauge detection is
* available, use that. Otherwise, revert to a simpler mechanism using the
* document's lang attribute or charset.
*
* @return Promise
* @resolves when the language is detected
*/
_assignLanguage(article, attributes) {
try {
Cu.import("resource://modules/translation/LanguageDetector.jsm");
return LanguageDetector.detectLanguage(article.textContent).then(result => {
article.language = result.confident ? result.language : null;
});
} catch(ex) {
return new Promise((resolve) => {
resolve(this._assignSimpleLanguage(attributes));
}).then(result => {
article.language = result;
});
}
},
_assignSimpleLanguage(attributes) {
var lang = attributes.lang.substring(0,2);
if (lang) {
return lang;
}
// If there is no lang attribute, try the charset.
// We can only use this for charsets that are specific to one language.
const charsetLang = new Map([
[ "us-ascii", "en" ],
[ "iso-8859-6", "ar" ],
[ "iso-8859-7", "el" ],
[ "iso-8859-8", "he" ],
[ "iso-8859-9", "tr" ],
[ "iso-8859-11", "th" ],
[ "jis_x0201", "ja" ],
[ "shift_jis", "ja" ],
[ "euc-jp", "ja" ]
]);
return charsetLang.get(attributes.charset);
},
_maybeAssignTextDirection(article) {
// TODO: Remove the hardcoded language codes below once bug 1320265 is resolved.
if (!article.dir && ["ar", "fa", "he", "ug", "ur"].includes(article.language)) {
article.dir = "rtl";
}
},
/**
* Assigns the estimated reading time range of the article to the article object.
*
* @param article the article object to assign the reading time estimate to.
*/
_assignReadTime(article) {
let lang = article.language || "en";
const readingSpeed = this._getReadingSpeedForLanguage(lang);
const charactersPerMinuteLow = readingSpeed.cpm - readingSpeed.variance;
const charactersPerMinuteHigh = readingSpeed.cpm + readingSpeed.variance;
const length = article.length;
article.readingTimeMinsSlow = Math.ceil(length / charactersPerMinuteLow);
article.readingTimeMinsFast = Math.ceil(length / charactersPerMinuteHigh);
},
/**
* Returns the reading speed of a selection of languages with likely variance.
*
* Reading speed estimated from a study done on reading speeds in various languages.
* study can be found here: http://iovs.arvojournals.org/article.aspx?articleid=2166061
*
* @return object with characters per minute and variance. Defaults to English
* if no suitable language is found in the collection.
*/
_getReadingSpeedForLanguage(lang) {
const readingSpeed = new Map([
[ "en", {cpm: 987, variance: 118 } ],
[ "ar", {cpm: 612, variance: 88 } ],
[ "de", {cpm: 920, variance: 86 } ],
[ "es", {cpm: 1025, variance: 127 } ],
[ "fi", {cpm: 1078, variance: 121 } ],
[ "fr", {cpm: 998, variance: 126 } ],
[ "he", {cpm: 833, variance: 130 } ],
[ "it", {cpm: 950, variance: 140 } ],
[ "jw", {cpm: 357, variance: 56 } ],
[ "nl", {cpm: 978, variance: 143 } ],
[ "pl", {cpm: 916, variance: 126 } ],
[ "pt", {cpm: 913, variance: 145 } ],
[ "ru", {cpm: 986, variance: 175 } ],
[ "sk", {cpm: 885, variance: 145 } ],
[ "sv", {cpm: 917, variance: 156 } ],
[ "tr", {cpm: 1054, variance: 156 } ],
[ "zh", {cpm: 255, variance: 29 } ],
]);
return readingSpeed.get(lang) || readingSpeed.get("en");
},
};