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 + + + + +

+ 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( @@ -144,6 +181,60 @@ 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 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( + '

' . 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.

'