414 lines
12 KiB
JavaScript
414 lines
12 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/. */
|
|
|
|
// @ts-check
|
|
|
|
/**
|
|
* @import { GetTextOptions, CanvasSnapshot, ExtractionResult, PageMetadata, ReaderModeDocument } from './PageExtractor.d.ts'
|
|
* @import { PageExtractorParent } from './PageExtractorParent.sys.mjs'
|
|
*/
|
|
|
|
/**
|
|
* We wait for the page to be ready before extracting content headlessly. It's hard
|
|
* to know when a page is "ready", however the strategy here is to wait for
|
|
* DOMContentLoaded, and then a requestIdleCallback. This way the page has time
|
|
* to do an initial amount of work. However, if we wait too long, it will be felt by
|
|
* the user as lag. To mitigate this, wait for at least 2 seconds for the page to settle.
|
|
*/
|
|
const MAX_REQUEST_IDLE_CALLBACK_DELAY_MS = 2000;
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const lazy = XPCOMUtils.declareLazy({
|
|
console: () =>
|
|
console.createInstance({
|
|
prefix: "PageExtractorChild",
|
|
maxLogLevelPref: "browser.ml.logLevel",
|
|
}),
|
|
ReaderMode: "moz-src:///toolkit/components/reader/ReaderMode.sys.mjs",
|
|
extractTextFromDOM:
|
|
"moz-src:///toolkit/components/pageextractor/DOMExtractor.sys.mjs",
|
|
isProbablyReaderable: "resource://gre/modules/Readerable.sys.mjs",
|
|
});
|
|
|
|
/**
|
|
* Extract a variety of content from pages for use in a smart window.
|
|
*/
|
|
export class PageExtractorChild extends JSWindowActorChild {
|
|
/**
|
|
* Route the messages coming from the parent process.
|
|
*
|
|
* @param {object} message
|
|
* @param {string} message.name
|
|
* @param {any} message.data
|
|
*
|
|
* @returns {Promise<unknown>}
|
|
*/
|
|
async receiveMessage({ name, data }) {
|
|
switch (name) {
|
|
case "PageExtractorParent:GetText":
|
|
await this.waitForPageReady();
|
|
return this.getText(data);
|
|
case "PageExtractorParent:WaitForPageReady":
|
|
return this.waitForPageReady();
|
|
case "PageExtractorParent:GetPageMetadata":
|
|
if (this.isAboutReader()) {
|
|
const document = this.browsingContext?.window?.document;
|
|
const result = await this.getText({ removeBoilerplate: true });
|
|
const text = result?.text ?? "";
|
|
const language = document?.querySelector(".container")?.lang ?? "";
|
|
const wordCount = this.#getWordCount(language, text);
|
|
|
|
return {
|
|
structuredDataTypes: [],
|
|
wordCount,
|
|
language,
|
|
isReaderable: true,
|
|
};
|
|
}
|
|
return this.getPageMetadata();
|
|
}
|
|
return Promise.reject(new Error("Unknown message: " + name));
|
|
}
|
|
|
|
/**
|
|
* Resolves after DOMContentLoaded, an idle callback, and a double
|
|
* requestAnimationFrame so layout and paint are committed before
|
|
* extraction reads page geometry.
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async waitForPageReady() {
|
|
const doc = this.document;
|
|
const win = doc.documentGlobal;
|
|
|
|
if (doc.readyState == "loading") {
|
|
await new Promise(resolve => {
|
|
doc.addEventListener("DOMContentLoaded", resolve, { once: true });
|
|
});
|
|
} else {
|
|
lazy.console.log("The page is already interactive");
|
|
}
|
|
|
|
await new Promise(resolve => {
|
|
win.requestIdleCallback(resolve, {
|
|
timeout: MAX_REQUEST_IDLE_CALLBACK_DELAY_MS,
|
|
});
|
|
});
|
|
await new Promise(resolve => {
|
|
win.requestAnimationFrame(() => win.requestAnimationFrame(resolve));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @see PageExtractorParent#getPageMetadata for docs
|
|
*
|
|
* @returns {Promise<PageMetadata>}
|
|
*/
|
|
async getPageMetadata() {
|
|
const document = this.browsingContext?.window?.document;
|
|
|
|
if (!document) {
|
|
return Promise.reject(
|
|
new Error("No document available for page metadata extraction.")
|
|
);
|
|
}
|
|
|
|
const structuredDataTypes = this.#extractStructuredDataTypes(document);
|
|
const language = this.#detectLanguage(document);
|
|
const wordCount = this.#getWordCount(language, document.body.innerText);
|
|
const isReaderable = lazy.isProbablyReaderable(document);
|
|
|
|
return { structuredDataTypes, wordCount, language, isReaderable };
|
|
}
|
|
|
|
/**
|
|
* This will establish a word count of the text argument based on the provided language.
|
|
*
|
|
* @param {string} language
|
|
* @param {string} text
|
|
* @returns {number}
|
|
*/
|
|
#getWordCount(language, text) {
|
|
let wordCount = 0;
|
|
const segmenter = new Intl.Segmenter(language || undefined, {
|
|
granularity: "word",
|
|
});
|
|
for (const { isWordLike } of segmenter.segment(text)) {
|
|
if (isWordLike) {
|
|
wordCount++;
|
|
}
|
|
}
|
|
return wordCount;
|
|
}
|
|
|
|
/**
|
|
* This extracts various `@type` values within the JSON-LD structured data markup of a page.
|
|
*
|
|
* @param {Document} document
|
|
* @returns {string[]}
|
|
*/
|
|
#extractStructuredDataTypes(document) {
|
|
const scripts = document.querySelectorAll(
|
|
'script[type="application/ld+json" i]'
|
|
);
|
|
const types = new Set();
|
|
|
|
const asArray = value => {
|
|
if (Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
return value == null ? [] : [value];
|
|
};
|
|
|
|
for (const script of scripts) {
|
|
const text = script.textContent?.trim();
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// JSON-LD can be:
|
|
// - an object
|
|
// - an array of objects
|
|
// - an object with @graph: [...]
|
|
const topLevelItems = asArray(parsed);
|
|
const graphItems = topLevelItems.flatMap(x => asArray(x?.["@graph"]));
|
|
const items = graphItems.length ? graphItems : topLevelItems;
|
|
|
|
for (const item of items) {
|
|
for (const t of asArray(item?.["@type"])) {
|
|
if (typeof t === "string") {
|
|
types.add(t);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(types);
|
|
}
|
|
|
|
/**
|
|
* Query the lang tag of the document.
|
|
*
|
|
* @param {Document} document
|
|
* @returns {string}
|
|
*/
|
|
#detectLanguage(document) {
|
|
const declared = document?.documentElement?.lang;
|
|
if (declared) {
|
|
try {
|
|
return new Intl.Locale(declared).baseName;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* @see PageExtractorParent#getText for docs
|
|
*
|
|
* @param {GetTextOptions} options
|
|
* @returns {Promise<ExtractionResult | null>}
|
|
*/
|
|
async getText(options = {}) {
|
|
const window = this.browsingContext?.window;
|
|
/** @type {Document} */
|
|
let document = window?.document;
|
|
/** @type {HTMLElement} */
|
|
let rootNode;
|
|
|
|
if (this.isAboutReader()) {
|
|
// If about:reader is loaded, find the proper rootNode so that we just get the
|
|
// content and not any of the UI. This will get passed to DOMExtractor so that
|
|
// the rest of the GetTextOptions can be applied.
|
|
|
|
lazy.console.log("Extracting content from about:reader");
|
|
// TODO - Explain what's different between this document and the browsing context.
|
|
document = this.manager.contentWindow.document;
|
|
|
|
if (!document) {
|
|
lazy.console.log("No content document was available");
|
|
return null;
|
|
}
|
|
|
|
/** @type {HTMLElement?} */
|
|
rootNode = document.querySelector(".container");
|
|
if (!rootNode) {
|
|
lazy.console.log("No container was found in reader mode.");
|
|
return null;
|
|
}
|
|
} else if (options.removeBoilerplate) {
|
|
// Boilerplate removal is requested. See if reader mode can be applied, and then
|
|
// use that for boilerplate removal.
|
|
|
|
if (
|
|
(document && lazy.isProbablyReaderable(document)) ||
|
|
options._forceRemoveBoilerplate
|
|
) {
|
|
// Run the document through reader mode, and use the DOMParser version of the
|
|
// content.
|
|
/** @type {ReaderModeDocument | null} */
|
|
const readerModeDocument =
|
|
await lazy.ReaderMode.parseDocument(document);
|
|
if (readerModeDocument) {
|
|
lazy.console.log("Document is readerable");
|
|
const { content } = readerModeDocument;
|
|
const parser = new DOMParser();
|
|
document = parser.parseFromString(content, "text/html");
|
|
rootNode = document.body;
|
|
} else {
|
|
lazy.console.log(
|
|
"Document is not readerable, boilerplate will not be removed"
|
|
);
|
|
}
|
|
} else {
|
|
lazy.console.log(
|
|
"Document is not readerable, boilerplate will not be removed"
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!document || !rootNode) {
|
|
lazy.console.log("Extracting content without boilerplate removal.");
|
|
// No document or no root node is here, we should use the default extraction
|
|
// strategy, of getting content directly from the hpage.
|
|
document = window?.document;
|
|
rootNode = document.body;
|
|
}
|
|
|
|
if (!document) {
|
|
lazy.console.log("No document was found.");
|
|
return null;
|
|
}
|
|
|
|
// All of the content gets extracted using the DOMExtractor, which knows how
|
|
// to apply certain settings in GetTextOptions.
|
|
const { text, links, canvases } = lazy.extractTextFromDOM(
|
|
document,
|
|
rootNode,
|
|
options
|
|
);
|
|
|
|
let canvasSnapshots = [];
|
|
if (options.includeCanvasSnapshots && canvases.length) {
|
|
canvasSnapshots = await this.#captureCanvases(canvases, options);
|
|
}
|
|
|
|
lazy.console.log("GetText", options);
|
|
lazy.console.debug({ text, links, canvasSnapshots });
|
|
|
|
return { text, links, canvasSnapshots };
|
|
}
|
|
|
|
/**
|
|
* Checks if about:reader is loaded, which requires special handling.
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
isAboutReader() {
|
|
// Accessing the documentURIObject in this way does not materialize the
|
|
// `window.location.href` and should be a cheaper check here.
|
|
let url = this.manager.contentWindow.document.documentURIObject;
|
|
return url.schemeIs("about") && url.pathQueryRef.startsWith("reader?");
|
|
}
|
|
|
|
/**
|
|
* Capture canvas elements as WebP blobs. WebP is chosen for its superior
|
|
* compression-to-quality ratio compared to PNG/JPEG, reducing the data sent
|
|
* to language models while preserving visual fidelity.
|
|
*
|
|
* @param {HTMLCanvasElement[]} canvases
|
|
* @param {GetTextOptions} options
|
|
* @returns {Promise<CanvasSnapshot[]>}
|
|
*/
|
|
async #captureCanvases(canvases, options) {
|
|
const maxDimension = options.maxCanvasDimension ?? 1024;
|
|
const quality = options.canvasQuality ?? 0.8;
|
|
|
|
const results = await Promise.all(
|
|
canvases.map(c => this.#captureCanvas(c, maxDimension, quality))
|
|
);
|
|
return results.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Capture a canvas element as a WebP blob. Uses OffscreenCanvas to avoid
|
|
* blocking the main thread during scaling and blob conversion. ImageBitmap
|
|
* is used as the source to efficiently transfer pixel data from the
|
|
* original canvas.
|
|
*
|
|
* @param {HTMLCanvasElement} canvas
|
|
* @param {number} maxDimension
|
|
* @param {number} quality
|
|
* @returns {Promise<CanvasSnapshot | null>}
|
|
*/
|
|
async #captureCanvas(canvas, maxDimension, quality) {
|
|
const window = canvas.documentGlobal;
|
|
const { width: originalWidth, height: originalHeight } = canvas;
|
|
|
|
try {
|
|
const bitmap = await window.createImageBitmap(canvas);
|
|
|
|
const scale = Math.min(
|
|
1,
|
|
maxDimension / Math.max(originalWidth, originalHeight)
|
|
);
|
|
const targetWidth = Math.floor(originalWidth * scale);
|
|
const targetHeight = Math.floor(originalHeight * scale);
|
|
|
|
const offscreen = new window.OffscreenCanvas(targetWidth, targetHeight);
|
|
// Alpha is enabled to preserve transparency in canvases that use it.
|
|
// willReadFrequently is false because we only draw and convert to blob,
|
|
// never reading pixels back, so hardware acceleration is preferred.
|
|
const ctx = offscreen.getContext("2d", {
|
|
alpha: true,
|
|
willReadFrequently: false,
|
|
});
|
|
|
|
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
|
|
bitmap.close();
|
|
|
|
let blob;
|
|
try {
|
|
blob = await offscreen.convertToBlob({
|
|
type: "image/webp",
|
|
quality,
|
|
});
|
|
} catch (securityError) {
|
|
// Tainted canvas fall back to original canvas toBlob which works
|
|
blob = await new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
b => (b ? resolve(b) : reject(new Error("toBlob failed"))),
|
|
"image/webp",
|
|
quality
|
|
);
|
|
});
|
|
|
|
return {
|
|
blob,
|
|
width: originalWidth,
|
|
height: originalHeight,
|
|
};
|
|
}
|
|
|
|
return {
|
|
blob,
|
|
width: targetWidth,
|
|
height: targetHeight,
|
|
};
|
|
} catch (error) {
|
|
lazy.console.debug?.("Canvas capture failed:", error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|