Files
webmail/lib/structured-mail/extract.ts
T

124 lines
5.1 KiB
TypeScript

/**
* Pull schema.org data out of a mail's raw HTML: JSON-LD blocks and
* microdata. The HTML is parsed into a detached document (DOMParser runs no
* scripts and loads nothing) that is never attached to the page; the sanitizer
* that renders the mail is a separate path and isn't touched.
*/
import { parseLenientJson } from './json-repair';
export type RawItem = Record<string, unknown>;
export interface ExtractedItem {
item: unknown;
source: 'json-ld' | 'microdata';
}
const MAX_JSONLD_BLOCKS = 50;
const MAX_MICRODATA_ITEMS = 500;
const MAX_MICRODATA_DEPTH = 12;
const MAX_HTML_LENGTH = 8_000_000;
/** Cheap test that a body may hold markup this feature reads. */
const TYPE_HINT = /Reservation|ParcelDelivery|Order/;
const SCHEMA_ITEMTYPE = /^https?:\/\/(?:www\.)?schema\.org\/([A-Za-z]+)\/?$/i;
/**
* Mail clients mark what they quote or forward with these containers;
* markup inside them belongs to someone else's mail.
*/
const QUOTED_CONTAINERS = 'blockquote, .gmail_quote, .moz-forward-container, #appendonsend, #divRplyFwdMsg';
export function defaultParseHtml(html: string): Document {
return new DOMParser().parseFromString(html, 'text/html');
}
export function mayContainStructuredData(html: string): boolean {
return TYPE_HINT.test(html);
}
/**
* Elements that sit inside a quoted or forwarded part: inside one of the
* containers, or after Outlook's `#divRplyFwdMsg` separator (Outlook puts
* the forwarded message after it, not inside it).
*/
function isQuoted(element: Element, doc: Document): boolean {
if (element.closest(QUOTED_CONTAINERS)) return true;
const separator = doc.getElementById('divRplyFwdMsg');
return !!separator && !!(separator.compareDocumentPosition(element) & 4 /* FOLLOWING */);
}
function itemTypes(element: Element): string[] {
return (element.getAttribute('itemtype') ?? '')
.split(/\s+/)
.map((value) => SCHEMA_ITEMTYPE.exec(value)?.[1])
.filter((value): value is string => !!value);
}
function propertyValue(element: Element, depth: number): unknown {
if (element.hasAttribute('itemscope')) return microdataItem(element, depth + 1);
const content = element.getAttribute('content');
if (content !== null) return content.trim();
const tag = element.tagName.toLowerCase();
const attr = (name: string) => (element.getAttribute(name) ?? '').trim();
switch (tag) {
case 'a': case 'link': case 'area': return attr('href');
case 'img': case 'audio': case 'video': case 'source': case 'embed': case 'iframe': case 'track': return attr('src');
case 'object': return attr('data');
case 'time': return element.hasAttribute('datetime') ? attr('datetime') : (element.textContent ?? '').trim();
case 'data': case 'meter': return attr('value');
default: return (element.textContent ?? '').replace(/\s+/g, ' ').trim();
}
}
/**
* One microdata item: properties are descendants with `itemprop` whose
* nearest `itemscope` ancestor is this item. A space-separated itemprop gives
* the value to each name; a repeated name becomes an array.
*/
function microdataItem(scope: Element, depth: number): RawItem | undefined {
if (depth > MAX_MICRODATA_DEPTH) return undefined;
const item: RawItem = {};
const types = itemTypes(scope);
if (types.length > 0) item['@type'] = types.length === 1 ? types[0] : types;
const add = (name: string, value: unknown) => {
if (value === undefined || value === '') return;
const existing = item[name];
if (existing === undefined) item[name] = value;
else if (Array.isArray(existing)) existing.push(value);
else item[name] = [existing, value];
};
for (const element of Array.from(scope.querySelectorAll('[itemprop]'))) {
const owner = element.parentElement?.closest('[itemscope]');
if (owner !== scope) continue;
const value = propertyValue(element, depth);
for (const name of (element.getAttribute('itemprop') ?? '').split(/\s+/).filter(Boolean)) add(name, value);
}
return item;
}
export function extractStructuredData(
html: string,
parseHtml: (html: string) => Document = defaultParseHtml,
): ExtractedItem[] {
if (!html || html.length > MAX_HTML_LENGTH || !mayContainStructuredData(html)) return [];
const doc = parseHtml(html);
const found: ExtractedItem[] = [];
const scripts = Array.from(doc.querySelectorAll('script')).filter(
(script) => (script.getAttribute('type') ?? '').trim().toLowerCase() === 'application/ld+json',
);
for (const script of scripts.slice(0, MAX_JSONLD_BLOCKS)) {
if (isQuoted(script, doc)) continue;
const value = parseLenientJson(script.textContent ?? '');
if (value !== undefined && value !== null) found.push({ item: value, source: 'json-ld' });
}
const tops = Array.from(doc.querySelectorAll('[itemscope]')).filter((element) => !element.hasAttribute('itemprop'));
for (const scope of tops.slice(0, MAX_MICRODATA_ITEMS)) {
// Only items typed on schema.org count; an untyped or foreign item is noise.
if (itemTypes(scope).length === 0 || isQuoted(scope, doc)) continue;
const item = microdataItem(scope, 0);
if (item) found.push({ item: { '@context': 'https://schema.org', ...item }, source: 'microdata' });
}
return found;
}