Compare commits

...
36 Commits
Author SHA1 Message Date
Jérémy BenoistandGitHub 03533f5e4f Merge pull request #111 from j0k3r/fix/tidy-php85
Fix bad encoding for `tidy_repair_string`
2026-03-27 20:34:19 +01:00
Jeremy Benoist 009b4ab9b8 Add PHP 8.5 on CI 2026-03-27 20:31:52 +01:00
Jeremy Benoist f9e73fb49a Fix bad encoding for tidy_repair_string
Tidy on PHP 8.5 is more restrictive on what can be given as input encoding. Before, it worked as an unknown value was converted to `utf8`.
2026-03-27 20:29:09 +01:00
Jérémy BenoistandGitHub 3042990efc Merge pull request #106 from jtojnar/encode
Fix character decoding regression when `title` precedes `meta[charset]`
2025-06-03 09:22:21 +02:00
Jan Tojnar 8b89d70b1a Fix character decoding regression when title precedes meta[charset]
Because of PHP 8.2 deprecation, in f14428e4c0, we stopped converting non-ASCII characters to HTML entities. Instead, we started to explicitly insert `meta[charset]` tag at the start of the document.

Later, we discovered that was breaking `html[lang]` so, in efbbc86df9, we made the insertion smarter. One of the improvements was that it would not insert the `meta[charset]` tag when it was already present.

That, however, broke websites that had `title` tag before `meta[charset]`. On those, libxml2 would decode the `title` contents as ISO-8859-1.

We could improve the logic (e.g. check that there is not text content before `meta[charset]`) or insert the tag unconditionally but it will probably be simplest to just go back to converting the non-ASCII characters to entities, just using non-deprecated function variant.
2025-05-28 01:10:59 +02:00
Jan Tojnar 3e9b15db46 tests: Check encoding was preserved in testHtmlLang
The fix introduced in efbbc86df9 alongside this test also manipulates `meta[charset]` but we were not checking if it does not break encoding.
2025-05-28 00:51:49 +02:00
Jérémy BenoistandGitHub 7413a38ff0 Merge pull request #104 from jtojnar/html-shadowing
Fix discarding `html[lang]`
2025-03-04 10:20:28 +01:00
Jérémy BenoistandGitHub a18cd0f2a9 Merge pull request #102 from jtojnar/local-no-domain
Do not set domainRegExp for local files
2025-03-04 10:20:15 +01:00
Jan Tojnar efbbc86df9 Fix discarding html[lang]
`DOMDocument::loadHTML` will parse HTML documents as ISO-8859-1 if there is no `meta[charset]` tag. This means that UTF-8-encoded HTML fragments such as those coming from JSON-LD `articleBody` field would be parsed with incorrect encoding.

In f14428e4c0, we tried to resolve it by putting `meta[charset]` tag at the start of the HTML fragment. Unfortunately, it turns out that causes parser to auto-insert a `html` element, losing the attributes of the original `html` tag.

Let’s try to insert the `meta[charset]` tag into the proper place in the HTML document.

We do not need to use the same trick with `JSLikeHTMLElement::__set`.
That expects smaller HTML fragments, not `html` documents, so creating `html` and `head` elements will not be a problem.
2025-03-04 01:51:34 +01:00
Jan Tojnar 541fab34a0 tests: Remove pointless debug assignment
It is unused since 8ab7d76cd5.
2025-03-03 23:55:32 +01:00
Jan Tojnar 90869d877e tests: Use ::class for DOMDocument class name
Also capitalize it properly.
2025-03-03 23:53:33 +01:00
Jan Tojnar c7208f6ad2 Do not set domainRegExp for local files
`parse_url($this->url, \PHP_URL_HOST)` will return `null` for local filesystem path.
Casting it to `string` will produce an empty regular expression,
which would match any link when computing link density.
2025-03-03 23:26:30 +01:00
Jérémy BenoistandGitHub 4258559b8a Merge pull request #100 from jtojnar/phpunit-bridge7
composer: Allow phpunit-bridge 7.0
2025-02-24 09:50:46 +01:00
Jan Tojnar 1ac761d708 composer: Allow phpunit-bridge 7.0 2025-02-24 09:47:14 +01:00
Jérémy BenoistandGitHub d3053fbce4 Merge pull request #99 from jtojnar/phpstan2
phpstan: Upgrade to version 2
2025-02-24 07:37:59 +01:00
Jan Tojnar 4c929754e9 phpstan: Upgrade to version 2
https://github.com/phpstan/phpstan/blob/2.1.x/UPGRADING.md

Required also bumping Rector since it uses PHPStan internally.
2025-02-23 02:49:08 +01:00
Jan Tojnar 1d7cdf3a12 phpstan: Use standard config path
This allows developer to create their own own config file, e.g. for setting `editorUrl`:
https://phpstan.org/user-guide/output-format#opening-file-in-an-editor
2025-02-23 02:46:57 +01:00
Jérémy BenoistandGitHub f825dcf55a Merge pull request #90 from jtojnar/foreaches
Iterate node lists with foreach
2024-10-11 08:56:35 +02:00
Jan Tojnar 9a9373de4b Iterate node lists with foreach
`DOMNodeList` implements `Traversable`.

There are some `for` loops left but we cannot simply replace those:
PHP follows the DOM specification, which requires that `NodeList`
objects in the DOM are live. As a result, any operation that removes
a node list member node from its parent (such as `removeChild`,
`replaceChild` or `appendChild`) will cause the next node
in the iterator to be skipped.

We could work around that by converting those node lists to static arrays
using `iterator_to_array` but not sure if it is worth it.
2024-10-10 09:01:45 +02:00
Jan Tojnar d454c3a462 Remove dead iteration code
This was forgotten in b580cf216d.
2024-10-10 09:01:45 +02:00
Jan Tojnar 8b1ef07401 Extract for-iterated items into variables
This simplifies the code a bit and will make it slightly easier in case we decide to switch to `foreach` iteration.
2024-10-10 09:01:45 +02:00
Jan Tojnar 5885dbbe78 Remove pointless stdClass
`DOMNode::$childNodes` always contained `DOMNodeList`.
2024-10-10 09:01:45 +02:00
Jérémy BenoistandGitHub 6947999782 Merge pull request #92 from jtojnar/ci-fix
ci: Fix & add PHP 8.4
2024-10-10 08:59:28 +02:00
Jan Tojnar da755013aa Remove extra set_error_handler callback argument
It is unused and would cause an error on PHP ≥ 8.0:
https://www.php.net/manual/en/function.set-error-handler.php#refsect1-function.set-error-handler-parameters

Not sure if the handler is even necessary – it was introduced in 175196d6c2 but I did not manage to reproduce the original error (Entity 'nbsp' not defined). It was probably fixed by f2a43b476c.
2024-10-10 08:52:28 +02:00
Jan Tojnar 5b9551d1e3 ci: Add PHP 8.4
PHP 8.4 is in beta, with final version scheduled for November so it is time to start testing it.
2024-10-10 01:27:59 +02:00
Jan Tojnar c7b10dcc45 Avoid E_STRICT constant
It will be deprecated in PHP 8.4 and it is meaningless nowadays anyway:
https://wiki.php.net/rfc/deprecations_php_8_4#remove_e_strict_error_level_and_deprecate_e_strict_constant

The use of the constant was introduced in 175196d6c2.
2024-10-10 01:27:59 +02:00
Jan Tojnar 80adfe870b Fix coding style
With php-cs-fixer 3.64.0, the `native_function_invocation` rule no longer passed.
2024-10-10 01:01:55 +02:00
Jérémy BenoistandGitHub cb6b6ac577 Merge pull request #88 from jtojnar/has-single-fix 2024-03-19 06:02:54 +01:00
Jan Tojnar 677f3f096e Fix hasSingleTagInsideElement method
It would fail for e.g. `<div> <p>foo</p> </div>`.

mozilla/readability uses children for the tag lookup, which return only elements.
PHP does not have children property so b580cf216d
mistakenly used `childNodes` instead, but that can return any node type.

Let’s filter the children ourselves.

Also add comments from mozilla/readability’s `_hasSingleTagInsideElement`.
2024-03-18 23:01:43 +01:00
Jérémy BenoistandGitHub 29122763db Merge pull request #89 from jtojnar/php74
Require PHP 7.4
2024-03-18 09:18:11 +01:00
Jan TojnarandJérémy Benoist 89d3b74259 Rectorize to PHP 7.4
Switches to short anonymous function syntax.
2024-03-18 09:16:43 +01:00
Jan TojnarandJérémy Benoist e792644fe8 Drop PHP < 7.4 support
This will allow us to use flexible heredocs in test,
as well as typed properties and other goodies.

https://www.php.net/releases/7_3_0.php
https://www.php.net/releases/7_4_0.php
2024-03-18 09:16:43 +01:00
Jan TojnarandJérémy Benoist 648d8c605b Update coding style for upcoming PHP-CS-Fixer changes
Once we bump minimum PHP version, we will get newer PHP-CS-Fixer,
which will try to apply this cleanups.

Also manually tweak anonymous functions so that they are cleanly formatted
once we switch to `fn` syntax.
2024-03-18 09:16:43 +01:00
Jérémy BenoistandGitHub f28191a728 Merge pull request #86 from jtojnar/ci-bump
ci: Update actions
2024-03-18 09:12:18 +01:00
Jan Tojnar 2103853a1b ci: Bump coveralls to 2.7.0
- Fixes PHP 8 support https://github.com/php-coveralls/php-coveralls/releases/tag/v2.4.3
2024-03-16 22:30:49 +01:00
Jan Tojnar 7f4c6cfcbd ci: Update actions
Mostly just of nodejs bump:

- https://github.com/actions/checkout/releases/tag/v4.0.0
- https://github.com/ramsey/composer-install/releases/tag/3.0.0
2024-03-16 16:01:16 +01:00
9 changed files with 194 additions and 135 deletions
+4 -4
View File
@@ -11,16 +11,16 @@ on:
jobs:
coding-standards:
name: "CS Fixer & PHPStan"
runs-on: "ubuntu-20.04"
runs-on: "ubuntu-22.04"
strategy:
matrix:
php:
- "7.2"
- "7.4"
steps:
- name: "Checkout"
uses: "actions/checkout@v3"
uses: "actions/checkout@v4"
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
@@ -33,7 +33,7 @@ jobs:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v2"
uses: "ramsey/composer-install@v3"
with:
composer-options: "--optimize-autoloader --prefer-dist"
+14 -14
View File
@@ -14,22 +14,22 @@ env:
jobs:
phpunit:
name: "PHPUnit (PHP ${{ matrix.php }})"
runs-on: "ubuntu-20.04"
runs-on: "ubuntu-22.04"
strategy:
matrix:
php:
- "7.2"
- "7.3"
- "7.4"
- "8.0"
- "8.1"
- "8.2"
- "8.3"
- "8.4"
- "8.5"
steps:
- name: "Checkout"
uses: "actions/checkout@v3"
uses: "actions/checkout@v4"
with:
fetch-depth: 2
@@ -48,7 +48,7 @@ jobs:
run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update"
- name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v2"
uses: "ramsey/composer-install@v3"
with:
composer-options: "--optimize-autoloader --prefer-dist"
@@ -60,16 +60,16 @@ jobs:
phpunit-coverage:
name: "PHPUnit coverage (PHP ${{ matrix.php }})"
runs-on: "ubuntu-20.04"
runs-on: "ubuntu-22.04"
strategy:
matrix:
php:
- "7.4"
- "8.0"
steps:
- name: "Checkout"
uses: "actions/checkout@v3"
uses: "actions/checkout@v4"
with:
fetch-depth: 2
@@ -88,7 +88,7 @@ jobs:
run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update"
- name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v2"
uses: "ramsey/composer-install@v3"
with:
composer-options: "--optimize-autoloader --prefer-dist"
@@ -99,7 +99,7 @@ jobs:
run: "php vendor/bin/simple-phpunit -v --coverage-clover build/logs/clover.xml"
- name: "Retrieve Coveralls phar"
run: "wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.2/php-coveralls.phar"
run: "wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.7.0/php-coveralls.phar"
- name: "Enable Coveralls phar"
run: "chmod +x php-coveralls.phar"
@@ -111,16 +111,16 @@ jobs:
phpunit-lowest:
name: "PHPUnit lowest deps (PHP ${{ matrix.php }})"
runs-on: "ubuntu-20.04"
runs-on: "ubuntu-22.04"
strategy:
matrix:
php:
- "7.2"
- "7.4"
steps:
- name: "Checkout"
uses: "actions/checkout@v3"
uses: "actions/checkout@v4"
with:
fetch-depth: 2
@@ -139,7 +139,7 @@ jobs:
run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update"
- name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v2"
uses: "ramsey/composer-install@v3"
with:
composer-options: "--optimize-autoloader --prefer-dist"
dependency-versions: "lowest"
+1
View File
@@ -4,3 +4,4 @@ composer.lock
.php_cs.cache
.php-cs-fixer.cache
.phpunit.result.cache
phpstan.neon
+4
View File
@@ -26,6 +26,10 @@ return (new PhpCsFixer\Config())
'strict_comparison' => true,
'strict_param' => true,
'concat_space' => ['spacing' => 'one'],
// Pulled in by @Symfony:risky but we still support PHP 7.4
'modernize_strpos' => false,
// Pulled in by @Symfony, we cannot add property types until we bump PHP to ≥ 7.4
'no_null_property_initialization' => false,
])
->setFinder($finder)
;
+5 -5
View File
@@ -24,7 +24,7 @@
"role": "Developer (original JS version)"
}],
"require": {
"php": ">=7.2.0",
"php": ">=7.4.0",
"ext-mbstring": "*",
"psr/log": "^1.0.1 || ^2.0 || ^3.0",
"masterminds/html5": "^2.7"
@@ -32,10 +32,10 @@
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"monolog/monolog": "^1.24|^2.1",
"symfony/phpunit-bridge": "^4.4|^5.3|^6.0",
"phpstan/phpstan": "^1.3",
"phpstan/phpstan-phpunit": "^1.0",
"rector/rector": "^0.15.0"
"symfony/phpunit-bridge": "^4.4|^5.3|^6.0|^7.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"rector/rector": "^2.0.0"
},
"suggest": {
"ext-tidy": "Used to clean up given HTML and to avoid problems with bad HTML structure."
-2
View File
@@ -8,8 +8,6 @@ parameters:
bootstrapFiles:
- vendor/bin/.phpunit/phpunit/vendor/autoload.php
checkMissingIterableValueType: false
includes:
- vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-phpunit/rules.neon
+7 -20
View File
@@ -3,29 +3,16 @@
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Core\ValueObject\PhpVersion;
use Rector\Set\ValueObject\LevelSetList;
return static function (RectorConfig $rectorConfig): void {
// paths to refactor; solid alternative to CLI arguments
$rectorConfig->paths([
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
]);
// Path to phpstan with extensions, that PHPSTan in Rector uses to determine types
$rectorConfig->phpstanConfig(__DIR__ . '/phpstan.neon');
$rectorConfig->bootstrapFiles([
])
->withBootstrapFiles([
__DIR__ . '/vendor/bin/.phpunit/phpunit/vendor/autoload.php',
__DIR__ . '/vendor/autoload.php',
]);
// Define what rule sets will be applied
$rectorConfig->sets([
LevelSetList::UP_TO_PHP_72,
]);
// is your PHP version different from the one your refactor to?
$rectorConfig->phpVersion(PhpVersion::PHP_72);
};
])
->withSets([LevelSetList::UP_TO_PHP_74])
;
+91 -60
View File
@@ -88,7 +88,7 @@ class Readability implements LoggerAwareInterface
'enclose-text' => true,
'merge-divs' => true,
// 'merge-spans' => true,
'input-encoding' => '????',
'input-encoding' => 'utf8',
'output-encoding' => 'utf8',
'hide-comments' => true,
];
@@ -142,7 +142,7 @@ class Readability implements LoggerAwareInterface
* @param string $parser Which parser to use for turning raw HTML into a DOMDocument
* @param bool $useTidy Use tidy
*/
public function __construct(string $html, string $url = null, string $parser = 'libxml', bool $useTidy = true)
public function __construct(string $html, ?string $url = null, string $parser = 'libxml', bool $useTidy = true)
{
$this->url = $url;
$this->html = $html;
@@ -302,8 +302,7 @@ class Readability implements LoggerAwareInterface
$articleLinks = $articleContent->getElementsByTagName('a');
$linkCount = 0;
for ($i = 0; $i < $articleLinks->length; ++$i) {
$articleLink = $articleLinks->item($i);
foreach ($articleLinks as $articleLink) {
$footnoteLink = $articleLink->cloneNode(true);
$refLink = $this->dom->createElement('a');
$footnote = $this->dom->createElement('li');
@@ -383,8 +382,8 @@ class Readability implements LoggerAwareInterface
// Remove service data-candidate attribute.
$elems = $xpath->query('.//*[@data-candidate]', $articleContent);
for ($i = $elems->length - 1; $i >= 0; --$i) {
$elems->item($i)->removeAttribute('data-candidate');
foreach ($elems as $elem) {
$elem->removeAttribute('data-candidate');
}
// Clean out junk from the article content.
@@ -520,11 +519,12 @@ class Readability implements LoggerAwareInterface
$textLength = mb_strlen($this->getInnerText($e, true, true));
$linkLength = 0;
for ($dRe = $this->domainRegExp, $i = 0, $il = $links->length; $i < $il; ++$i) {
if ($excludeExternal && $dRe && !preg_match($dRe, $links->item($i)->getAttribute('href'))) {
$dRe = $this->domainRegExp;
foreach ($links as $link) {
if ($excludeExternal && $dRe && !preg_match($dRe, $link->getAttribute('href'))) {
continue;
}
$linkLength += mb_strlen($this->getInnerText($links->item($i)));
$linkLength += mb_strlen($this->getInnerText($link));
}
if ($textLength > 0 && $linkLength > 0) {
@@ -586,7 +586,7 @@ class Readability implements LoggerAwareInterface
}
// Then check the elements inside this element for the same.
if (preg_match($this->regexps['media'], $targetList->item($y)->getInnerHTML())) {
if (preg_match($this->regexps['media'], $currentItem->getInnerHTML())) {
continue;
}
}
@@ -640,15 +640,15 @@ class Readability implements LoggerAwareInterface
$embedCount = 0;
$embeds = $node->getElementsByTagName('embed');
for ($ei = 0, $il = $embeds->length; $ei < $il; ++$ei) {
if (preg_match($this->regexps['media'], $embeds->item($ei)->getAttribute('src'))) {
foreach ($embeds as $embed) {
if (preg_match($this->regexps['media'], $embed->getAttribute('src'))) {
++$embedCount;
}
}
$embeds = $node->getElementsByTagName('iframe');
for ($ei = 0, $il = $embeds->length; $ei < $il; ++$ei) {
if (preg_match($this->regexps['media'], $embeds->item($ei)->getAttribute('src'))) {
foreach ($embeds as $embed) {
if (preg_match($this->regexps['media'], $embed->getAttribute('src'))) {
++$embedCount;
}
}
@@ -668,10 +668,10 @@ class Readability implements LoggerAwareInterface
$this->logger->debug(' content length less than 6 chars, 0 embeds and either 0 images or more than 2 images');
$toRemove = true;
} elseif (!$isList && $weight < 25 && $linkDensity > 0.25) {
$this->logger->debug(' weight is ' . $weight . ' < 25 and link density is ' . sprintf('%.2f', $linkDensity) . ' > 0.25');
$this->logger->debug(' weight is ' . $weight . ' < 25 and link density is ' . \sprintf('%.2f', $linkDensity) . ' > 0.25');
$toRemove = true;
} elseif ($a > 2 && ($weight >= 25 && $linkDensity > 0.5)) {
$this->logger->debug(' more than 2 links and weight is ' . $weight . ' > 25 but link density is ' . sprintf('%.2f', $linkDensity) . ' > 0.5');
$this->logger->debug(' more than 2 links and weight is ' . $weight . ' > 25 but link density is ' . \sprintf('%.2f', $linkDensity) . ' > 0.5');
$toRemove = true;
} elseif ($embedCount > 3) {
$this->logger->debug(' more than 3 embeds');
@@ -691,10 +691,10 @@ class Readability implements LoggerAwareInterface
$this->logger->debug(' content length less than 10 chars and 0 images, or more than 2 images');
$toRemove = true;
} elseif (!$isList && $weight < 25 && $linkDensity > 0.2) {
$this->logger->debug(' weight is ' . $weight . ' lower than 0 and link density is ' . sprintf('%.2f', $linkDensity) . ' > 0.2');
$this->logger->debug(' weight is ' . $weight . ' lower than 0 and link density is ' . \sprintf('%.2f', $linkDensity) . ' > 0.2');
$toRemove = true;
} elseif ($weight >= 25 && $linkDensity > 0.5) {
$this->logger->debug(' weight above 25 but link density is ' . sprintf('%.2f', $linkDensity) . ' > 0.5');
$this->logger->debug(' weight above 25 but link density is ' . \sprintf('%.2f', $linkDensity) . ' > 0.5');
$toRemove = true;
} elseif ((1 === $embedCount && $contentLength < 75) || $embedCount > 1) {
$this->logger->debug(' 1 embed and content length smaller than 75 chars, or more than one embed');
@@ -719,8 +719,9 @@ class Readability implements LoggerAwareInterface
$headers = $e->getElementsByTagName('h' . $headerIndex);
for ($i = $headers->length - 1; $i >= 0; --$i) {
if ($this->getWeight($headers->item($i)) < 0 || $this->getLinkDensity($headers->item($i)) > 0.33) {
$headers->item($i)->parentNode->removeChild($headers->item($i));
$header = $headers->item($i);
if ($this->getWeight($header) < 0 || $this->getLinkDensity($header) > 0.33) {
$header->parentNode->removeChild($header);
}
}
}
@@ -739,7 +740,7 @@ class Readability implements LoggerAwareInterface
*/
public function addFlag(int $flag): void
{
$this->flags = $this->flags | $flag;
$this->flags |= $flag;
}
/**
@@ -747,7 +748,7 @@ class Readability implements LoggerAwareInterface
*/
public function removeFlag(int $flag): void
{
$this->flags = $this->flags & ~$flag;
$this->flags &= ~$flag;
}
/**
@@ -812,12 +813,14 @@ class Readability implements LoggerAwareInterface
// Remove all style tags in head.
$styleTags = $this->dom->getElementsByTagName('style');
for ($i = $styleTags->length - 1; $i >= 0; --$i) {
$styleTags->item($i)->parentNode->removeChild($styleTags->item($i));
$styleTag = $styleTags->item($i);
$styleTag->parentNode->removeChild($styleTag);
}
$linkTags = $this->dom->getElementsByTagName('link');
for ($i = $linkTags->length - 1; $i >= 0; --$i) {
$linkTags->item($i)->parentNode->removeChild($linkTags->item($i));
$linkTag = $linkTags->item($i);
$linkTag->parentNode->removeChild($linkTag);
}
}
@@ -893,11 +896,9 @@ class Readability implements LoggerAwareInterface
* Using a variety of metrics (content score, classname, element types), find the content that is
* most likely to be the stuff a user wants to read. Then return it wrapped up in a div.
*
* @param \DOMElement $page
*
* @return \DOMElement|false
*/
protected function grabArticle(\DOMElement $page = null)
protected function grabArticle(?\DOMElement $page = null)
{
if (!$page) {
$page = $this->dom;
@@ -933,9 +934,9 @@ class Readability implements LoggerAwareInterface
// Remove unlikely candidates
$unlikelyMatchString = $node->getAttribute('class') . ' ' . $node->getAttribute('id') . ' ' . $node->getAttribute('style');
if (mb_strlen($unlikelyMatchString) > 3 && // don't process "empty" strings
preg_match($this->regexps['unlikelyCandidates'], $unlikelyMatchString) &&
!preg_match($this->regexps['okMaybeItsACandidate'], $unlikelyMatchString)
if (mb_strlen($unlikelyMatchString) > 3 // don't process "empty" strings
&& preg_match($this->regexps['unlikelyCandidates'], $unlikelyMatchString)
&& !preg_match($this->regexps['okMaybeItsACandidate'], $unlikelyMatchString)
) {
$this->logger->debug('Removing unlikely candidate (using conf) ' . $node->getNodePath() . ' by "' . $unlikelyMatchString . '"');
$node->parentNode->removeChild($node);
@@ -1017,15 +1018,15 @@ class Readability implements LoggerAwareInterface
* A score is determined by things like number of commas, class names, etc.
* Maybe eventually link density.
*/
for ($pt = 0, $scored = \count($nodesToScore); $pt < $scored; ++$pt) {
$ancestors = $this->getAncestors($nodesToScore[$pt], 5);
foreach ($nodesToScore as $nodeToScore) {
$ancestors = $this->getAncestors($nodeToScore, 5);
// No parent node? Move on...
if (0 === \count($ancestors)) {
continue;
}
$innerText = $this->getInnerText($nodesToScore[$pt]);
$innerText = $this->getInnerText($nodeToScore);
// If this paragraph is less than MIN_PARAGRAPH_LENGTH (default:20) characters, don't even count it.
if (mb_strlen($innerText) < self::MIN_PARAGRAPH_LENGTH) {
@@ -1078,11 +1079,6 @@ class Readability implements LoggerAwareInterface
}
}
$candidates = $xpath->query('.//*[not(self::body) and (@class or @id or @style) and ((number(@readability) < 40) or not(@readability))]', $page->documentElement);
for ($c = $candidates->length - 1; $c >= 0; --$c) {
$node = $candidates->item($c);
}
unset($candidates);
}
@@ -1120,9 +1116,11 @@ class Readability implements LoggerAwareInterface
}
}
$topCandidates = array_filter($topCandidates, function ($v, $idx) {
return 0 === $idx || null !== $v;
}, \ARRAY_FILTER_USE_BOTH);
$topCandidates = array_filter(
$topCandidates,
static fn ($v, $idx) => 0 === $idx || null !== $v,
\ARRAY_FILTER_USE_BOTH
);
$topCandidate = $topCandidates[0];
/*
@@ -1231,11 +1229,6 @@ class Readability implements LoggerAwareInterface
$parentOfTopCandidate = $topCandidate->parentNode;
$siblingNodes = $parentOfTopCandidate->childNodes;
if (0 === $siblingNodes->length) {
$siblingNodes = new \stdClass();
$siblingNodes->length = 0;
}
for ($s = 0, $sl = $siblingNodes->length; $s < $sl; ++$s) {
$siblingNode = $siblingNodes->item($s);
$siblingNodeName = $siblingNode->nodeName;
@@ -1390,7 +1383,10 @@ class Readability implements LoggerAwareInterface
$this->logger->debug('Parsing URL: ' . $this->url);
if ($this->url) {
$this->domainRegExp = '/' . strtr((string) preg_replace('/www\d*\./', '', (string) parse_url($this->url, \PHP_URL_HOST)), ['.' => '\.']) . '/';
$host = parse_url($this->url, \PHP_URL_HOST);
if (null !== $host) {
$this->domainRegExp = '/' . strtr((string) preg_replace('/www\d*\./', '', $host), ['.' => '\.']) . '/';
}
}
mb_internal_encoding('UTF-8');
@@ -1426,7 +1422,7 @@ class Readability implements LoggerAwareInterface
unset($tidy);
}
$this->html = '<meta charset="utf-8">' . (string) $this->html;
$this->html = self::entitizeNonAscii((string) $this->html);
if ('html5lib' === $this->parser || 'html5' === $this->parser) {
$this->dom = (new HTML5())->loadHTML($this->html);
@@ -1442,7 +1438,7 @@ class Readability implements LoggerAwareInterface
libxml_use_internal_errors(false);
}
$this->dom->registerNodeClass(\DOMElement::class, \Readability\JSLikeHTMLElement::class);
$this->dom->registerNodeClass(\DOMElement::class, JSLikeHTMLElement::class);
}
private function getAncestors(\DOMElement $node, int $maxDepth = 0): array
@@ -1464,21 +1460,38 @@ class Readability implements LoggerAwareInterface
{
return \XML_TEXT_NODE === $node->nodeType
|| \in_array(strtoupper($node->nodeName), $this->phrasingElements, true)
|| (\in_array(strtoupper($node->nodeName), ['A', 'DEL', 'INS'], true) && !\in_array(false, array_map(function ($c) {
return $this->isPhrasingContent($c);
}, iterator_to_array($node->childNodes)), true));
|| (
\in_array(strtoupper($node->nodeName), ['A', 'DEL', 'INS'], true)
&& !\in_array(
false,
array_map(
fn ($c) => $this->isPhrasingContent($c),
iterator_to_array($node->childNodes)
),
true
)
);
}
/**
* Checks if `$node` has only whitespace and a single element with `$tag` for the tag name.
* Returns false if `$node` contains non-empty text nodes
* or if it contains no element with given tag or more than 1 element.
*/
private function hasSingleTagInsideElement(\DOMElement $node, string $tag): bool
{
if (1 !== $node->childNodes->length || $node->childNodes->item(0)->nodeName !== $tag) {
$childNodes = iterator_to_array($node->childNodes);
$children = array_filter($childNodes, static fn ($childNode) => $childNode instanceof \DOMElement);
// There should be exactly 1 element child with given tag
if (1 !== \count($children) || $children[0]->nodeName !== $tag) {
return false;
}
$a = array_filter(iterator_to_array($node->childNodes), function ($childNode) {
return $childNode instanceof \DOMText &&
preg_match($this->regexps['hasContent'], $this->getInnerText($childNode));
});
$a = array_filter(
$childNodes,
fn ($childNode) => $childNode instanceof \DOMText && preg_match($this->regexps['hasContent'], $this->getInnerText($childNode))
);
return 0 === \count($a);
}
@@ -1491,9 +1504,27 @@ class Readability implements LoggerAwareInterface
*/
private function isNodeVisible(\DOMElement $node): bool
{
return !($node->hasAttribute('style')
&& preg_match($this->regexps['isNotVisible'], $node->getAttribute('style'))
return !(
$node->hasAttribute('style')
&& preg_match($this->regexps['isNotVisible'], $node->getAttribute('style'))
)
&& !$node->hasAttribute('hidden');
&& !$node->hasAttribute('hidden');
}
/**
* Converts non-ASCII UTF-8 characters to numeric HTML entities.
*
* `DOMDocument::loadHTML` will parse HTML documents as ISO-8859-1 if there is no `meta[charset]` tag.
* This means that UTF-8-encoded HTML fragments such as those coming from JSON-LD `articleBody` field would be parsed with incorrect encoding.
*
* @param string $html UTF-8 encoded document
*/
private static function entitizeNonAscii(string $html): string
{
$convmap = [
0x80, 0x1FFFFF, 0, 0x10FFFF,
];
return mb_encode_numericentity($html, $convmap, 'utf8', true);
}
}
+68 -30
View File
@@ -21,10 +21,11 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testConstructDefault(): void
{
$readability = $this->getReadability('');
$this->assertSame('utf8', $readability->tidy_config['input-encoding']);
$readability->init();
$this->assertNull($readability->url);
$this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom);
}
public function testConstructHtml5Parser(): void
@@ -33,7 +34,7 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$readability->init();
$this->assertSame('http://0.0.0.0', $readability->url);
$this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom);
$this->assertSame('<html/>', $readability->original_html);
}
@@ -46,7 +47,7 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$readability->init();
$this->assertSame('http://0.0.0.0', $readability->url);
$this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom);
$this->assertSame('<html/>', $readability->original_html);
$this->assertTrue($readability->tidied);
}
@@ -60,7 +61,7 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertSame('', $readability->original_html);
$this->assertFalse($readability->tidied);
$this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom);
}
public function testConstructSimpleWithoutTidy(): void
@@ -69,7 +70,7 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$readability->init();
$this->assertSame('http://0.0.0.0', $readability->url);
$this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom);
$this->assertSame('<html/>', $readability->original_html);
$this->assertFalse($readability->tidied);
}
@@ -115,7 +116,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testInitDiv(): void
{
$readability = $this->getReadability('<div>' . str_repeat('This is the awesome content :)', 7) . '</div>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -129,7 +129,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithFootnotes(): void
{
$readability = $this->getReadability('<div>' . str_repeat('<p>This is an awesome text with some links, here there are: <a href="http://0.0.0.0/test.html">the awesome</a></p>', 7) . '</div>', 'http://0.0.0.0');
$readability->debug = true;
$readability->convertLinksToFootnotes = true;
$res = $readability->init();
@@ -146,7 +145,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testStandardClean(): void
{
$readability = $this->getReadability('<div><h2>Title</h2>' . str_repeat('<p>This is an awesome text with some links, here there are: <a href="http://0.0.0.0/test.html">the awesome</a></p>', 7) . '<a href="#nofollow" rel="nofollow">will NOT be removed</a></div>', 'http://0.0.0.0');
$readability->debug = true;
$readability->lightClean = false;
$res = $readability->init();
@@ -163,7 +161,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithIframe(): void
{
$readability = $this->getReadability('<div><h2>Title</h2>' . str_repeat('<p>This is an awesome text with some links, here there are: <a href="http://0.0.0.0/test.html">the awesome</a></p>', 7) . '<p>This is an awesome text with some links, here there are <iframe src="http://youtube.com/test" href="#nofollow" rel="nofollow"></iframe><iframe>http://soundcloud.com/test</iframe></p></div>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -178,7 +175,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithArticle(): void
{
$readability = $this->getReadability('<article><p>' . str_repeat('This is an awesome text with some links, here there are: the awesome', 20) . '</p><p>This is an awesome text with some links, here there are <iframe src="http://youtube.com/test" href="#nofollow" rel="nofollow"></iframe></p></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -193,7 +189,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithAside(): void
{
$readability = $this->getReadability('<article>' . str_repeat('<p>This is an awesome text with some links, here there are: <a href="http://0.0.0.0/test.html">the awesome</a></p>', 7) . '<footer><aside>' . str_repeat('<p>This is an awesome text with some links, here there are</p>', 8) . '</aside></footer></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -208,7 +203,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithClasses(): void
{
$readability = $this->getReadability('<article>' . str_repeat('<p>This is an awesome text with some links, here there are: <a href="http://0.0.0.0/test.html">the awesome</a></p>', 7) . '<div style="display:none">' . str_repeat('<p class="clock">This text should be removed</p>', 10) . '</div></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -223,7 +217,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithClassesWithoutLightClean(): void
{
$readability = $this->getReadability('<article>' . str_repeat('<p>This is an awesome text with some links, here there are: <a href="http://0.0.0.0/test.html">the awesome</a></p>', 7) . '<div style="display:none">' . str_repeat('<p class="clock">This text should be removed</p>', 10) . '</div></article>', 'http://0.0.0.0');
$readability->debug = true;
$readability->lightClean = false;
$res = $readability->init();
@@ -239,7 +232,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithTd(): void
{
$readability = $this->getReadability('<table><tr>' . str_repeat('<td><p>This is an awesome text with some links, here there are the awesome</td>', 7) . '</tr></table>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -252,7 +244,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithSameClasses(): void
{
$readability = $this->getReadability('<article class="awesomecontent">' . str_repeat('<p>This is an awesome text with some links, here there are the awesome</p>', 7) . '<div class="awesomecontent">This text is also an awesome text and you should know that !</div></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -266,7 +257,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testWithScript(): void
{
$readability = $this->getReadability('<article class="awesomecontent">' . str_repeat('<p>This is an awesome text with some links, here there are the awesome</p>', 7) . '<p><script>This text is also an awesome text and you should know that !</script></p></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -280,7 +270,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testTitle(): void
{
$readability = $this->getReadability('<title>this is my title</title><article class="awesomecontent">' . str_repeat('<p>This is an awesome text with some links, here there are the awesome</p>', 7) . '<p></p></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -294,7 +283,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testTitleWithDash(): void
{
$readability = $this->getReadability('<title> title2 - title3 </title><article class="awesomecontent">' . str_repeat('<p>This is an awesome text with some links, here there are the awesome</p>', 7) . '<p></p></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -308,7 +296,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testTitleWithDoubleDot(): void
{
$readability = $this->getReadability('<title> title2 : title3 </title><article class="awesomecontent">' . str_repeat('<p>This is an awesome text with some links, here there are the awesome</p>', 7) . '<p></p></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -322,7 +309,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testTitleTooShortUseH1(): void
{
$readability = $this->getReadability('<title>too short</title><h1>this is my h1 title !</h1><article class="awesomecontent">' . str_repeat('<p>This is an awesome text with some links, here there are the awesome</p>', 7) . '<p></p></article>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -335,12 +321,12 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testAutoClosingIframeNotThrowingException(): void
{
$oldErrorReporting = error_reporting(\E_ALL | \E_STRICT);
$oldErrorReporting = error_reporting(\E_ALL);
$oldDisplayErrors = ini_set('display_errors', '1');
// dummy function to be used to the next test
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline, array $errcontext) {
set_error_handler(static function (int $errno, string $errstr, string $errfile, int $errline) {
throw new \Exception($errstr, $errno);
}, \E_ALL | \E_STRICT);
});
try {
$data = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
@@ -369,7 +355,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
</html>';
$readability = $this->getReadability($data, 'http://iosgames.ru/?p=22030');
$readability->debug = true;
$res = $readability->init();
@@ -437,7 +422,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
</html>';
$readability = $this->getReadability($data, 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
@@ -474,7 +458,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$html = (string) file_get_contents('tests/fixtures/childNodeGoesNull.html');
$readability = $this->getReadability($html, 'http://0.0.0.0');
$readability->debug = true;
$readability->convertLinksToFootnotes = true;
$res = $readability->init();
@@ -487,7 +470,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$html = (string) file_get_contents('tests/fixtures/keepFootnotes.html');
$readability = $this->getReadability($html, 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -501,7 +483,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$html = (string) file_get_contents('tests/fixtures/wipedBody.html');
$readability = $this->getReadability($html, 'http://0.0.0.0', 'libxml', false);
$readability->debug = true;
$res = $readability->init();
$this->assertTrue($res);
@@ -540,7 +521,6 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testVisibleNode(string $content, bool $shouldBeVisible): void
{
$readability = $this->getReadability($content, 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init();
if ($shouldBeVisible) {
@@ -550,7 +530,65 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
}
}
private function getReadability(string $html, string $url = null, string $parser = 'libxml', bool $useTidy = true): Readability
// https://github.com/wallabag/wallabag/issues/8158
public function testCharsetAfterTitle(): void
{
$readability = $this->getReadability('<!DOCTYPE html><html lang="et"><head><title>Tõde ja õigus I</title> <meta charset="utf-8"></head><body><p>See oli läinud aastasaja kolmanda veerandi lõpul. Päike lähenes silmapiirile, seistes sedavõrd madalas, et enam ei ulatunud valgustama ei mäkke ronivat hobust, kes puutelgedega vankrit vedas, ei vankril istuvat noort naist ega ka ligi kolmekümnelist meest, kes kõndis vankri kõrval.</p></body></html>', 'https://et.wikisource.org/wiki/T%C3%B5de_ja_%C3%B5igus_I/I');
$readability->convertLinksToFootnotes = true;
$res = $readability->init();
$this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle());
$this->assertSame('Tõde ja õigus I', $readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('Päike lähenes', $readability->getContent()->getInnerHtml());
}
/**
* @return array<string, array{0: string, 1: string, 2?: bool}>
*/
public function dataForHtmlLang(): array
{
return [
'meta' => [
'<html lang="fr"><head><meta charset="utf-8"></head><body><article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article></body></html>',
'fr',
],
'head' => [
'<html lang="fr"><head><title>Foo</title></head><body><article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article></body></html>',
'fr',
],
'headless' => [
'<html lang="fr"><body><article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article></body></html>',
'fr',
// tidy would add <head> tag.
false,
],
'fragment' => [
'<article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article>',
'',
// tidy would add <html>.
false,
],
];
}
/**
* @dataProvider dataForHtmlLang
*/
public function testHtmlLang(string $html, string $lang, bool $useTidy = true): void
{
$readability = $this->getReadability($html, 'http://0.0.0.0', 'libxml', $useTidy);
$res = $readability->init();
$this->assertTrue($res);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom);
$this->assertSame($lang, $readability->dom->documentElement->getAttribute('lang'));
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent());
$this->assertStringContainsString('êtres', $readability->getContent()->getInnerHtml());
}
private function getReadability(string $html, ?string $url = null, string $parser = 'libxml', bool $useTidy = true): Readability
{
$readability = new Readability($html, $url, $parser, $useTidy);