diff --git a/.gitignore b/.gitignore index b00cf733..ecfa0d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ test-results/ playwright-report/ .playwright-mcp/ .tmp/ +.scratch/ apps/reader/public/downloads/ apps/extension/release/ artifacts/ diff --git a/apps/extension/src/page-annotations.test.ts b/apps/extension/src/page-annotations.test.ts index 8c1869c5..c56fc590 100644 --- a/apps/extension/src/page-annotations.test.ts +++ b/apps/extension/src/page-annotations.test.ts @@ -293,7 +293,7 @@ describe("drawing on the page", () => { }); describe("highlights made on the saved reading copy", () => { - // The reading copy is Readability's extraction, which drops text the live page shows. + // The reading copy is an article extraction, which drops text the live page shows. const live = `Essay

A headline about things

The first paragraph has a sentence that ends here, with commas, and more commas, for scoring.

diff --git a/apps/reader/src/use-web-capture.ts b/apps/reader/src/use-web-capture.ts index affa665e..67577344 100644 --- a/apps/reader/src/use-web-capture.ts +++ b/apps/reader/src/use-web-capture.ts @@ -54,7 +54,7 @@ export function useWebCapture( setOffer(null); setNotice(null); try { - // Readability, the metadata extractors and PDFium load only when a source is added. + // The article extractor, metadata extractors and PDFium load only when a source is added. const [flow, { readerSourceAdditionServices }] = await Promise.all([ import("./source-addition-flow.js"), import("./source-addition-services.js"), diff --git a/docs/extension-capture-improvements.md b/docs/extension-capture-improvements.md index 075f1ecd..c4ee719f 100644 --- a/docs/extension-capture-improvements.md +++ b/docs/extension-capture-improvements.md @@ -164,7 +164,7 @@ Selection capture targets the top-level page, not cross-origin embedded frames o shadow roots (capture includes it; the live selection index does not). Chrome's PDF viewer does not expose selections, so PDFs are highlighted in Reader. Live highlights are a projection, not the canonical annotation. Same-panel retry identity is not cross-window atomic deduplication. Missing -passages in Readability's saved copy require opening that copy in Reader and selecting the +passages in the extracted reading copy require opening that copy in Reader and selecting the intended text there; Reader will not silently substitute the live page or overwrite the archive. The service worker opens its own Connect session for page status; concurrent use with the panel relies on the SDK's grants tolerating two sessions in one extension origin. diff --git a/docs/web-capture-extraction.md b/docs/web-capture-extraction.md new file mode 100644 index 00000000..929c5908 --- /dev/null +++ b/docs/web-capture-extraction.md @@ -0,0 +1,64 @@ +# Web capture extraction + +New captures in Reader and the MV3 extension share `webCaptureImport` in +`packages/web-capture`. It loads the pinned `defuddle@0.19.5` browser core only +when saving, never `defuddle/full` or `defuddle/node`. The browser bundle is +self-contained; npm also installs CLI/optional dependencies, but those are not +part of either application's extractor chunk. + +## Preserve the reading evidence + +Defuddle runs synchronously on a clone of the captured document. Our scholarly +metadata, JSON-LD and publisher-tag pipeline still reads the untouched original; +publisher authors retain their existing precedence. Output remains HTML passed +through `prepareHtmlDocument`, with a separate unchanged archive. + +We disable standardization, content-pattern removal, partial-selector removal, +hidden-element removal and small-image removal. The comparison found real losses +with the defaults: truncated Wikipedia sections, code figures and captions in +the Rust Book, arXiv tables/short summaries, and hidden sidenotes. These settings +prefer complete evidence and original citation/code/math structure over extra +cleanup. Exact-selector cleanup and content scoring remain enabled. Some +boilerplate and duplicate math representations can consequently survive. + +As a last-resort safety check, an extraction with fewer than half the page's +words (at least 400 page words, excluding navigation, header/footer, scripts, +styles, templates and explicitly hidden elements) tries the separately lazy, +pinned Readability 0.6.0. It switches only if Readability recovers more than both +1.5 times the extracted words and 200 additional words. This is a truncation +heuristic, not proof that every passage survived, and does not detect small losses. + +`capture.extractor` records `defuddle@0.19.5` or `mozilla-readability@0.6.0` on the +new source note. The field is optional for historical captures. There is no +backfill, opening-time extraction, or migration of existing `readable.html`: +replacing that text would invalidate its highlights. + +## Network audit (0.19.5) + +The installed browser-core sources were searched for `fetch`, XHR and other +network transports. All extractor HTTP calls are in these async paths: + +| Extractor | Network paths | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| X oEmbed | FxTwitter API (including articles), then Twitter publish/oEmbed | +| Reddit | old.reddit HTML, then the Reddit Atom feed (`/.rss`) | +| YouTube | InnerTube player (Android, iOS, web clients), next/chapters, caption-track XML URLs; async transcript-panel interaction can also initiate page requests | +| Bilibili | view metadata, player WBI/v2 and v2 variants, subtitle-track URLs | +| C2 wiki | `c2.com/wiki/remodel/pages/` JSON | + +They are reachable through `parseAsync()` or `fetchAsyncVariables()`, neither of +which we call. We additionally set `useAsync: false` and supply a throwing fetch +override rather than letting any extractor use global fetch. The Node/CLI +`fetch.js` has direct fetch/proxy HTTP transports, but is not in the browser +entry point. No extractor XHR calls were found. + +Tests spy on fetch/XHR for all five async-extractor families, including empty +local shells and inline YouTube caption data. Resource/script sanitization and +original-document immutability are also covered. A separate real Chromium check +observed zero external requests while extracting the 39-page comparison corpus, +the regression fixtures and empty X/Reddit/YouTube/Bilibili/C2 shells. Happy DOM's +iframe loading is disabled in the unit tests to model browsers' inert parsed +documents; Chromium also checked that generated embeds do not initiate requests. + +When upgrading Defuddle, repeat the source audit and comparison rather than +assuming the version's network or cleanup behavior remains unchanged. diff --git a/eslint.config.js b/eslint.config.js index eb9ae9d1..d37d6631 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,7 @@ export default tseslint.config( "**/coverage/**", "**/node_modules/**", "**/.wrangler/**", + "**/.scratch/**", "apps/capacitor/android/**", "apps/capacitor/ios/**", ], diff --git a/packages/connect/src/source-frontmatter.ts b/packages/connect/src/source-frontmatter.ts index e9df00e1..4c2fdca4 100644 --- a/packages/connect/src/source-frontmatter.ts +++ b/packages/connect/src/source-frontmatter.ts @@ -77,6 +77,7 @@ function captureFields( captured_at: capture.retrievedAt, submitted_url: capture.submittedUrl, canonical_url: capture.canonicalUrl, + ...(capture.extractor ? { extractor: capture.extractor } : {}), }, }; } diff --git a/packages/connect/src/source-imports-web-capture.test.ts b/packages/connect/src/source-imports-web-capture.test.ts index 604deb6e..a8d55995 100644 --- a/packages/connect/src/source-imports-web-capture.test.ts +++ b/packages/connect/src/source-imports-web-capture.test.ts @@ -55,66 +55,70 @@ describe("ConnectSourceImportRepository web capture provenance", () => { ); }); - it("preserves raw and readable web representations with capture metadata", async () => { - const create = vi.fn(() => Promise.resolve(success(recordDocument()))); - const upload = vi.fn((path: string) => - Promise.resolve( - fileDescriptor({ - fileId: path.includes("archive") ? "file-archive" : "file-readable", - path, - mediaType: "text/html", - }), - ), - ); - const repository = new ConnectSourceImportRepository( - { create } as unknown as ReaderConnectClient, - { upload }, - ); + it.each([undefined, "defuddle@0.19.5", "mozilla-readability@0.6.0"])( + "preserves raw and readable web representations with extractor %s", + async (extractor) => { + const create = vi.fn(() => Promise.resolve(success(recordDocument()))); + const upload = vi.fn((path: string) => + Promise.resolve( + fileDescriptor({ + fileId: path.includes("archive") ? "file-archive" : "file-readable", + path, + mediaType: "text/html", + }), + ), + ); + const repository = new ConnectSourceImportRepository( + { create } as unknown as ReaderConnectClient, + { upload }, + ); - await repository.commitFile(webCapturePlan()); + await repository.commitFile(webCapturePlan(extractor)); - expect(upload.mock.calls.map(([path]) => path)).toEqual([ - "files/reader/src_import/example.archive.html", - "files/reader/src_import/example.readable.html", - ]); - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ - frontmatter: expect.objectContaining({ - kind: "webpage", - url: "https://example.com/canonical", - original_url: "https://example.com/submitted", - authors: ["Jane Example"], - published: "2026-08-01", - description: "A captured essay.", - language: "en", - site: "Example Review", - capture: { - submitted_url: "https://example.com/submitted", - canonical_url: "https://example.com/canonical", - captured_at: "2026-08-10T11:59:00.000Z", - method: "url", - application: "dev.mdbase.reader", - }, - documents: [ - expect.objectContaining({ - role: "primary", - file_id: "file-readable", - derived_from_file_id: "file-archive", - }), - expect.objectContaining({ - role: "archive", - file_id: "file-archive", - origin_url: "https://example.com/canonical", - retrieved_at: "2026-08-10T11:59:00.000Z", - }), - ], + expect(upload.mock.calls.map(([path]) => path)).toEqual([ + "files/reader/src_import/example.archive.html", + "files/reader/src_import/example.readable.html", + ]); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + frontmatter: expect.objectContaining({ + kind: "webpage", + url: "https://example.com/canonical", + original_url: "https://example.com/submitted", + authors: ["Jane Example"], + published: "2026-08-01", + description: "A captured essay.", + language: "en", + site: "Example Review", + capture: { + submitted_url: "https://example.com/submitted", + canonical_url: "https://example.com/canonical", + captured_at: "2026-08-10T11:59:00.000Z", + method: "url", + application: "dev.mdbase.reader", + ...(extractor ? { extractor } : {}), + }, + documents: [ + expect.objectContaining({ + role: "primary", + file_id: "file-readable", + derived_from_file_id: "file-archive", + }), + expect.objectContaining({ + role: "archive", + file_id: "file-archive", + origin_url: "https://example.com/canonical", + retrieved_at: "2026-08-10T11:59:00.000Z", + }), + ], + }), }), - }), - ); - }); + ); + }, + ); }); -function webCapturePlan(): PlannedSourceFileImport { +function webCapturePlan(extractor?: string): PlannedSourceFileImport { const base = plan(); const representation = base.representations[0]!; return { @@ -131,6 +135,7 @@ function webCapturePlan(): PlannedSourceFileImport { submittedUrl: "https://example.com/submitted", canonicalUrl: "https://example.com/canonical", retrievedAt: dateTime("2026-08-10T11:59:00.000Z"), + ...(extractor ? { extractor } : {}), }, representations: [ { diff --git a/packages/core/src/application/import-source-file.test.ts b/packages/core/src/application/import-source-file.test.ts index 4aecfd93..9467aa4b 100644 --- a/packages/core/src/application/import-source-file.test.ts +++ b/packages/core/src/application/import-source-file.test.ts @@ -121,6 +121,7 @@ describe("importSourceFile web and duplicate safeguards", () => { submittedUrl: "https://example.com/story#part", canonicalUrl: "https://www.example.com/story", retrievedAt: dateTime("2026-08-10T11:59:00.000Z"), + extractor: "defuddle@0.19.5", }, archive: { name: "example-com.archive.html", @@ -136,6 +137,7 @@ describe("importSourceFile web and duplicate safeguards", () => { submittedUrl: "https://example.com/story", canonicalUrl: "https://www.example.com/story", retrievedAt: "2026-08-10T11:59:00.000Z", + extractor: "defuddle@0.19.5", }, representations: [ expect.objectContaining({ role: "primary", derivedFromRole: "archive" }), diff --git a/packages/core/src/application/source-import-ports.ts b/packages/core/src/application/source-import-ports.ts index d1c281a0..be5af474 100644 --- a/packages/core/src/application/source-import-ports.ts +++ b/packages/core/src/application/source-import-ports.ts @@ -56,6 +56,8 @@ export interface SourceCaptureProvenance { readonly submittedUrl: string; readonly canonicalUrl: string; readonly retrievedAt: DateTime; + /** Extractor used for this new reading copy; absent on older captures. */ + readonly extractor?: string; } export interface SourceCaptureArchive { diff --git a/packages/core/src/application/source-import-values.ts b/packages/core/src/application/source-import-values.ts index 547dd747..95321344 100644 --- a/packages/core/src/application/source-import-values.ts +++ b/packages/core/src/application/source-import-values.ts @@ -21,7 +21,13 @@ export function validateCapture( ): NonNullable { const submittedUrl = httpsUrl(capture.submittedUrl); const canonicalUrl = httpsUrl(capture.canonicalUrl); - return { submittedUrl, canonicalUrl, retrievedAt: capture.retrievedAt }; + const extractor = normalizeOptionalText(capture.extractor, 100); + return { + submittedUrl, + canonicalUrl, + retrievedAt: capture.retrievedAt, + ...(extractor ? { extractor } : {}), + }; } export function validateMetadata(metadata: SourceImportMetadata): SourceImportMetadata { diff --git a/packages/web-capture/package.json b/packages/web-capture/package.json index 9d60e0d5..7c0482fa 100644 --- a/packages/web-capture/package.json +++ b/packages/web-capture/package.json @@ -15,6 +15,7 @@ "dependencies": { "@mdbase-reader/core": "workspace:*", "@mdbase-reader/renderer-html": "workspace:*", - "@mozilla/readability": "^0.6.0" + "@mozilla/readability": "0.6.0", + "defuddle": "0.19.5" } } diff --git a/packages/web-capture/src/fixtures/README.md b/packages/web-capture/src/fixtures/README.md new file mode 100644 index 00000000..ee858fe9 --- /dev/null +++ b/packages/web-capture/src/fixtures/README.md @@ -0,0 +1,18 @@ +# Capture fixtures + +These small, original HTML pages reproduce structures observed in the public-page +comparison; they do not copy the articles' text. The full fetched corpus stays in +`.scratch/corpus` (gitignored). + +- `academic.html`: structures reduced from [Llama 3](https://arxiv.org/html/2407.21783v1) + and [DeepSeekMath](https://arxiv.org/html/2402.03300v2): short summary paragraphs, + MathML, table figures, captions, hidden footnotes, and conflicting + publisher/JSON-LD metadata. +- `code-guide.html`: structures reduced from the + [Rust Book ownership chapter](https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html): + code inside figures, diagram captions, and ordinary preformatted code. +- `encyclopedia.html`: structures reduced from + [CRISPR gene editing](https://en.wikipedia.org/wiki/CRISPR_gene_editing): + Wikipedia-style sections, a data table, and original-format citation anchors. + +Tests exercise the final sanitized reading copy, not just raw extractor output. diff --git a/packages/web-capture/src/fixtures/academic.html b/packages/web-capture/src/fixtures/academic.html new file mode 100644 index 00000000..bd76041b --- /dev/null +++ b/packages/web-capture/src/fixtures/academic.html @@ -0,0 +1,106 @@ + + + + Small structures, durable evidence + + + + + + + + + + + + + +
+

Small structures, durable evidence

+

+ We study a compact example whose paragraphs and evidence should survive extraction. A reader + needs every assertion, not only the longest paragraphs, to make useful and durable + annotations. +

+
    +
  • +

    + Language model pre-training. This short summary is substantive evidence rather than a + related article card. It explains the first stage of our experiment and belongs in the + reading copy. +

    +
  • +
  • +

    + Language model post-training. The second stage adds feedback to our experiment. Removing + this short summary would silently lose real content needed to understand the result. +

    +
  • +
+

+ The inline expression + x+1 + describes the next observation. See the accompanying measurements for exact values and the + note for the assumptions.1 +

+
+ A diagram of two observations +
Figure 1: Two observations share a common baseline.
+
+
+ + + + + + + + + + + + + + + + + + +
+ Table 1: Measured observations +
ObservationValue
Baseline measurement42
Follow-up measurement43
+
Measurements retain their row and column relationships.
+
+
def measure(x):
+    return x + 1
+
+

+ All structures above form part of this small article. Capturing them as HTML preserves the + relationship between prose, mathematical notation, code, and measured values for later + reading. +

+
    + +
+
+ + + + diff --git a/packages/web-capture/src/fixtures/code-guide.html b/packages/web-capture/src/fixtures/code-guide.html new file mode 100644 index 00000000..a1efb278 --- /dev/null +++ b/packages/web-capture/src/fixtures/code-guide.html @@ -0,0 +1,54 @@ + + + + Ownership guide + + + + +
+

Ownership guide

+

+ This guide explains how a small program owns a value and passes it to another function. The + examples are part of the explanation, and their captions carry information the reader may + need to highlight. +

+

+ In the first example we retain the complete program, including its main function. Some + documentation sites wrap code in figures, so those containers must not be mistaken for + decorative images and removed. +

+
+
fn main() {
+    let value = String::from("durable reading copy");
+    println!("{}", value);
+}
+
Listing 1: The value belongs to the main function.
+
+
+ The memory layout +
Figure 1: A pointer, a length, and a capacity identify this string.
+
+

+ After the call returns, the owning function remains responsible for the value. These + explanatory paragraphs connect the code to the diagram and should remain in their original + order without synthetic whitespace changes. +

+
fn length(value: &str) -> usize {
+    value.len()
+}
+

+ The helper borrows rather than takes ownership. This final statement completes the + explanation and provides a useful paragraph for checking that highlighting still locates the + exact text in the saved reading copy. +

+
+ +
Newsletter Subscribe Account
+ + diff --git a/packages/web-capture/src/fixtures/encyclopedia.html b/packages/web-capture/src/fixtures/encyclopedia.html new file mode 100644 index 00000000..7b589c17 --- /dev/null +++ b/packages/web-capture/src/fixtures/encyclopedia.html @@ -0,0 +1,69 @@ + + + + Durable evidence - Wikipedia + + + +
+

Durable evidence

+
+

+ Durable evidence is material preserved with its original context and structure. This + synthetic encyclopedia article demonstrates the relationships between source text, + references, and data tables that a reader must retain. +

+
+

Background

+

+ A reading copy supports later research when its paragraphs remain available. Extraction + must distinguish navigation from substantive passages, even when a passage is short or + includes several links to references. +

+

+ We preserve a citation exactly as captured[1] + so the footnote and its original anchor remain connected. Citations should not be + rewritten into new numbering schemes when the surrounding prose can be preserved. +

+
+
+

Measurements

+

+ Data tables provide a compact account of the observations. Each row belongs to a + different condition; extracting only the prose would remove important evidence even + though the remaining word count might appear sufficient. +

+ + + + + + + + + + + + + + +
+ Evidence by condition +
ConditionObservation
OriginalComplete source text
CapturedStable reading copy
+
+
+

References

+
    +
  1. + Back Example Author. A synthetic reference with enough + context to identify the evidence. +
  2. +
+
+
+
+
Privacy policy About Disclaimers
+ + diff --git a/packages/web-capture/src/web-capture-fallback.test.ts b/packages/web-capture/src/web-capture-fallback.test.ts new file mode 100644 index 00000000..6ad1f148 --- /dev/null +++ b/packages/web-capture/src/web-capture-fallback.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import { + WEB_CAPTURE_EXTRACTOR, + webCaptureImport, + type CapturedWebDocument, +} from "./web-capture.js"; + +const { output } = vi.hoisted(() => ({ output: { content: "

Only the opening survived.

" } })); +vi.mock("defuddle", () => ({ + default: class { + parse(): { readonly content: string; readonly title: string } { + return { ...output, title: "A clipped article" }; + } + }, +})); + +const paragraph = + "The preserved article discusses a reproducible observation and explains its implications for later research. Its evidence belongs in the reading copy so readers can select and annotate the full passage without losing important context. "; +const html = `A complete article

A complete article

${Array.from({ length: 20 }, (_, i) => `

Observation ${String(i)}. ${paragraph.repeat(2)}

`).join("")}
`; +const capture = (source: string): CapturedWebDocument => ({ + submittedUrl: "https://example.com/article", + canonicalUrl: "https://example.com/article", + retrievedAt: "2026-08-14T00:00:00.000Z", + html: source, +}); + +describe("short extraction safety fallback", () => { + it("recovers substantially more text with Readability and records the actual extractor", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(() => { + throw new Error("Network request"); + }); + try { + const result = await webCaptureImport(capture(html)); + expect(result.capture.extractor).toBe("mozilla-readability@0.6.0"); + expect(new TextDecoder().decode(result.bytes)).toContain("Observation 19."); + expect(new TextDecoder().decode(result.archive.bytes)).toBe(html); + expect(fetch).not.toHaveBeenCalled(); + } finally { + fetch.mockRestore(); + } + }); + + it("does not replace an ordinary short page", async () => { + const result = await webCaptureImport( + capture("Short note

A brief note.

"), + ); + expect(result.capture.extractor).toBe(WEB_CAPTURE_EXTRACTOR); + }); + + it("does not switch merely because the page has a large navigation menu", async () => { + const result = await webCaptureImport( + capture(`

A brief note.

`), + ); + expect(result.capture.extractor).toBe(WEB_CAPTURE_EXTRACTOR); + }); + + it("keeps Defuddle when Readability cannot recover substantially more", async () => { + const prior = output.content; + output.content = `

${paragraph.repeat(35)}

`; + try { + const result = await webCaptureImport( + capture(`${html}`), + ); + expect(result.capture.extractor).toBe(WEB_CAPTURE_EXTRACTOR); + } finally { + output.content = prior; + } + }); +}); diff --git a/packages/web-capture/src/web-capture.test.ts b/packages/web-capture/src/web-capture.test.ts index ab25a822..12fcec2b 100644 --- a/packages/web-capture/src/web-capture.test.ts +++ b/packages/web-capture/src/web-capture.test.ts @@ -1,7 +1,31 @@ // @vitest-environment happy-dom -import { describe, expect, it } from "vitest"; +// @vitest-environment-options {"settings":{"disableIframePageLoading":true}} +import { readFileSync } from "node:fs"; -import { webCaptureImport } from "./web-capture.js"; +import { htmlSelectionDraft, locateHtmlTarget } from "@mdbase-reader/renderer-html"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + WEB_CAPTURE_EXTRACTOR, + webCaptureImport, + type CapturedWebDocument, +} from "./web-capture.js"; + +const capture = ( + html: string, + canonicalUrl = "https://example.com/article", +): CapturedWebDocument => ({ + submittedUrl: canonicalUrl, + canonicalUrl, + retrievedAt: "2026-08-14T00:00:00.000Z", + html, +}); + +const fixture = (name: string): string => readFileSync(`src/fixtures/${name}.html`, "utf8"); +const readingDocument = (bytes: Uint8Array): Document => + new DOMParser().parseFromString(new TextDecoder().decode(bytes), "text/html"); + +afterEach(() => vi.restoreAllMocks()); describe("web capture extraction", () => { it("extracts the article and metadata while retaining a separate archive", async () => { @@ -22,6 +46,7 @@ describe("web capture extraction", () => { const archive = new TextDecoder().decode(result.archive.bytes); expect(result.title).toBe("A useful essay"); + expect(result.capture.extractor).toBe(WEB_CAPTURE_EXTRACTOR); expect(result.metadata).toMatchObject({ authors: ["Jane Example"], published: "2026-08-01", @@ -33,4 +58,137 @@ describe("web capture extraction", () => { expect(readable).not.toContain(" { + const html = fixture("academic"); + const parsed = new DOMParser().parseFromString(html, "text/html"); + const before = parsed.documentElement.outerHTML; + const result = await webCaptureImport(capture(html), { document: parsed }); + const readable = readingDocument(result.bytes); + expect(parsed.documentElement.outerHTML).toBe(before); + expect(new TextDecoder().decode(result.archive.bytes)).toBe(html); + expect(result.metadata).toMatchObject({ + authors: ["Jane Example", "Sam Researcher"], + published: "2026-08-01", + language: "en", + description: "Evidence survives without rewriting the captured document.", + }); + expect(result.scholarly).toMatchObject({ + doi: "10.1234/structures.2026", + citation: { + title: "Small structures, durable evidence", + publisher: "Example University", + "container-title": "Journal of Small Structures", + }, + }); + expect(readable.querySelector("math")?.textContent.replace(/\s+/gu, "")).toBe("x+1"); + expect(readable.querySelector("table caption")?.textContent).toContain("Measured observations"); + expect(readable.querySelectorAll("figcaption")).toHaveLength(2); + expect(readable.querySelector('a[href="#note-1"]')).not.toBeNull(); + expect(readable.querySelector("#note-1")?.textContent).toContain("baseline is held constant"); + expect(readable.body.textContent).toContain("Language model pre-training."); + expect(readable.body.textContent).toContain("Language model post-training."); + expect(readable.querySelector("pre code")?.textContent).toBe( + "def measure(x):\n return x + 1\n", + ); + }); + + it.each(["academic", "code-guide", "encyclopedia"])( + "keeps highlightable text and safe HTML in %s", + async (name) => { + const result = await webCaptureImport( + capture( + fixture(name), + name === "encyclopedia" ? "https://en.wikipedia.org/wiki/Durable_evidence" : undefined, + ), + ); + const readable = readingDocument(result.bytes); + expect(result.capture.extractor).toBe(WEB_CAPTURE_EXTRACTOR); + expect(readable.querySelector("script, nav, aside, iframe")).toBeNull(); + expect(readable.querySelector('[http-equiv="Content-Security-Policy"]')).not.toBeNull(); + document.body.innerHTML = readable.body.innerHTML; + const paragraph = document.querySelector("article p"); + if (!paragraph) { + throw new Error("Fixture paragraph is missing."); + } + const range = document.createRange(); + range.selectNodeContents(paragraph); + const draft = htmlSelectionDraft({ + document, + range, + href: "[[files/example.readable.html]]", + progression: 0.2, + }); + expect(draft).not.toBeNull(); + expect(locateHtmlTarget(document, draft!.target)?.toString()).toBe(range.toString().trim()); + if (name === "code-guide") { + expect(readable.querySelector("figure pre code")?.textContent).toContain( + 'let value = String::from("durable reading copy");', + ); + expect(readable.querySelectorAll("figcaption")).toHaveLength(2); + } + if (name === "encyclopedia") { + expect(readable.querySelector('a[href="#cite_note-1"]')?.textContent).toBe("[1]"); + expect(readable.querySelector("#cite_note-1")).not.toBeNull(); + expect(readable.querySelector("table")?.textContent).toContain("Complete source text"); + } + }, + ); +}); + +describe("offline capture extraction", () => { + it.each([ + "https://x.com/example/status/123456789", + "https://twitter.com/example/article/123456789", + "https://www.reddit.com/r/example/comments/abc123/example/", + "https://www.youtube.com/watch?v=abcdefghijk", + "https://www.bilibili.com/video/BV1xx411c7mD/", + "https://wiki.c2.com/?ExamplePage", + ])("never fetches missing content from %s", async (url) => { + const forbidden = (): never => { + throw new Error("Extraction made a network request."); + }; + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(forbidden); + const windowFetch = vi.spyOn(window, "fetch").mockImplementation(forbidden); + const xhr = vi.spyOn(XMLHttpRequest.prototype, "open").mockImplementation(forbidden); + const send = vi.spyOn(XMLHttpRequest.prototype, "send").mockImplementation(forbidden); + const imported = webCaptureImport( + capture( + 'Local shell', + url, + ), + ); + // X pages belong to the social-post adapter, which refuses a shell with no loaded post. + if (/^https:\/\/(?:x|twitter)\.com\//u.test(url)) { + await expect(imported).rejects.toThrow(); + } else { + expect((await imported).title).toBe("Local shell"); + } + expect(fetch).not.toHaveBeenCalled(); + expect(windowFetch).not.toHaveBeenCalled(); + expect(xhr).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + + it("does not fetch resources or execute scripts embedded in captured HTML", async () => { + const forbidden = (): never => { + throw new Error("Extraction made a network request."); + }; + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(forbidden); + const xhr = vi.spyOn(XMLHttpRequest.prototype, "open").mockImplementation(forbidden); + const result = await webCaptureImport( + capture( + fixture("code-guide").replace( + "
", + '

Unsafe link

', + ), + ), + ); + const readable = readingDocument(result.bytes); + expect(fetch).not.toHaveBeenCalled(); + expect(xhr).not.toHaveBeenCalled(); + expect( + readable.querySelector('script, iframe, [onclick], [src^="https:"], [href^="javascript:"]'), + ).toBeNull(); + }); }); diff --git a/packages/web-capture/src/web-capture.ts b/packages/web-capture/src/web-capture.ts index adce0b33..a1066fbd 100644 --- a/packages/web-capture/src/web-capture.ts +++ b/packages/web-capture/src/web-capture.ts @@ -10,9 +10,18 @@ import { extractScholarlyMetadata, type ScholarlyMetadata } from "./scholarly-me import { socialPostImport } from "./social/import.js"; import { adapterFor, postAddress } from "./social/index.js"; -import type { Readability } from "@mozilla/readability"; +export const WEB_CAPTURE_EXTRACTOR = "defuddle@0.19.5"; +const fallbackExtractor = "mozilla-readability@0.6.0"; -export const WEB_CAPTURE_EXTRACTOR = "mozilla-readability@0.6.0"; +interface ExtractedArticle { + readonly content: string; + readonly title?: string | null; + readonly author?: string | null; + readonly published?: string | null; + readonly description?: string | null; + readonly language?: string | null; + readonly site?: string | null; +} export interface CapturedWebDocument { readonly submittedUrl: string; @@ -81,18 +90,20 @@ export async function webCaptureImport( }; return socialPostImport(await adapter.extract(ref, socialDocument), capture); } - // Loaded on save only: opening the capture panel should not pay for Readability. - const [{ Readability }, { prepareHtmlDocument }] = await Promise.all([ - import("@mozilla/readability"), - import("@mdbase-reader/renderer-html"), - ]); const parsed = options.document ?? parseHtml(capture.html); const canonicalUrl = new URL(capture.canonicalUrl); - const article = new Readability(parsed.cloneNode(true) as Document, { - keepClasses: false, - }).parse(); - const title = normalizedText(article?.title, 300) ?? captureTitle(parsed, canonicalUrl.hostname); - const readable = articleDocument(parsed, article?.content ?? parsed.body.innerHTML, title); + // Loaded on save only: opening the capture panel should not pay for extraction. + const [{ article, extractor }, { prepareHtmlDocument }] = await Promise.all([ + extractArticle(parsed, capture.canonicalUrl), + import("@mdbase-reader/renderer-html"), + ]); + const title = normalizedText(article.title, 300) ?? captureTitle(parsed, canonicalUrl.hostname); + const readable = articleDocument( + parsed, + article.content || parsed.body.innerHTML, + title, + article.language, + ); const stem = safeStem(canonicalUrl.hostname); const scholarly = extractScholarlyMetadata(parsed, capture.canonicalUrl); const metadata = captureMetadata(parsed, canonicalUrl, article); @@ -109,13 +120,79 @@ export async function webCaptureImport( submittedUrl: capture.submittedUrl, canonicalUrl: capture.canonicalUrl, retrievedAt: dateTime(capture.retrievedAt), + extractor, }, - // Publisher tags list every author; Readability's byline is often a single display string. + // Publisher tags list every author; an extractor's byline is often a single display string. metadata: scholarlyAuthors.length ? { ...metadata, authors: scholarlyAuthors } : metadata, scholarly, }; } +async function extractArticle( + parsed: Document, + url: string, +): Promise<{ readonly article: ExtractedArticle; readonly extractor: string }> { + const { default: Defuddle } = await import("defuddle"); + const article: ExtractedArticle = new Defuddle(parsed.cloneNode(true) as Document, { + url, + useAsync: false, + fetch: extractionFetch, + // Prefer intact evidence over aggressive cleanup: rewriting structures, partial selectors, + // short-content patterns and hidden sidenotes all lost real text in the comparison corpus. + standardize: false, + removeContentPatterns: false, + removePartialSelectors: false, + removeHiddenElements: false, + removeSmallImages: false, + }).parse(); + const extractedWords = wordCount(article.content); + if (suspiciouslyShort(parsed, extractedWords)) { + const { Readability } = await import("@mozilla/readability"); + const fallback = new Readability(parsed.cloneNode(true) as Document, { + keepClasses: false, + }).parse(); + // A large body can just be navigation. Switch only for substantially more readable text. + if ( + fallback && + wordCount(fallback.content ?? "") > Math.max(extractedWords * 1.5, extractedWords + 200) + ) { + return { + article: { + content: fallback.content ?? "", + title: fallback.title ?? null, + author: fallback.byline ?? null, + published: fallback.publishedTime ?? null, + description: fallback.excerpt ?? null, + language: fallback.lang ?? null, + site: fallback.siteName ?? null, + }, + extractor: fallbackExtractor, + }; + } + } + return { article, extractor: WEB_CAPTURE_EXTRACTOR }; +} + +function extractionFetch(): never { + throw new Error("Web capture extraction must not access the network."); +} + +function wordCount(content: string): number { + const template = document.createElement("template"); + template.innerHTML = content; + for (const element of template.content.querySelectorAll( + "script, style, noscript, template, nav, header, footer, [hidden]", + )) { + element.remove(); + } + return template.content.textContent.trim().split(/\s+/u).filter(Boolean).length; +} + +function suspiciouslyShort(document: Document, extractedWords: number): boolean { + const pageWords = wordCount(document.body.innerHTML); + return pageWords >= 400 && extractedWords < pageWords * 0.5; +} + function snapshotDocument(document: Document): Document { const clone = document.cloneNode(true) as Document; for (const control of clone.querySelectorAll("input, textarea, select, option")) { @@ -135,23 +212,28 @@ function parseHtml(html: string): Document { return new DOMParser().parseFromString(html, "text/html"); } -function articleDocument(source: Document, content: string, title: string): string { - const language = normalizedText(source.documentElement.lang, 100); +function articleDocument( + source: Document, + content: string, + title: string, + extractedLanguage?: string | null, +): string { + const language = firstText([source.documentElement.lang, extractedLanguage], 100); return `${escapeText(title)}
${content}
`; } function captureMetadata( document: Document, canonicalUrl: URL, - article: ReturnType, + article: ExtractedArticle, ): SourceImportMetadata { const authors = captureAuthors(document, article); const published = capturePublished(document, article); const description = captureDescription(document, article); - const language = firstText([article?.lang, document.documentElement.lang], 100); + const language = firstText([article.language, document.documentElement.lang], 100); const site = firstText( [ - article?.siteName, + article.site, document.querySelector('meta[property="og:site_name"]')?.content, canonicalUrl.hostname, ], @@ -166,7 +248,7 @@ function captureMetadata( }; } -function captureAuthors(document: Document, article: ReturnType): string[] { +function captureAuthors(document: Document, article: ExtractedArticle): string[] { const metaAuthors = [ ...document.querySelectorAll( 'meta[name="author"], meta[property="article:author"]', @@ -174,17 +256,14 @@ function captureAuthors(document: Document, article: ReturnType normalizedText(content, 300)) .filter((value): value is string => value !== undefined); - const byline = normalizedText(article?.byline, 300); + const byline = normalizedText(article.author, 300); return [...new Set([...(byline ? [byline] : []), ...metaAuthors])]; } -function capturePublished( - document: Document, - article: ReturnType, -): string | undefined { +function capturePublished(document: Document, article: ExtractedArticle): string | undefined { return firstText( [ - article?.publishedTime, + article.published, document.querySelector('meta[property="article:published_time"]')?.content, document.querySelector('meta[name="date"]')?.content, document.querySelector("time[datetime]")?.dateTime, @@ -193,13 +272,10 @@ function capturePublished( ); } -function captureDescription( - document: Document, - article: ReturnType, -): string | undefined { +function captureDescription(document: Document, article: ExtractedArticle): string | undefined { return firstText( [ - article?.excerpt, + article.description, document.querySelector('meta[name="description"]')?.content, document.querySelector('meta[property="og:description"]')?.content, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e10d11d1..83bd8079 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -433,8 +433,11 @@ importers: specifier: workspace:* version: link:../renderer-html '@mozilla/readability': - specifier: ^0.6.0 + specifier: 0.6.0 version: 0.6.0 + defuddle: + specifier: 0.19.5 + version: 0.19.5 packages: @@ -1144,6 +1147,9 @@ packages: '@codemirror/view': optional: true + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@mozilla/readability@0.6.0': resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} @@ -1752,6 +1758,10 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -1848,6 +1858,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1894,6 +1915,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defuddle@0.19.5: + resolution: {integrity: sha512-WpgFv5TWA8s8bWRXMb+tosdEbdeZwgrKYw+BQiuTQfQc4XdCm69Yq/76Lzgvr0ALC1VxZcMWwjNGGgIstcxUtw==} + hasBin: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1913,6 +1938,35 @@ packages: dockview@8.3.1: resolution: {integrity: sha512-2odBC9cDKcVCVx8Db86vq9s9JZKzbgvdkxDzcfsrD9nORLc5UqhLgLvVcJguorBv7CyEpKDzFO1JVLYWJCYrJA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1935,10 +1989,18 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2294,6 +2356,12 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2585,6 +2653,15 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} @@ -2609,6 +2686,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathml-to-latex@1.8.0: + resolution: {integrity: sha512-gQ0uK3zqB8HwlfaXJkEL5rgaZNbKUiBMmBP/B/W+b+t6KcseLSuYb1b0BjLgS9ZiQa24ePkqTX8/6FaQuDL7wQ==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -2658,6 +2738,10 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3027,6 +3111,10 @@ packages: resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} engines: {node: '>=18'} + temml@0.13.5: + resolution: {integrity: sha512-aPkDDgunanpLNL0ql32HbolqLep+w8DRcVXRql7rWrMt/PhczdLgL4UBYYVU3BYjCNjkGq4EqwIicu/zWr1iOg==} + engines: {node: '>=18.13.0'} + through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} @@ -3065,6 +3153,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3097,6 +3189,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + ulid@2.4.0: resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} hasBin: true @@ -3359,7 +3454,6 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - snapshots: '@babel/code-frame@7.29.7': @@ -4307,6 +4401,9 @@ snapshots: optionalDependencies: '@codemirror/view': 6.43.8 + '@mixmark-io/domino@2.2.0': + optional: true + '@mozilla/readability@0.6.0': {} '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -4881,6 +4978,9 @@ snapshots: big-integer@1.6.52: {} + boolbase@2.0.0: + optional: true + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -4972,6 +5072,21 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + optional: true + + css-what@8.0.0: + optional: true + + cssom@0.5.0: + optional: true + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} @@ -5016,6 +5131,17 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defuddle@0.19.5: + dependencies: + commander: 12.1.0 + mathml-to-latex: 1.8.0 + optionalDependencies: + linkedom: 0.18.13 + temml: 0.13.5 + turndown: 7.2.4 + transitivePeerDependencies: + - canvas + detect-libc@2.1.2: {} devalue@5.9.0: {} @@ -5032,6 +5158,50 @@ snapshots: dependencies: dockview-core: 8.3.1 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + optional: true + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.1.0 + optional: true + + domelementtype@2.3.0: + optional: true + + domelementtype@3.0.0: + optional: true + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + optional: true + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + optional: true + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + optional: true + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + optional: true + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5056,8 +5226,14 @@ snapshots: emoji-regex@9.2.2: {} + entities@4.5.0: + optional: true + entities@7.0.1: {} + entities@8.1.0: + optional: true + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -5518,6 +5694,17 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-escaper@3.0.3: + optional: true + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + optional: true + ignore@5.3.2: {} ignore@7.0.6: {} @@ -5775,6 +5962,15 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + optional: true + locate-character@3.0.0: {} locate-path@6.0.0: @@ -5795,6 +5991,10 @@ snapshots: math-intrinsics@1.1.0: {} + mathml-to-latex@1.8.0: + dependencies: + '@xmldom/xmldom': 0.9.10 + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -5843,6 +6043,11 @@ snapshots: node-releases@2.0.53: {} + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + optional: true + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -6272,6 +6477,9 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + temml@0.13.5: + optional: true + through2@4.0.2: dependencies: readable-stream: 3.6.2 @@ -6299,6 +6507,11 @@ snapshots: tslib@2.8.1: {} + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + optional: true + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -6349,6 +6562,9 @@ snapshots: typescript@5.9.3: {} + uhyphen@0.2.0: + optional: true + ulid@2.4.0: {} unbox-primitive@1.1.0: