|
|
|
|
Quelle DOMExtractor.sys.mjs
Sprache: unbekannt
|
|
rahmenlose Ansicht.mjs DruckansichtUnknown {[0] [0] [0]}Mathematik
/* 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 https://mozilla.org/MPL/2. 0/. */
// @ts-check
/**
* @see {extractTextFromDOM} for a high level overview of this file.
*/
/**
* @import { GetTextOptions, DOMExtractionResult, ExtractionStrategy } from './PageExtrac tor.d.ts'
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = XPCOMUtils.declareLazy({
SearchStaticData:
"moz-src:///toolkit/components/search/SearchStaticData.sys.mjs",
});
const WHITESPACE_REGEX = /\s+/g;
const MARKDOWN_TEXT_ESCAPE_REGEX = /[\[\]()]/g;
const OPEN_PAREN_REGEX = /\(/g;
const CLOSE_PAREN_REGEX = /\)/g;
const DEFAULT_STRATEGY = Object.freeze({
filterSelector: null,
formatBlockAnchorsAsMarkdown: false,
formatBlockAnchorSelector: null,
});
const GOOGLE_SEARCH_STRATEGY = Object.freeze({
filterSelector: "cite",
formatBlockAnchorsAsMarkdown: true,
formatBlockAnchorSelector: "cite",
});
/**
* The context for extracting text content from the DOM.
*/
class ExtractionContext {
/**
* Set of nodes that have already been processed, used to avoid duplicating text extraction.
*
* @type {Set<Node>}
*/
#processedNodes = new Set();
/**
* Set of anchors that have already had their content formatted as a markdown link.
* Used to prevent duplicate markdown links for multiple blocks inside the same anchor.
*
* @type {Set<HTMLAnchorElement>}
*/
#linkedAnchors = new Set();
/**
* The text-extraction options, provided at initialization.
*
* @type {GetTextOptions}
*/
#options;
/**
* The accumulated text content that has been extracted from the DOM.
*
* @type {string}
*/
#textContent = "";
/**
* @type {Set<string>}
*/
#links = new Set();
/**
* @type {Set<HTMLCanvasElement>}
*/
#canvases = new Set();
/**
* @type {number}
*/
#minCanvasSize;
/**
* @type {number}
*/
#maxCanvasCount;
/**
* When extracting content just from the viewport, this value will be set.
*
* @type {{ top: number; left: number; right: number; bottom: number } | null}
*/
#viewportRect = null;
/**
* @type {ExtractionStrategy}
*/
#strategy = DEFAULT_STRATEGY;
/**
* Constructs a new extraction context with the provided options.
*
* @param {Document} document
* @param {GetTextOptions} options
*/
constructor(document, options) {
this.#options = options;
this.#minCanvasSize = options.minCanvasSize ?? 50;
this.#maxCanvasCount = options.includeCanvasSnapshots
? (options.maxCanvasCount ?? 10)
: 0;
if (options.justViewport) {
const { visualViewport } = document.defaultView;
const { offsetTop, offsetLeft, width, height } = visualViewport;
this.#viewportRect = {
top: offsetTop,
left: offsetLeft,
right: offsetLeft + width,
bottom: offsetTop + height,
};
}
if (options.sourceUrl) {
this.#strategy = getStrategyForUrl(URL.parse(options.sourceUrl));
}
}
/**
* Returns true if the element should be filtered based on current site strategy.
*
* @param {Node} node
* @returns {boolean}
*/
shouldFilterNode(node) {
const filterSelector = this.#strategy.filterSelector;
if (!filterSelector) {
return false;
}
const element = asElement(node);
if (!element) {
return false;
}
return element.matches(filterSelector);
}
/**
* Accumulated text content produced during traversal.
*
* @returns {string}
*/
get textContent() {
return this.#textContent;
}
/**
* @returns {string[]}
*/
get links() {
return Array.from(this.#links);
}
/**
* @returns {HTMLCanvasElement[]}
*/
get canvases() {
return Array.from(this.#canvases);
}
/**
* @param {string} href
*/
maybeAddLink(href) {
this.#links.add(href);
}
/**
* Add href from an anchor element if it has one.
*
* @param {HTMLAnchorElement} anchor
*/
#addHrefFromAnchor(anchor) {
if (anchor.hasAttribute("href")) {
const href = anchor.href;
if (href) {
this.#links.add(href);
}
}
}
/**
* Get an ancestor anchor element for block content.
* Returns null for top-level elements or if no ancestor anchor exists.
*
* @param {Element} element
* @returns {HTMLAnchorElement | null}
*/
#getAncestorAnchor(element) {
const { nodeName } = element;
if (nodeName === "BODY" || nodeName === "HTML") {
return null;
}
const ancestor = element.closest("a");
if (ancestor?.hasAttribute("href")) {
return /** @type {HTMLAnchorElement} */ (ancestor);
}
return null;
}
/**
* @param {HTMLCanvasElement} canvas
*/
#maybeAddCanvas(canvas) {
const canvasSet = this.#canvases;
if (canvasSet.has(canvas)) {
return;
}
if (canvasSet.size >= this.#maxCanvasCount) {
return;
}
const minSize = this.#minCanvasSize;
if (canvas.width < minSize || canvas.height < minSize) {
return;
}
if (isNodeHidden(canvas) || this.maybeOutOfViewport(canvas)) {
return;
}
canvasSet.add(canvas);
}
/**
* If this node is an anchor element, add its href to the links set.
* Used for container nodes that will be subdivided, to capture anchors
* that wrap block-level content.
*
* @param {Node} node
*/
addLinkIfAnchor(node) {
const element = asElement(node);
if (element?.nodeName === "A") {
const href = /** @type {HTMLAnchorElement} */ (element).href;
if (href) {
this.maybeAddLink(href);
}
}
}
/**
* Extract all links from a node using querySelector.
* Should only be called on leaf/accepted blocks, not on containers
* that will be subdivided.
*
* @param {Node} node
*/
extractLinksFromBlock(node) {
const element = asElement(node);
if (!element) {
return;
}
if (element.nodeName === "A") {
this.#addHrefFromAnchor(/** @type {HTMLAnchorElement} */ (element));
} else {
const ancestorAnchor = this.#getAncestorAnchor(element);
if (ancestorAnchor) {
this.#addHrefFromAnchor(ancestorAnchor);
}
}
const anchors = element.getElementsByTagName("a");
for (let i = 0, len = anchors.length; i < len; i++) {
this.#addHrefFromAnchor(anchors[i]);
}
}
/**
* Extract all canvases from a node.
*
* @param {Node} node
*/
extractCanvasesFromBlock(node) {
const canvasSet = this.#canvases;
const maxCount = this.#maxCanvasCount;
if (canvasSet.size >= maxCount) {
return;
}
const element = asElement(node);
if (!element) {
return;
}
if (element.tagName === "CANVAS") {
this.#maybeAddCanvas(/** @type {HTMLCanvasElement} */ (element));
return;
}
const canvases = element.getElementsByTagName("canvas");
const len = canvases.length;
if (len === 0) {
return;
}
for (let i = 0; i < len; i++) {
if (canvasSet.size >= maxCount) {
break;
}
this.#maybeAddCanvas(canvases[i]);
}
}
/**
* Returns true if a condition has been met such that the text
* extraction should stop early, otherwise false.
*
* @returns {boolean}
*/
shouldStopExtraction() {
const { sufficientLength } = this.#options;
if (
sufficientLength !== undefined &&
this.#textContent.length >= sufficientLength
) {
return true;
}
return false;
}
/**
* Returns true if this node or its ancestor's text content has
* already been extracted from the DOM.
*
* @param {Node} node
*/
#isNodeProcessed(node) {
if (this.#processedNodes.has(node)) {
return true;
}
for (const ancestor of getAncestorsIterator(node)) {
if (this.#processedNodes.has(ancestor)) {
return true;
}
}
return false;
}
/**
* When capturing content only in the viewport, skip nodes that are outside of it.
*
* @param {Node} node
*/
maybeOutOfViewport(node) {
if (!this.#viewportRect) {
// We don't have a viewport rect, so skip this check.
return false;
}
const element = getHTMLElementForStyle(node);
if (!element) {
return false;
}
const rect = element.getBoundingClientRect();
if (!rect) {
return false;
}
return (
rect.bottom <= this.#viewportRect.top ||
rect.top >= this.#viewportRect.bottom ||
rect.right <= this.#viewportRect.left ||
rect.left >= this.#viewportRect.right
);
}
/**
* Append the node's text content to the accumulated text only if the node
* itself as well as no ancestor of the node has already been processed.
*
* @param {Node} node
*/
maybeAppendTextContent(node) {
if (this.#isNodeProcessed(node)) {
return;
}
this.#processedNodes.add(node);
if (isNodeHidden(node)) {
return;
}
if (this.maybeOutOfViewport(node)) {
// This only can return true when we're capturing just the viewport nodes.
return;
}
const element = asHTMLElement(node);
const text = asTextNode(node);
let innerText = "";
if (element) {
if (this.#hasInlineAnchors(element) || this.#strategy.filterSelector) {
innerText = this.#extractTextWithMarkdownLinks(element);
} else {
innerText = element.innerText.trim();
}
// Wrap as markdown link if block is inside an ancestor anchor.
// Only format once per anchor to avoid duplicate links
if (this.#strategy.formatBlockAnchorsAsMarkdown && innerText) {
const ancestorAnchor = this.#getAncestorAnchor(element);
const selector = this.#strategy.formatBlockAnchorSelector;
if (
ancestorAnchor?.href &&
!this.#linkedAnchors.has(ancestorAnchor) &&
(!selector || ancestorAnchor.querySelector(selector))
) {
innerText = escapeMarkdownLink(innerText, ancestorAnchor.href);
this.#linkedAnchors.add(ancestorAnchor);
}
}
} else if (text?.nodeValue) {
innerText = text.nodeValue.trim();
}
if (innerText) {
if (node.documentGlobal) {
// Use whitespace behavior from the DOM.
this.#textContent += "\n" + innerText;
} else {
// Manually collapse whitespace since the DOM is not attached to a window.
// The behavior of innerText is different here, and whitespace does not
// automatically get collapsed.
this.#textContent = collapseWhitespace(innerText, this.#textContent);
}
}
}
/**
* Check if a block contains any inline anchors that should be formatted as markdown.
* Anchors that wrap block content are excluded since they will be handled by
* the block splitting strategy.
*
* @param {HTMLElement} element
* @returns {boolean}
*/
#hasInlineAnchors(element) {
if (element.nodeName === "A") {
return !this.#wrapsBlockContent(element);
}
const anchors = element.querySelectorAll("a");
for (const anchor of anchors) {
if (!this.#wrapsBlockContent(anchor)) {
return true;
}
}
return false;
}
/**
* Extract text from an element, formatting inline anchors as markdown.
* Uses a TreeWalker to traverse the content in document order without
* cloning or modifying the DOM.
*
* @param {HTMLElement} element
* @returns {string}
*/
#extractTextWithMarkdownLinks(element) {
// Handle the simple case where the element itself is an inline anchor
if (element.nodeName === "A" && !this.#wrapsBlockContent(element)) {
return this.#formatAnchorAsMarkdown(element);
}
const parts = [];
this.#walkAndExtract(element, parts);
// Normalize whitespace for clean output
return parts.join("").replace(WHITESPACE_REGEX, " ").trim();
}
/**
* Recursively walk the DOM and extract text with various formatting options.
* Handles both markdown link formatting and filtering.
*
* @param {Node} node
* @param {string[]} parts
*/
#walkAndExtract(node, parts) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue ?? "";
if (text) {
parts.push(text);
}
return;
}
const element = asElement(node);
if (!element) {
return;
}
const filterSelector = this.#strategy.filterSelector;
if (filterSelector && element.matches(filterSelector)) {
return;
}
// If this is an anchor, check if it wraps block content
if (element.nodeName === "A") {
if (this.#wrapsBlockContent(element)) {
// Anchor wraps block content - extract children normally without markdown
for (const child of element.childNodes) {
this.#walkAndExtract(child, parts);
}
} else {
// Inline anchor - format as markdown
parts.push(this.#formatAnchorAsMarkdown(element));
}
return;
}
// For other elements, recurse into children
for (const child of element.childNodes) {
this.#walkAndExtract(child, parts);
}
}
/**
* Format an anchor element as markdown [text](url).
* Uses the resolved href property for the URL to get absolute URLs.
*
* @param {HTMLAnchorElement} anchor
* @returns {string}
*/
#formatAnchorAsMarkdown(anchor) {
// Normalize whitespace in link text for clean markdown output
// e.g., <a>Some \n text</a> becomes [Some text](url)
let linkText = (anchor.textContent ?? "")
.replace(WHITESPACE_REGEX, " ")
.trim();
// For image-only anchors, use alt text if available
if (!linkText) {
const img = anchor.querySelector("img");
if (img) {
linkText = (img.alt ?? "").trim();
}
}
// No text means we can't produce meaningful markdown
if (!linkText) {
return "";
}
// Use anchor.href which provides the resolved (absolute) URL.
// Empty href resolves to the current document URL, which is valid.
const href = anchor.href;
if (!href) {
return linkText;
}
return escapeMarkdownLink(linkText, href);
}
/**
* Check if an anchor element wraps block-level content.
* Such anchors should not be formatted as markdown since their
* content will be extracted separately by the block splitting strategy.
* Checks recursively to handle cases like <a><span><div>...</div></span></a>.
*
* @param {Element} element
* @returns {boolean}
*/
#wrapsBlockContent(element) {
for (const child of element.childNodes) {
const childElement = asElement(child);
if (!childElement) {
continue;
}
if (getIsBlockLike(childElement)) {
return true;
}
// Recursively check inline children for nested block content
if (this.#wrapsBlockContent(childElement)) {
return true;
}
}
return false;
}
}
/**
* Extracts visible text content from the DOM.
* By default, this extracts content from the entire page.
*
* Callers may specify filters for the extracted text via
* the supported options @see {GetTextOptions}.
*
* @param {Document} document
* @param {HTMLElement} rootNode
* @param {GetTextOptions} options
*
* @returns {DOMExtractionResult}
*
* In-depth documentation:
*
* Webpages are complicated documents. There are many different semantic structures
* like <article>, aria controls or even specifications like schema.org. The DOMExtractor
* can use these as hints, but ultimately the goal is to extract the user visible text
* from a webpage in the same way it is presented to the user. Text in layout is done
* through inline elements that go through reflow within a block. The intent of this
* algorithm is to collect all of the blocks on the screen, and convert each block into
* a paragraph of plain text that is representative of the information that is displayed
* on the screen.
*
* For example:
*
* <article>
* <div>
* This <span>is an example</span> of a block with inline elements.
* </div>
* <span style="display: block">
* The <div style="display: inline">computed style</div> is respected for extraction.
* </span>
* <div style="display: none">
* Only visible text will be extracted.
* </div>
* </article>
*
* If extraction is run on this document you will get the following lines:
*
* ```
* This is an example of a block with inline elements.\n
* The computed style is respected for extraction.\n
* ```
*
* This text should be formatted in a way that a language model can infer the meaning
* of the page, and work efficiently with the returned structure. A user reads and
* understands the content of the page based on how it's displayed to them. Therefore
* a language model should get plain text that as closely resembles that.
*
* The DOMExtractor supports different modes to limit the amount of content, or provide
* only information that is in the viewport. Ultimately it should be able to take any
* type of request from things like the get_page_content tool call, and fulfill that
* request in an efficient way that returns content as much as possible as how a user
* would actually experience it once rendered to the page.
*
* This strategy differs from more traditional scraping methods, as the browser has
* access to the full styled page. We can measure the computed style of elements to
* determine visibility and the actually computed block status (e.g. "display: block"
* and "display: inline")
*/
export function extractTextFromDOM(document, rootNode, options) {
const context = new ExtractionContext(document, options);
subdivideAndExtractText(rootNode, context);
return {
text: context.textContent.trim(),
links: context.links,
canvases: context.canvases,
};
}
/**
* Tags excluded from text extraction.
*/
const CONTENT_EXCLUDED_TAGS = new Set([
// TODO - We should add this and write some tests.
"CODE",
// The following are deprecated tags.
"DIR",
"APPLET",
// The following are embedded elements, and are not supported (yet).
"MATH",
"EMBED",
"OBJECT",
"IFRAME",
// This is an SVG tag that can contain arbitrary XML, ignore it.
"METADATA",
// These are elements that are treated as opaque by Firefox which causes their
// innerHTML property to be just the raw text node behind it. Any text that is sent as
// HTML must be valid, and there is no guarantee that the innerHTML is valid.
"NOSCRIPT",
"NOEMBED",
"NOFRAMES",
// Do not parse the HEAD tag.
"HEAD",
// These are not user-visible tags.
"STYLE",
"SCRIPT",
"TEMPLATE",
]);
const CONTENT_EXCLUDED_NODE_SELECTOR = [...CONTENT_EXCLUDED_TAGS].join(",");
/**
* Get the ShadowRoot from the chrome-only openOrClosedShadowRoot API.
* This allows for extracting the content from WebComponents, which is not
* normally feasible in non-privileged contexts.
*
* @param {Node} node
*
* @returns {ShadowRoot | null}
*/
function getShadowRoot(node) {
return asElement(node)?.openOrClosedShadowRoot ?? null;
}
/**
* Escape brackets and parentheses in link text, and parentheses in URL for valid markdown
*
* @param {string} text
* @param {string} url
* @returns {string}
*/
function escapeMarkdownLink(text, url) {
const escapedText = text.replace(MARKDOWN_TEXT_ESCAPE_REGEX, "\\$&");
const escapedHref = url
.replace(OPEN_PAREN_REGEX, "%28")
.replace(CLOSE_PAREN_REGEX, "%29");
return `[${escapedText}](${escapedHref})`;
}
/**
* Returns the extraction strategy for the given URL.
*
* @param {URL | null} url
* @returns {ExtractionStrategy}
*/
function getStrategyForUrl(url) {
if (!url) {
return DEFAULT_STRATEGY;
}
const { hostname, pathname, searchParams } = url;
// Google search result page strategy:
// Filter out <cite> elements which contain URL breadcrumbs like
// "https://www.example.com > path > to > page" that confuse LLMs.
// Only format block anchors as markdown if they contain a cite element.
const matchedGoogleDomains =
lazy.SearchStaticData.getAlternateDomains(hostname);
const isGoogleSearch =
matchedGoogleDomains.length &&
pathname === "/search" &&
searchParams.has("q");
if (isGoogleSearch) {
return GOOGLE_SEARCH_STRATEGY;
}
return DEFAULT_STRATEGY;
}
/**
* Determines if a node is ready for text extraction, or if it should be subdivided
* further. Rejects nodes that the site strategy filters out, so the entire subtree
* is skipped without needing to check ancestors. It doesn't check if the node has
* already been processed. This is done at the block level.
*
* @param {Node} node
* @param {ExtractionContext} context
* @returns {number} - NodeFilter acceptance status.
*/
function determineBlockStatus(node, context) {
if (!node) {
return NodeFilter.FILTER_REJECT;
}
if (context.shouldFilterNode(node)) {
return NodeFilter.FILTER_REJECT;
}
if (getShadowRoot(node)) {
return NodeFilter.FILTER_ACCEPT;
}
const canvasElement = asElement(node);
if (canvasElement?.tagName === "CANVAS") {
return NodeFilter.FILTER_ACCEPT;
}
if (isExcludedNode(node)) {
// This is an explicit.
return NodeFilter.FILTER_REJECT;
}
if (
containsExcludedNode(node, CONTENT_EXCLUDED_NODE_SELECTOR) &&
!hasNonWhitespaceTextNodes(node)
) {
// Skip this node, and dig deeper into its tree to cut off smaller pieces to extract.
return NodeFilter.FILTER_SKIP;
}
if (nodeNeedsSubdividing(node)) {
// Skip this node, and dig deeper into its tree to cut off smaller pieces
// to extract. It is presumed to be a wrapper of block elements.
return NodeFilter.FILTER_SKIP;
}
// This textContent call is fairly expensive.
if (!node.textContent?.trim().length) {
// Check if this is an anchor with an image.
// Accept these anchors so their links are captured, even without alt text.
const anchorElement = asElement(node);
if (anchorElement?.nodeName === "A") {
const img = anchorElement.querySelector("img");
if (img) {
return NodeFilter.FILTER_ACCEPT;
}
}
// Do not use subtrees that are empty of text.
return !node.hasChildNodes()
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_SKIP;
}
// This node can be treated as entire block and is ready for text extraction.
return NodeFilter.FILTER_ACCEPT;
}
/**
* Determine if this element is an inline element or a block element.
*
* @param {Node} node
* @returns {boolean}
*/
function nodeNeedsSubdividing(node) {
const element = asElement(node);
if (!element) {
// Only elements need to be further subdivided.
return false;
}
for (let childNode of element.childNodes) {
if (!childNode) {
continue;
}
switch (childNode.nodeType) {
case Node.TEXT_NODE: {
// Keep checking for more inline or text nodes.
continue;
}
case Node.ELEMENT_NODE: {
if (getIsBlockLike(childNode)) {
// This node is a block node, so it needs further subdividing.
return true;
} else if (nodeNeedsSubdividing(childNode)) {
// This non-block-like node may contain other block-like nodes.
return true;
}
// Keep checking for more inline or text nodes.
continue;
}
default: {
return true;
}
}
}
return false;
}
/**
* Returns true if a node is hidden based on factors such as collapsed state and
* computed style, otherwise false.
*
* @param {Node} node
* @returns {boolean}
*/
function isNodeHidden(node) {
if (!node.documentGlobal) {
// This node is not actually connected to a live browser context, so we can't
// determine if it's hidden or not. This can happen for a DOMParser document.
return false;
}
const element = getHTMLElementForStyle(node);
if (!element) {
// If we cannot get an HTMLElement to check visibility, we should not
// consider the node hidden. This can happen with cross-compartment
// elements where HTMLElement.isInstance fails.
return false;
}
// This is a cheap and easy check that will not compute style or force reflow.
if (element.hidden) {
// The element is explicitly hidden.
return true;
}
// Handle open/closed <details> elements. This will also not compute style or force reflow.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details
if (
// The element is within a closed <details>
element.closest("details:not([open])") &&
// The element is not part of the <summary> of the <details>, which is always visible, even when closed.
!element.closest("summary")
) {
// The element is within a closed <details> and is not part of the <summary>, therefore it is not visible.
return true;
}
// This forces reflow, which has a performance cost, but this is also what JQuery uses for its :hidden and :visible.
// https://github.com/jquery/jquery/blob/bd6b453b7effa78b292812dbe218491624994526/src/css/hiddenVisibleSelectors.js#L1-L10
if (
!(
element.offsetWidth ||
element.offsetHeight ||
element.getClientRects().length
)
) {
return true;
}
// The element may still have a zero-sized bounding client rectangle.
const boundingClientRect = element.getBoundingClientRect();
if (
boundingClientRect &&
(boundingClientRect.width === 0 || boundingClientRect.height === 0)
) {
return true;
}
const { documentGlobal } = element;
if (!documentGlobal) {
// We cannot compute the style without documentGlobal, so we will assume it is not visible.
return true;
}
// This flushes the style, which is a performance cost.
const style = documentGlobal.getComputedStyle(element);
if (!style) {
// We were unable to compute the style, so we will assume it is not visible.
return true;
}
// This is an issue with the DOM library generation.
const { display, visibility, opacity } = style;
return (
display === "none" ||
visibility === "hidden" ||
visibility === "collapse" ||
opacity === "0"
);
}
/**
* @param {Node} node
*/
function isExcludedNode(node) {
// Property access be expensive, so destructure required properties so they are
// not accessed multiple times.
const { nodeType } = node;
if (nodeType === Node.TEXT_NODE) {
// Text nodes are never excluded.
return false;
}
const element = asElement(node);
if (!element) {
// Only elements and and text nodes should be considered.
return true;
}
const { nodeName } = element;
if (CONTENT_EXCLUDED_TAGS.has(nodeName.toUpperCase())) {
// SVG tags can be lowercased, so ensure everything is uppercased.
// This is an excluded tag.
return true;
}
return false;
}
/**
* Like `#isExcludedNode` but looks at the full subtree. Used to see whether
* we can consider a subtree, or whether we should split it into smaller
* branches first to try to exclude more of the content.
*
* @param {Node} node
* @param {string} excludedNodeSelector
*
* @returns {boolean}
*/
function containsExcludedNode(node, excludedNodeSelector) {
return Boolean(asElement(node)?.querySelector(excludedNodeSelector));
}
/**
* Test whether any of the direct child text nodes of are non-whitespace text nodes.
*
* For example:
* - `<p>test</p>`: yes
* - `<p> </p>`: no
* - `<p><b>test</b></p>`: no
*
* @param {Node} node
*
* @returns {boolean}
*/
function hasNonWhitespaceTextNodes(node) {
if (node.nodeType !== Node.ELEMENT_NODE) {
// Only check element nodes.
return false;
}
for (const child of node.childNodes) {
const textNode = asTextNode(child);
if (textNode) {
if (!textNode.textContent?.trim()) {
// This is just whitespace.
continue;
}
// A text node with content was found.
return true;
}
}
// No text nodes were found.
return false;
}
/**
* Start walking down through a node's subtree and decide which nodes to extract content
* from. This first node is the root of the page.
*
* The nodes go through a process of subdivision until an appropriate sized chunk
* of inline text can be found.
*
* @param {Node} node
* @param {ExtractionContext} context
*/
function subdivideAndExtractText(node, context) {
if (context.shouldStopExtraction()) {
return;
}
switch (determineBlockStatus(node, context)) {
case NodeFilter.FILTER_REJECT: {
// This node is rejected as it shouldn't be used for text extraction.
return;
}
// Either a shadow host or a block element
case NodeFilter.FILTER_ACCEPT: {
const shadowRoot = getShadowRoot(node);
if (shadowRoot) {
processSubdivide(shadowRoot, context);
} else {
context.extractLinksFromBlock(node);
context.extractCanvasesFromBlock(node);
context.maybeAppendTextContent(node);
}
break;
}
case NodeFilter.FILTER_SKIP: {
// This node may have text to extract, but it needs to be subdivided into smaller
// pieces. Create a TreeWalker to walk the subtree, and find the subtrees/nodes
// that contain enough inline elements to extract.
// Only check if this node itself is an anchor (for anchors wrapping block content).
// Don't scan descendants here - they'll be processed when child blocks are accepted.
context.addLinkIfAnchor(node);
processSubdivide(node, context);
break;
}
}
}
/**
* Add qualified nodes to have their text content extracted by recursively walking
* through the DOM tree of nodes, including elements in the Shadow DOM.
*
* @param {Node} node
* @param {ExtractionContext} context
*/
function processSubdivide(node, context) {
if (context.shouldStopExtraction()) {
return;
}
const { ownerDocument } = node;
if (!ownerDocument) {
return;
}
// This iterator will contain each node that has been subdivided enough to have its
// text extracted.
const nodeIterator = ownerDocument.createTreeWalker(
node,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
n => determineBlockStatus(n, context)
);
let currentNode;
while ((currentNode = nodeIterator.nextNode())) {
const shadowRoot = getShadowRoot(currentNode);
if (shadowRoot) {
processSubdivide(shadowRoot, context);
} else {
context.extractLinksFromBlock(currentNode);
context.extractCanvasesFromBlock(currentNode);
context.maybeAppendTextContent(currentNode);
}
if (context.shouldStopExtraction()) {
return;
}
}
}
/**
* Returns an iterator of a node's ancestors.
*
* @param {Node} node
*
* @yields {Node}
*/
function* getAncestorsIterator(node) {
const document = node.ownerDocument;
if (!document) {
return;
}
for (
let parent = node.parentNode;
parent && parent !== document.documentElement;
parent = parent.parentNode
) {
yield parent;
}
}
/**
* This list is not really exhaustive, as it's just covering common block elements that
* can be used in reader mode.
*/
// prettier-ignore
const blockLikeElements = new Set([
"ARTICLE", "ASIDE", "BLOCKQUOTE", "BODY", "CAPTION", "COL", "COLGROUP", "DD", "DETAILS",
"DIALOG", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", "FOOTER", "FORM",
"H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HGROUP", "HR", "HTML", "LEGEND", "LI",
"MAIN", "NAV", "OL", "P", "PRE", "SECTION", "TABLE", "TBODY", "TD", "TFOOT", "TH",
"THEAD", "TR", "UL",
]);
/**
* Reads the elements computed style and determines if the element is a block-like
* element or not. Every element that lays out like a block should be used as a unit
* for text extraction.
*
* @param {Node} node
* @returns {boolean}
*/
function getIsBlockLike(node) {
const element = asElement(node);
if (!element) {
return false;
}
const { documentGlobal } = element;
if (!documentGlobal) {
// This root node is detached from a window, and so there is no computed style.
// Just use the assumed style.
return blockLikeElements.has(element.tagName);
}
if (element.namespaceURI === "http://www.w3.org/2000/svg") {
// SVG elements will report as inline, but there is no block layout in SVG.
// Treat every SVG element as being block so that every node will be subdivided.
return true;
}
/** @type {Record<string, string>} */
// @ts-expect-error - This is a workaround for the CSSStyleDeclaration not being indexable.
const style = documentGlobal.getComputedStyle(element) ?? { display: null };
return style.display !== "inline" && style.display !== "none";
}
/**
* Use TypeScript to determine if the Node is an Element.
*
* @param {Node | null | undefined} node
* @returns {Element | null}
*/
function asElement(node) {
if (node?.nodeType === Node.ELEMENT_NODE) {
return /** @type {HTMLElement} */ (node);
}
return null;
}
/**
* Use TypeScript to determine if the Node is an Element.
*
* @param {Node | null} node
*
* @returns {Text | null}
*/
function asTextNode(node) {
if (node?.nodeType === Node.TEXT_NODE) {
return /** @type {Text} */ (node);
}
return null;
}
/**
* Use TypeScript to determine if the Node is an HTMLElement.
*
* @param {Node | null} node
*
* @returns {HTMLElement | null}
*/
function asHTMLElement(node) {
if (HTMLElement.isInstance(node)) {
return node;
}
return null;
}
/**
* This function returns the correct element to determine the
* style of node.
*
* @param {Node} node
*
* @returns {HTMLElement | null}
*/
function getHTMLElementForStyle(node) {
const element = asHTMLElement(node);
if (element) {
return element;
}
if (node.parentElement) {
return asHTMLElement(node.parentElement);
}
// For cases like text node where its parent is ShadowRoot,
// we'd like to use flattenedTreeParentNode
if (node.flattenedTreeParentNode) {
return asHTMLElement(node.flattenedTreeParentNode);
}
// If the text node is not connected or doesn't have a frame.
return null;
}
/**
* Ensure whitespace isn't repeated in the text. This algorithm maintains at most 2
* newlines in some whitespace, or 1 whitespace character. Only "\n" and " " are retained.
* This is similar to the whitespace collapsing behavior of rendered HTML. Note that this
* algorithm ignores Unicode whitespace characters, which are a larger set of potential
* characters.
*
* https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Text/Whitespace
*
* So:
* "\t\n \t" => "\n"
* "\t\n \t\n\n\n" => "\n\n"
* "example text" => "example text"
* "\n\r" => ""
*
* @param {string} currentText
* @param {string} [previousText]
* @returns {string}
*/
export function collapseWhitespace(currentText, previousText = "") {
// Find the lastWhitespaceIndex in the previousText.
let lastWhitespaceIndex;
for (
lastWhitespaceIndex = previousText.length;
lastWhitespaceIndex > 0;
lastWhitespaceIndex--
) {
const ch = previousText[lastWhitespaceIndex - 1];
if (ch != " " && ch != "\n" && ch != "\t" && ch != "\r") {
break;
}
}
// Collect the trailling whitespace from the previousText.
let trailingWhitespace = previousText.slice(
lastWhitespaceIndex,
previousText.length
);
// Move the trailing whitespace from the previousText to the currentText so that
// the whitespace collapses correctly.
const prefixText = previousText.slice(0, lastWhitespaceIndex);
const postfixText = trailingWhitespace + currentText;
let collapsedText = "";
let prevWasWhitespace = !!prefixText;
let newLinesCount = prefixText ? 1 : 0;
for (let i = 0; i < postfixText.length; i++) {
const ch = postfixText[i];
if (
// Is this a whitespace character that is used in HTML whitespace collapsing?
ch === " " ||
ch === "\n" ||
ch === "\t" ||
ch === "\r"
) {
// Remember that there was whitespace and count the newlines.
if (ch === "\n") {
newLinesCount++;
}
prevWasWhitespace = true;
} else {
// There is a character that needs to be added. Also add any whitespace that
// was encountered.
if (prevWasWhitespace) {
// Add the collapsed version of the whitespace.
if (newLinesCount == 0) {
collapsedText += " ";
} else if (newLinesCount == 1) {
collapsedText += "\n";
} else {
collapsedText += "\n\n";
}
// Reset the whitespace tracking varaibles.
newLinesCount = 0;
prevWasWhitespace = false;
}
// Add the next character.
collapsedText += ch;
}
}
if (prevWasWhitespace) {
// Add the collapsed version of the whitespace.
if (newLinesCount == 0) {
collapsedText += " ";
} else if (newLinesCount == 1) {
collapsedText += "\n";
} else {
collapsedText += "\n\n";
}
}
return prefixText + collapsedText;
}
[zur Elbe Produktseite wechseln0.68Quellennavigators]
|
2026-08-25
|
|
|
|
|
Neuigkeiten |
| Aktuelles |
| Motto des Tages |
|
Open Source Software |
|
|
|
Jenseits des Üblichen ....
|
|
Besucherstatistik |
|
|
| Statistik |
| #Sources=141584 |
| #Domains=738142 |
|
|