244 lines
7.7 KiB
JavaScript
244 lines
7.7 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
|
|
ReaderMode: "moz-src:///toolkit/components/reader/ReaderMode.sys.mjs",
|
|
});
|
|
|
|
/**
|
|
* Represents a child actor for handling link previews in the browser.
|
|
* Interacts with content windows and handles events related to link previews.
|
|
*
|
|
* @class LinkPreviewChild
|
|
* @augments {JSWindowActorChild}
|
|
*/
|
|
export class LinkPreviewChild extends JSWindowActorChild {
|
|
/**
|
|
* Handles incoming messages from the parent actor.
|
|
*
|
|
* @param {object} message - The message object containing name and data.
|
|
* @param {string} message.name - The name of the message.
|
|
* @param {object} message.data - Data associated with the message.
|
|
* @returns {Promise<object>|undefined} The result of fetchPageData if applicable.
|
|
*/
|
|
async receiveMessage({ name, data }) {
|
|
if (name === "LinkPreview:FetchPageData") {
|
|
return this.fetchPageData(data.url);
|
|
}
|
|
//expected a return value. consistent-return (eslint)
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Fetches the HTML content from the given URL.
|
|
*
|
|
* @param {string} url - The URL to fetch.
|
|
* @returns {Promise<string>} The HTML content as a string.
|
|
* @throws {Error} If the fetch fails or the content type is invalid.
|
|
*/
|
|
fetchHTML(url) {
|
|
const uri = lazy.NetUtil.newURI(url);
|
|
if (!uri.schemeIs("https")) {
|
|
throw Components.Exception(
|
|
"Only handling https",
|
|
Cr.NS_ERROR_UNKNOWN_PROTOCOL
|
|
);
|
|
}
|
|
|
|
// Make requests with a channel to automatically get safe browsing checks.
|
|
// Use null principals in combination with anonymous for now ahead of
|
|
// fetching content with cookies to handle sites requiring login.
|
|
const principal = Services.scriptSecurityManager.createNullPrincipal({});
|
|
const channel = lazy.NetUtil.newChannel({
|
|
contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT,
|
|
loadingPrincipal: principal,
|
|
securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
|
|
triggeringPrincipal: principal,
|
|
uri,
|
|
}).QueryInterface(Ci.nsIHttpChannel);
|
|
channel.loadFlags = Ci.nsIRequest.LOAD_ANONYMOUS;
|
|
|
|
// Specially identify this request, e.g., for publishers to opt out
|
|
channel.setRequestHeader("x-firefox-ai", "1", false);
|
|
|
|
const { promise, resolve, reject } = Promise.withResolvers();
|
|
const MAX_CONTENT_LENGTH = 2 * 1024 * 1024; // 2 MB limit
|
|
|
|
let charset = "utf-8";
|
|
const byteChunks = [];
|
|
let totalLength = 0;
|
|
channel.asyncOpen({
|
|
onDataAvailable(request, stream, offset, count) {
|
|
totalLength += count;
|
|
if (totalLength > MAX_CONTENT_LENGTH) {
|
|
request.cancel(Cr.NS_ERROR_FILE_TOO_BIG);
|
|
} else {
|
|
byteChunks.push(lazy.NetUtil.readInputStream(stream, count));
|
|
}
|
|
},
|
|
onStartRequest(request) {
|
|
const http = request.QueryInterface(Ci.nsIHttpChannel);
|
|
|
|
// Enforce text/html if provided by server
|
|
let contentType = "";
|
|
try {
|
|
contentType = http.getResponseHeader("content-type");
|
|
} catch (ex) {}
|
|
if (contentType && !contentType.startsWith("text/html")) {
|
|
request.cancel(Cr.NS_ERROR_FILE_UNKNOWN_TYPE);
|
|
}
|
|
|
|
// Save charset for later decoding
|
|
const match = contentType.match(/charset=([^;]+)/i);
|
|
if (match) {
|
|
charset = match[1];
|
|
}
|
|
|
|
// Enforce max length if provided by server
|
|
try {
|
|
if (http.getResponseHeader("content-length") > MAX_CONTENT_LENGTH) {
|
|
request.cancel(Cr.NS_ERROR_FILE_TOO_BIG);
|
|
}
|
|
} catch (ex) {}
|
|
},
|
|
onStopRequest(_request, status) {
|
|
if (Components.isSuccessCode(status)) {
|
|
const bytes = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (const chunk of byteChunks) {
|
|
bytes.set(new Uint8Array(chunk), offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
|
|
const decoder = new TextDecoder(charset);
|
|
resolve(decoder.decode(bytes));
|
|
} else {
|
|
reject(Components.Exception("Failed to fetch HTML", status));
|
|
}
|
|
},
|
|
});
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Fetches HTML content from a URL and parses its meta tags and page text.
|
|
*
|
|
* @param {string} url - The URL to fetch and parse.
|
|
* @returns {Promise<object>} An object containing meta information, page text, and HTML code.
|
|
*/
|
|
async fetchPageData(url) {
|
|
const ret = {
|
|
article: {},
|
|
metaInfo: {},
|
|
url,
|
|
};
|
|
try {
|
|
const htmlCode = await this.fetchHTML(url);
|
|
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(htmlCode, "text/html");
|
|
|
|
ret.metaInfo = this.parseMetaTagsFromDoc(doc);
|
|
ret.article = await this.getArticleDataFromDoc(doc);
|
|
} catch (error) {
|
|
console.error(`Failed to fetch and parse page data: ${error}`);
|
|
ret.error = { message: error.message, result: error.result };
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* Parses meta tags from the provided Document into a key-value object.
|
|
* Also extracts the title if available.
|
|
*
|
|
* @param {Document} doc - The parsed HTML document.
|
|
* @returns {object} An object containing meta tag key-value pairs.
|
|
*/
|
|
parseMetaTagsFromDoc(doc) {
|
|
const metaTags = doc.querySelectorAll("meta");
|
|
const metaInfo = {};
|
|
|
|
// TODO: Define the meta tags we are interested in
|
|
const desiredMetaNames = [
|
|
"description",
|
|
"og:image",
|
|
"title",
|
|
"og:title",
|
|
"twitter:title",
|
|
"og:description",
|
|
"twitter:description",
|
|
];
|
|
|
|
metaTags.forEach(tag => {
|
|
const name = tag.getAttribute("name") || tag.getAttribute("property");
|
|
const content = tag.getAttribute("content");
|
|
if (name && content) {
|
|
if (desiredMetaNames.includes(name.toLowerCase())) {
|
|
metaInfo[name] = content;
|
|
}
|
|
}
|
|
});
|
|
|
|
const title = doc.querySelector("title")?.textContent;
|
|
if (title) {
|
|
metaInfo["html:title"] = title;
|
|
}
|
|
|
|
return metaInfo;
|
|
}
|
|
|
|
/**
|
|
* Extracts article data from the provided Document using ReaderMode.
|
|
*
|
|
* @param {Document} doc - The parsed HTML document.
|
|
* @returns {Promise<object>} The extracted article data including specified fields.
|
|
*/
|
|
async getArticleDataFromDoc(doc) {
|
|
try {
|
|
const article = await lazy.ReaderMode.parseDocument(doc);
|
|
if (article) {
|
|
const {
|
|
title,
|
|
byline,
|
|
content,
|
|
length,
|
|
siteName,
|
|
excerpt,
|
|
readingTimeMinsSlow,
|
|
readingTimeMinsFast,
|
|
} = article;
|
|
|
|
// parseDocument return a `textContent` that strips structure and newlines, which we need for the model.
|
|
// So we convert the HTML `content` to plain text directly, preserving formatting and newlines.
|
|
const textContent = Cc["@mozilla.org/parserutils;1"]
|
|
.getService(Ci.nsIParserUtils)
|
|
.convertToPlainText(
|
|
content,
|
|
Ci.nsIDocumentEncoder.OutputSelectionOnly | // Use only selected reader-view fragment
|
|
Ci.nsIDocumentEncoder.OutputAbsoluteLinks |
|
|
Ci.nsIDocumentEncoder.OutputFormatted, // Pretty-print formatting
|
|
0 // No line-wrapping
|
|
);
|
|
|
|
return {
|
|
title,
|
|
byline,
|
|
textContent,
|
|
length,
|
|
siteName,
|
|
excerpt,
|
|
readingTimeMinsFast,
|
|
readingTimeMinsSlow,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error("Error parsing document with ReaderMode:", error);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
}
|