diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6dac749..19e8dc3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,14 @@
# Change Log
All notable changes to this project will be documented in this file.
+## [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
+
## [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..e7e56b1 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
@@ -86,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
@@ -149,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 `
By Jane Doe
`) 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/Article.php b/src/Article.php
index 277ac69..4091e4d 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 implements \JsonSerializable
{
+ /** 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,65 @@ public function __construct(
*/
public function hasContent(): bool
{
- return $this->content !== null;
+ return $this->contentElement !== null;
}
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
+ {
+ $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/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 07e212d..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,
@@ -195,14 +214,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 +236,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 +476,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..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
+
+
+
+
+
'
+ );
+
+ // 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 testArticleSerializationIncludesLazyProperties(): void
+ {
+ $article = new Readability()->parse(
+ '