From 513af032a3176ad92597f405cbd4155dad1954d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:39:23 +0000 Subject: [PATCH 1/4] Compute Article content-derived properties lazily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On every successful parse, Article was built eagerly with content (a full innerHTML serialization of the extracted body), textContent (a full text walk), length, and the collected image list — paid even by callers that only use contentElement, hasContent() or the metadata. content, textContent, length and images are now virtual properties (PHP 8.4 property hooks) computed from contentElement on first access and cached, hasContent() checks contentElement directly, and image collection moved onto Article. Reads are unchanged; the Article constructor now takes only the metadata and contentElement. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iNBFSQM7xGLoYg9Y5EJx4 --- CHANGELOG.md | 5 ++ README.md | 4 +- src/Article.php | 106 ++++++++++++++++++++++++++++++--------- src/Readability.php | 35 ------------- test/ReadabilityTest.php | 32 ++++++++++++ 5 files changed, 122 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dac749..11e09b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log All notable changes to this project will be documented in this file. +## [Unreleased] + +### Changed +- `Article`'s content-derived properties (`content`, `textContent`, `length`, `images`) are now computed lazily from `contentElement` on first access (via property hooks) and cached, so callers that only use `contentElement`, `hasContent()` or the metadata no longer pay for serializing the article HTML and text on every parse. Reads are unchanged (same names, same types, same values); `hasContent()` now checks `contentElement`. Two edges to note: the `Article` constructor no longer takes `content`/`textContent`/`length`/`images` (it derives them from `contentElement`), and the lazy properties, being virtual, no longer appear in `json_encode()`/`get_object_vars()` output — read them explicitly if you serialize articles + ## [v4.0.0](https://github.com/fivefilters/readability.php/releases/tag/v4.0.0) First stable release of the 4.0 series — a ground-up rewrite. This release includes **all changes from [v4.0.0-beta.1](https://github.com/fivefilters/readability.php/releases/tag/v4.0.0-beta.1) below** (see [UPGRADE.md](UPGRADE.md) for the 3.x → 4.0 migration guide), plus the following changes since beta.1: diff --git a/README.md b/README.md index 9c82b86..4588e11 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ try { When no article content can be found — the case where Readability.js returns `null` — `parse()` still returns an `Article` carrying whatever was extracted before content detection failed (`title`, `byline`, `dir`, `lang`, `excerpt`, `siteName`, `publishedTime`, `image`), with the content-derived properties (`content`, `textContent`, `length`, `contentElement`) set to `null`. `Article::hasContent()` tells the two apart. `ParseException` is reserved for the cases where parsing cannot be attempted at all: empty input, or a document exceeding `maxElemsToParse` (where Readability.js throws too). -`Article` is a readonly value object mirroring what Readability.js returns: +`Article` is an immutable value object mirroring what Readability.js returns: ```php $article->title; // string – article title @@ -70,6 +70,8 @@ echo $article; // same as $article->content `image` and `images` are a PHP-specific addition (Readability.js has no image extraction). When `fixRelativeURLs` is enabled they are returned as absolute URLs. +The content-derived properties (`content`, `textContent`, `length`, `images`) are computed from `contentElement` on first access and then cached, so callers that only work with the DOM element (or only check `hasContent()` and the metadata) never pay for serializing the article HTML and text. A consequence of the caching: if you mutate `contentElement`, do it before reading `content`/`textContent` — mutations made after the first read are not reflected. + So for finer control over the output, wrap the properties in your own HTML: ```php diff --git a/src/Article.php b/src/Article.php index 277ac69..c103623 100644 --- a/src/Article.php +++ b/src/Article.php @@ -13,45 +13,85 @@ * library still returns an Article carrying that metadata, with the * content-derived properties (content, textContent, length, contentElement) * set to null. Use hasContent() to tell the two apart. + * + * The content-derived properties (content, textContent, length, images) are + * computed from contentElement on first access and cached, so callers that + * only need the DOM tree never pay for serializing it. Mutating + * contentElement after reading one of them will not be reflected in + * subsequent reads. */ -final readonly class Article +final class Article { + /** HTML string of the processed article content; null when no content was found. */ + public ?string $content { + get { + if ($this->contentElement === null) { + return null; + } + return $this->contentCache ??= $this->contentElement->innerHTML; + } + } + + /** Text content of the article, with all the HTML tags removed; null when no content was found. */ + public ?string $textContent { + get { + if ($this->contentElement === null) { + return null; + } + return $this->textContentCache ??= $this->contentElement->textContent; + } + } + + /** Length of the article's text content, in characters (Unicode code points); null when no content was found. */ + public ?int $length { + get { + if ($this->contentElement === null) { + return null; + } + return $this->lengthCache ??= mb_strlen($this->textContent); + } + } + + /** + * All image URLs found for the article: the lead image (if any) + * followed by every in the content, de-duplicated. PHP-specific. + * Content srcs are absolute when fixRelativeURLs is enabled. + * + * @var list + */ + public array $images { + get => $this->imagesCache ??= $this->collectImages(); + } + + private ?string $contentCache = null; + private ?string $textContentCache = null; + private ?int $lengthCache = null; + /** @var ?list */ + private ?array $imagesCache = null; + public function __construct( /** Article title (empty string if none was found). */ - public string $title, + public readonly string $title, /** Author metadata, if found. */ - public ?string $byline, + public readonly ?string $byline, /** Content direction ("ltr"/"rtl"), if found. */ - public ?string $dir, + public readonly ?string $dir, /** Content language, if found. */ - public ?string $lang, - /** HTML string of the processed article content; null when no content was found. */ - public ?string $content, - /** Text content of the article, with all the HTML tags removed; null when no content was found. */ - public ?string $textContent, - /** Length of the article's text content, in characters (Unicode code points); null when no content was found. */ - public ?int $length, + public readonly ?string $lang, /** Article description, or short excerpt from the content. */ - public ?string $excerpt, + public readonly ?string $excerpt, /** Name of the site, if found. */ - public ?string $siteName, + public readonly ?string $siteName, /** Published time, if found. */ - public ?string $publishedTime, + public readonly ?string $publishedTime, /** * The lead image URL (from og:image/twitter:image, or a * ), if found. PHP-specific; not part of * Readability.js. Absolute when fixRelativeURLs is enabled. */ - public ?string $image, - /** - * All image URLs found for the article: the lead image (if any) - * followed by every in the content, de-duplicated. PHP-specific. - * - * @var list - */ - public array $images, + public readonly ?string $image, /** The article content as a DOM element, for callers who want to keep working on the tree; null when no content was found. */ - public ?\Dom\Element $contentElement, + public readonly ?\Dom\Element $contentElement, ) { } @@ -62,11 +102,29 @@ public function __construct( */ public function hasContent(): bool { - return $this->content !== null; + return $this->contentElement !== null; } public function __toString(): string { return $this->content ?? ''; } + + /** @return list */ + private function collectImages(): array + { + $urls = []; + if ($this->image !== null) { + $urls[] = $this->image; + } + if ($this->contentElement !== null) { + foreach ($this->contentElement->querySelectorAll('img') as $img) { + $src = (string) $img->getAttribute('src'); + if ($src !== '') { + $urls[] = $src; + } + } + } + return array_values(array_unique($urls)); + } } diff --git a/src/Readability.php b/src/Readability.php index 07e212d..a5c5934 100644 --- a/src/Readability.php +++ b/src/Readability.php @@ -195,14 +195,10 @@ public function parse(\Dom\HTMLDocument|string $document): Article byline: self::pick($metadata['byline'], $this->articleByline), dir: $this->articleDir, lang: self::pick($this->articleLang), - content: null, - textContent: null, - length: null, excerpt: self::pick($metadata['excerpt']), siteName: self::pick($metadata['siteName'], $this->articleSiteName), publishedTime: self::pick($metadata['publishedTime']), image: $leadImage, - images: $leadImage !== null ? [$leadImage] : [], contentElement: null, ); } @@ -221,23 +217,15 @@ public function parse(\Dom\HTMLDocument|string $document): Article } } - $textContent = $articleContent->textContent; - - $images = $this->collectImages($articleContent, $leadImage); - return new Article( title: $this->articleTitle ?? '', byline: self::pick($metadata['byline'], $this->articleByline), dir: $this->articleDir, lang: self::pick($this->articleLang), - content: $articleContent->innerHTML, - textContent: $textContent, - length: mb_strlen($textContent), excerpt: self::pick($metadata['excerpt']), siteName: self::pick($metadata['siteName'], $this->articleSiteName), publishedTime: self::pick($metadata['publishedTime']), image: $leadImage, - images: $images, contentElement: $articleContent, ); } finally { @@ -469,29 +457,6 @@ private function getLeadImageUrl(): ?string return null; } - /** - * PHP-specific: the list of image URLs for the article — the lead image - * (if any) followed by every content src, de-duplicated. Content - * srcs are already absolute here when fixRelativeURLs is enabled (see - * fixRelativeUris); the lead image is absolutized by the caller. - * - * @return list - */ - private function collectImages(\Dom\Element $content, ?string $leadImage): array - { - $urls = []; - if ($leadImage !== null) { - $urls[] = $leadImage; - } - foreach ($this->getAllNodesWithTag($content, ['img']) as $img) { - $src = (string) $img->getAttribute('src'); - if ($src !== '') { - $urls[] = $src; - } - } - return array_values(array_unique($urls)); - } - /** The toAbsoluteURI closure inside _fixRelativeUris. */ private function toAbsoluteURI(string $uri): string { diff --git a/test/ReadabilityTest.php b/test/ReadabilityTest.php index b5e6734..6bbe16f 100644 --- a/test/ReadabilityTest.php +++ b/test/ReadabilityTest.php @@ -144,6 +144,38 @@ public function testArticleWithContentReportsHasContent(): void $this->assertGreaterThan(0, $article->length); } + public function testContentIsDerivedLazilyFromContentElementAndCached(): void + { + $article = new Readability()->parse( + '

' . str_repeat('Long enough paragraph text. ', 30) . '

' + ); + + // content/textContent/length are serialized from contentElement on + // first access, so a mutation made before that first read shows up... + $extra = $article->contentElement->ownerDocument->createElement('p'); + $extra->textContent = 'LAZY-MARKER'; + $article->contentElement->appendChild($extra); + $this->assertStringContainsString('LAZY-MARKER', $article->content); + $this->assertStringContainsString('LAZY-MARKER', $article->textContent); + $this->assertSame(mb_strlen($article->textContent), $article->length); + + // ...and the first read is cached: later mutations are not reflected. + $article->contentElement->removeChild($extra); + $this->assertStringContainsString('LAZY-MARKER', $article->content); + $this->assertStringContainsString('LAZY-MARKER', $article->textContent); + } + + public function testArticlePropertiesCannotBeWritten(): void + { + $article = new Readability()->parse( + '

' . str_repeat('Long enough paragraph text. ', 30) . '

' + ); + + $this->expectException(\Error::class); + /** @psalm-suppress InvalidPropertyAssignmentValue */ + $article->content = 'nope'; + } + public function testCustomAllowedVideoRegex(): void { $source = '

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc mollis leo lacus, vitae semper nisl ullamcorper ut.

' From 991feb57197e9a79dd400d9b01ab6db5ea23f00c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:45:20 +0000 Subject: [PATCH 2/4] Preserve json_encode/var_dump output for lazy Article properties Virtual (hooked) properties are invisible to json_encode() and var_dump(), which would silently drop content, textContent, length and images from dumped articles. jsonSerialize() and __debugInfo() spell out the full property list in the pre-lazy order, so both produce the same output as the eager implementation (at the cost of triggering the serialization the lazy properties otherwise avoid). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iNBFSQM7xGLoYg9Y5EJx4 --- CHANGELOG.md | 2 +- src/Article.php | 38 +++++++++++++++++++++++++++++++++++++- test/ReadabilityTest.php | 22 ++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e09b5..b3c2c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Changed -- `Article`'s content-derived properties (`content`, `textContent`, `length`, `images`) are now computed lazily from `contentElement` on first access (via property hooks) and cached, so callers that only use `contentElement`, `hasContent()` or the metadata no longer pay for serializing the article HTML and text on every parse. Reads are unchanged (same names, same types, same values); `hasContent()` now checks `contentElement`. Two edges to note: the `Article` constructor no longer takes `content`/`textContent`/`length`/`images` (it derives them from `contentElement`), and the lazy properties, being virtual, no longer appear in `json_encode()`/`get_object_vars()` output — read them explicitly if you serialize articles +- `Article`'s content-derived properties (`content`, `textContent`, `length`, `images`) are now computed lazily from `contentElement` on first access (via property hooks) and cached, so callers that only use `contentElement`, `hasContent()` or the metadata no longer pay for serializing the article HTML and text on every parse. Reads are unchanged (same names, same types, same values); `hasContent()` now checks `contentElement`. `json_encode()` and `var_dump()` output is preserved via `jsonSerialize()`/`__debugInfo()` (note that both trigger the full serialization). Two edges to note: the `Article` constructor no longer takes `content`/`textContent`/`length`/`images` (it derives them from `contentElement`), and the lazy properties, being virtual, no longer appear in `get_object_vars()` or `(array)` casts ## [v4.0.0](https://github.com/fivefilters/readability.php/releases/tag/v4.0.0) diff --git a/src/Article.php b/src/Article.php index c103623..4091e4d 100644 --- a/src/Article.php +++ b/src/Article.php @@ -20,7 +20,7 @@ * contentElement after reading one of them will not be reflected in * subsequent reads. */ -final class Article +final class Article implements \JsonSerializable { /** HTML string of the processed article content; null when no content was found. */ public ?string $content { @@ -110,6 +110,42 @@ public function __toString(): string return $this->content ?? ''; } + /** + * json_encode() and var_dump() only see real properties, not virtual + * (hooked) ones, which would silently drop content, textContent, length + * and images. These two methods spell out the full property list, in the + * order the eager implementation used, so the output is unchanged. Note + * that both therefore trigger the serialization the lazy properties + * otherwise avoid. + * + * @return array + */ + #[\Override] + public function jsonSerialize(): array + { + return $this->__debugInfo(); + } + + /** @return array */ + public function __debugInfo(): array + { + return [ + 'title' => $this->title, + 'byline' => $this->byline, + 'dir' => $this->dir, + 'lang' => $this->lang, + 'content' => $this->content, + 'textContent' => $this->textContent, + 'length' => $this->length, + 'excerpt' => $this->excerpt, + 'siteName' => $this->siteName, + 'publishedTime' => $this->publishedTime, + 'image' => $this->image, + 'images' => $this->images, + 'contentElement' => $this->contentElement, + ]; + } + /** @return list */ private function collectImages(): array { diff --git a/test/ReadabilityTest.php b/test/ReadabilityTest.php index 6bbe16f..a1a7a83 100644 --- a/test/ReadabilityTest.php +++ b/test/ReadabilityTest.php @@ -165,6 +165,28 @@ public function testContentIsDerivedLazilyFromContentElementAndCached(): void $this->assertStringContainsString('LAZY-MARKER', $article->textContent); } + public function testArticleSerializationIncludesLazyProperties(): void + { + $article = new Readability()->parse( + '

' . str_repeat('Long enough paragraph text. ', 30) . '

' + ); + + // Virtual (hooked) properties are invisible to json_encode() and + // var_dump(); jsonSerialize()/__debugInfo() reinstate them. + $encoded = json_decode(json_encode($article), true); + $this->assertSame($article->content, $encoded['content']); + $this->assertSame($article->textContent, $encoded['textContent']); + $this->assertSame($article->length, $encoded['length']); + $this->assertSame($article->images, $encoded['images']); + $this->assertSame($article->title, $encoded['title']); + + ob_start(); + var_dump($article); + $dump = ob_get_clean(); + $this->assertStringContainsString('"textContent"', $dump); + $this->assertStringContainsString('Long enough paragraph text.', $dump); + } + public function testArticlePropertiesCannotBeWritten(): void { $article = new Readability()->parse( From 8e68b622c8a8f9c19a14282f8ab117c8c9bb951d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:48:01 +0000 Subject: [PATCH 3/4] Prepare 4.0.1 release Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iNBFSQM7xGLoYg9Y5EJx4 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c2c99..85f1f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Change Log All notable changes to this project will be documented in this file. -## [Unreleased] +## [v4.0.1](https://github.com/fivefilters/readability.php/releases/tag/v4.0.1) ### Changed - `Article`'s content-derived properties (`content`, `textContent`, `length`, `images`) are now computed lazily from `contentElement` on first access (via property hooks) and cached, so callers that only use `contentElement`, `hasContent()` or the metadata no longer pay for serializing the article HTML and text on every parse. Reads are unchanged (same names, same types, same values); `hasContent()` now checks `contentElement`. `json_encode()` and `var_dump()` output is preserved via `jsonSerialize()`/`__debugInfo()` (note that both trigger the full serialization). Two edges to note: the `Article` constructor no longer takes `content`/`textContent`/`length`/`images` (it derives them from `contentElement`), and the lazy properties, being virtual, no longer appear in `get_object_vars()` or `(array)` casts From 14398c4391581e8e62939b36142213e77f7790bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 17:24:05 +0000 Subject: [PATCH 4/4] Add metadataOnly option; target 4.1.0 metadataOnly extracts only the title and metadata, skipping content extraction entirely: parse() skips every stage that mutates the document (unwrapNoscriptImages, removeScripts, prepDocument and grabArticle), so a passed-in Dom\HTMLDocument is guaranteed to be left unmodified. Title and metadata extraction depend on none of those stages (JSON-LD is read before script removal anyway); the article language, normally picked up during grabArticle's traversal, is read directly from the root element instead. Useful for callers that locate the article body themselves and only want Readability's title/metadata extraction. The release becomes 4.1.0 rather than 4.0.1: it adds an option, and the Article constructor signature change from the lazy-properties work is more than a patch under semver. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iNBFSQM7xGLoYg9Y5EJx4 --- CHANGELOG.md | 5 ++++- README.md | 3 ++- src/Configuration.php | 10 ++++++++++ src/Readability.php | 33 ++++++++++++++++++++++++++------- test/ReadabilityTest.php | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85f1f0a..19e8dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ # Change Log All notable changes to this project will be documented in this file. -## [v4.0.1](https://github.com/fivefilters/readability.php/releases/tag/v4.0.1) +## [v4.1.0](https://github.com/fivefilters/readability.php/releases/tag/v4.1.0) + +### Added +- `metadataOnly` option (PHP-specific): extract only the title and metadata, skipping content extraction entirely. `parse()` then skips every stage that mutates the document (noscript image unwrapping, script removal, document prep, and the article grab itself), so a passed-in `\Dom\HTMLDocument` is guaranteed to be left unmodified. The returned `Article` has the content-derived properties set to `null`, as when no content is found (`hasContent()` returns `false`) ### Changed - `Article`'s content-derived properties (`content`, `textContent`, `length`, `images`) are now computed lazily from `contentElement` on first access (via property hooks) and cached, so callers that only use `contentElement`, `hasContent()` or the metadata no longer pay for serializing the article HTML and text on every parse. Reads are unchanged (same names, same types, same values); `hasContent()` now checks `contentElement`. `json_encode()` and `var_dump()` output is preserved via `jsonSerialize()`/`__debugInfo()` (note that both trigger the full serialization). Two edges to note: the `Article` constructor no longer takes `content`/`textContent`/`length`/`images` (it derives them from `contentElement`), and the lazy properties, being virtual, no longer appear in `get_object_vars()` or `(array)` casts diff --git a/README.md b/README.md index 4588e11..e7e56b1 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ foreach ($article->contentElement->querySelectorAll('img[src]') as $img) { } ``` -`parse()` also accepts a `\Dom\HTMLDocument` directly (for example because you want to pre-process it). Note that a passed document is modified in place while the article is extracted. +`parse()` also accepts a `\Dom\HTMLDocument` directly (for example because you want to pre-process it). Note that a passed document is modified in place while the article is extracted (unless the `metadataOnly` option is set, which guarantees a read-only pass). ### Checking if a page is readerable @@ -151,6 +151,7 @@ PHP-specific options (a browser knows the page URL; this library must be told): - **originalURL**: default `null`, the URL the article was fetched from, used as the base for URL fixing. A `` in the document is honored too. - **logger**: default `null`, an optional PSR-3 `LoggerInterface`. When set, debug messages are also sent to it (independently of the `debug` flag, which only controls `error_log()`). - **keepInlineByline**: default `false`. By default an inline byline (e.g. a ``) is removed from the content, as in Readability.js. Set this to `true` to keep it in the content; either way it is still extracted into `$article->byline`. +- **metadataOnly**: default `false`. Extract only the title and metadata, skipping content extraction entirely — useful when you locate the article body yourself and only want Readability's title/metadata extraction. `parse()` then skips every stage that mutates the document, so a passed-in `\Dom\HTMLDocument` is guaranteed to be left unmodified (normally a passed document is modified in place). The returned `Article` has the content-derived properties set to `null`, as when no content is found (`hasContent()` returns `false`). Toggles for internal Readability flags carried over from earlier versions (always on in Readability.js): diff --git a/src/Configuration.php b/src/Configuration.php index 36a0356..6daff5b 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -49,6 +49,16 @@ public function __construct( * removes it; readability.php 3.x kept it unless articleByline was set. */ public readonly bool $keepInlineByline = false, + /** + * PHP-specific: extract only the title and metadata, skipping content + * extraction entirely. parse() then skips every stage that mutates + * the document (noscript image unwrapping, script removal, document + * prep, and the article grab itself), so a passed-in + * \Dom\HTMLDocument is guaranteed to be left unmodified. The + * returned Article has the content-derived properties set to null + * (hasContent() returns false), as when no content is found. + */ + public readonly bool $metadataOnly = false, /** Internal flag toggles carried over from the previous major version (always on in JS). */ public readonly bool $stripUnlikelyCandidates = true, public readonly bool $weightClasses = true, diff --git a/src/Readability.php b/src/Readability.php index a5c5934..c89a429 100644 --- a/src/Readability.php +++ b/src/Readability.php @@ -118,7 +118,9 @@ public function __construct(?Configuration $configuration = null, mixed ...$opti * Parse an HTML string and return the article. When no article content * is found (where Readability.js returns null), the returned Article * carries the extracted title and metadata with null content — see - * Article::hasContent(). + * Article::hasContent(). The same shape is returned when the + * metadataOnly option is set, which also guarantees a passed-in + * document is not modified. * * @throws ParseException when the input is empty or the document exceeds * maxElemsToParse @@ -159,16 +161,27 @@ public function parse(\Dom\HTMLDocument|string $document): Article } } - // Unwrap image from noscript - $this->unwrapNoscriptImages($document); + // PHP-specific: with metadataOnly, skip every stage that mutates + // the document — unwrapNoscriptImages, removeScripts, prepDocument + // and grabArticle — so the parse is guaranteed read-only on a + // passed-in document. Title and metadata extraction depend on none + // of them (JSON-LD is read before script removal anyway). + $metadataOnly = $this->configuration->metadataOnly; + + if (!$metadataOnly) { + // Unwrap image from noscript + $this->unwrapNoscriptImages($document); + } // Extract JSON-LD metadata before removing scripts $jsonLd = $this->configuration->disableJSONLD ? [] : $this->getJSONLD($document); - // Remove script tags from the document. - $this->removeScripts($document); + if (!$metadataOnly) { + // Remove script tags from the document. + $this->removeScripts($document); - $this->prepDocument(); + $this->prepDocument(); + } $metadata = $this->getArticleMetadata($jsonLd); $this->metadata = $metadata; @@ -184,7 +197,13 @@ public function parse(\Dom\HTMLDocument|string $document): Article $leadImage = $this->toAbsoluteURI($leadImage); } - $articleContent = $this->grabArticle(); + if ($metadataOnly) { + // grabArticle is skipped; replicate its one read-only + // extraction, the article language from the root element. + $this->articleLang = $document->documentElement?->getAttribute('lang'); + } + + $articleContent = $metadataOnly ? null : $this->grabArticle(); if (!$articleContent) { // PHP-specific: where Readability.js returns a bare null and // discards the title and metadata it had already extracted, diff --git a/test/ReadabilityTest.php b/test/ReadabilityTest.php index a1a7a83..0b795de 100644 --- a/test/ReadabilityTest.php +++ b/test/ReadabilityTest.php @@ -133,6 +133,43 @@ public function testNoContentReturnsMetadataOnlyArticle(): void $this->assertNull($article->dir); } + public function testMetadataOnlySkipsContentExtractionAndLeavesDocumentUntouched(): void + { + // Scripts, noscript images and extractable content: everything the + // normal parse would mutate or remove. + $html = << + Site Name - A Longer Article Title About Things + + + + +

+ HTML . str_repeat('Long enough paragraph text. ', 30) . <<

+ HTML; + $document = \Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR); + $before = $document->saveHtml(); + + $article = new Readability(metadataOnly: true)->parse($document); + + // The read-only guarantee: the caller's document is untouched. + $this->assertSame($before, $document->saveHtml()); + + // No content extraction... + $this->assertFalse($article->hasContent()); + $this->assertNull($article->content); + $this->assertNull($article->contentElement); + + // ...but title and metadata are extracted as usual. + $this->assertSame('A Longer Article Title About Things', $article->title); + $this->assertSame('Jane Doe', $article->byline); + $this->assertSame('fr', $article->lang); + $this->assertSame('A short description.', $article->excerpt); + $this->assertSame('https://example.com/lead.jpg', $article->image); + $this->assertSame(['https://example.com/lead.jpg'], $article->images); + } + public function testArticleWithContentReportsHasContent(): void { $article = new Readability()->parse(