Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 `<base href>` 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 `<p class="byline">By Jane Doe</p>`) 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):

Expand Down
142 changes: 118 additions & 24 deletions src/Article.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> in the content, de-duplicated. PHP-specific.
* Content srcs are absolute when fixRelativeURLs is enabled.
*
* @var list<string>
*/
public array $images {
get => $this->imagesCache ??= $this->collectImages();
}

private ?string $contentCache = null;
private ?string $textContentCache = null;
private ?int $lengthCache = null;
/** @var ?list<string> */
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
* <link rel="img_src">), 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 <img> in the content, de-duplicated. PHP-specific.
*
* @var list<string>
*/
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,
) {
}

Expand All @@ -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<string, mixed>
*/
#[\Override]
public function jsonSerialize(): array
{
return $this->__debugInfo();
}

/** @return array<string, mixed> */
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<string> */
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));
}
}
10 changes: 10 additions & 0 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 26 additions & 42 deletions src/Readability.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
);
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 <img> 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<string>
*/
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
{
Expand Down
Loading