Compare commits

..
22 Commits
Author SHA1 Message Date
Jérémy BenoistandGitHub 38870cdff1 Merge pull request #80 from jtojnar/stricter
Fix some CI issues
2023-04-03 14:47:32 +02:00
Jan Tojnar 9bdd3b6b2e ci: Add PHP 8.2 and 8.3 2023-03-31 05:26:07 +02:00
Jan Tojnar f14428e4c0 Do not use mb_convert_encoding with HTML-ENTITIES as target encoding
This is deprecated since PHP 8.2:

    Deprecated: mb_convert_encoding(): Handling HTML entities via mbstring is deprecated; use htmlspecialchars, htmlentities, or mb_encode_numericentity/mb_decode_numericentity instead

It was used because `DOMDocument`, which uses libxml2 internally, will parse the HTML as ISO-8859-1, unless the document contains an XML encoding declaration or HTML meta tag setting character set.
Since first such element wins, putting the `meta[charset]` up front will ensure the parser uses the correct encoding, even if the document contains incorrect meta tag (e.g. when the document is converted to UTF-8 without also updating the metadata by the software passing it to Readability).

https://stackoverflow.com/a/39148511/160386
2023-03-31 05:26:07 +02:00
Jan Tojnar 23f824a1ce tests: Fix “THE ERROR HANDLER HAS CHANGED!” 2023-03-31 03:19:22 +02:00
Jan Tojnar 2a57124528 composer: upgrade rector 2023-03-31 03:19:22 +02:00
Jan Tojnar 0975574bdb Rector: Upgrade configuration 2023-03-31 03:19:22 +02:00
Jan Tojnar 9ed89bde92 Fix PHP-Cs-Fixer changes
1) src/Readability.php (braces, no_unneeded_control_parentheses, single_line_comment_spacing, global_namespace_import, no_unused_imports, phpdoc_align)
   2) src/JSLikeHTMLElement.php (phpdoc_separation)

Switch code blocks to Markdown syntax to work around `phpdoc_separation`, ApiGen uses Markdown these days anyway.
2023-03-31 03:14:00 +02:00
Jan Tojnar 2c6c6d5987 PHPStan: Use stable PHPUnit path
phpunit-bridge will create a symlink.
2023-03-31 03:14:00 +02:00
Jan Tojnar c5407ec07c composer: Add scripts for development 2023-03-31 03:14:00 +02:00
Jérémy BenoistandGitHub 7cd8476d38 Merge pull request #79 from j0k3r/fix/psr-log-2-3
Allow `psr/log` 2.0 & 3.0
2022-10-17 22:44:36 +02:00
Jeremy Benoist 82083c872b Allow psr/log 2.0 & 3.0 2022-10-17 22:42:47 +02:00
Kevin DecherfandJeremy Benoist 6689f19956 Strip script and style tags through ::clean() method instead of preg_replace
Huge tags can lead to a failure of preg_replace, thus erasing the whole
fetched content.

Fixes https://github.com/wallabag/wallabag/issues/5847

Signed-off-by: Kevin Decherf <kevin@kdecherf.com>
2022-06-13 09:13:23 +02:00
Jérémy BenoistandGitHub 0c0653dad6 Merge pull request #73 from Kdecherf/fix/impr
Fix `isPhrasingContent` conditions, text node replacement
2022-02-16 00:03:37 +01:00
Kevin Decherf 2ab87d7445 Fix isPhrasingContent conditions, text node replacement
It also disables reverting forced paragraph elements as it can break
layouts or corrupt content.

Signed-off-by: Kevin Decherf <kevin@kdecherf.com>
2022-02-15 20:53:28 +01:00
Jérémy BenoistandGitHub 8af69ad68c Merge pull request #71 from j0k3r/feature/enable-rector
Add Rector
2022-02-04 12:15:57 +01:00
Jeremy Benoist c2a1639b34 Add Rector 2022-02-04 12:13:37 +01:00
Jérémy BenoistandGitHub ccf1b336c5 Merge pull request #64 from Kdecherf/improvements 2022-02-04 05:27:57 +01:00
Kevin Decherf a44c4e5482 Add routine to remove invisible nodes
Readability was previously removing (was trying to actually, see next
section) invisible nodes using a pattern from `unlikelyCandidates`. This
was quite hacky and was removed during a backport of logics from
mozilla/readability. There is still a need to remove them so here we
are. We still use a pattern but specifically against the style
attribute. We also remove nodes with the attribute `hidden`.

The clean feature of tidy actually replaces inline style attributes
with css classes thus preventing readability to detect invisible nodes,
see https://github.com/htacg/tidy-html5/blob/5.6.0/src/clean.c#L1488
We therefore set clean configuration to false.

Signed-off-by: Kevin Decherf <kevin@kdecherf.com>
2022-02-04 01:23:11 +01:00
Kevin Decherf b580cf216d Backport some logics from mozilla/readability
This change backports several things from mozilla/readability:

- Add child score to all ancestors instead of the first parent only
- Check 5 top candidates and try to find alternative candidates within
  ancestors, this can help to find a better parent and grab more content
- Reduce patterns from `unlikelyCandidates` to the one used by Mozilla
  as ours tend to remove useful nodes
- Score headers (h2 to h6) by default in addition to div, p, td and
  section

Signed-off-by: Kevin Decherf <kevin@kdecherf.com>
2022-02-04 01:05:13 +01:00
Jérémy BenoistandGitHub 2e9349f076 Merge pull request #69 from j0k3r/feature/php-7.2
Require PHP >= 7.2
2022-02-02 12:53:28 +01:00
Jeremy Benoist c4bba53dbe Remove Scrutinizer 2022-02-02 12:52:12 +01:00
Jeremy Benoist 66215a6c80 Require PHP >= 7.2
- remove test on Composer v1
- remove deprecated function
- move `loadHtml()` into `init()` instead of `__construct`

Kinda prepare 2.0 version :)
2022-02-02 12:44:24 +01:00
13 changed files with 506 additions and 460 deletions
+1
View File
@@ -6,5 +6,6 @@
/.php_cs export-ignore /.php_cs export-ignore
/phpunit.xml.dist export-ignore /phpunit.xml.dist export-ignore
/phpstan.neon export-ignore /phpstan.neon export-ignore
/rector.php export-ignore
/.github export-ignore /.github export-ignore
/tests export-ignore /tests export-ignore
+4 -12
View File
@@ -4,28 +4,23 @@ on:
pull_request: pull_request:
branches: branches:
- master - master
- 1.x
push: push:
branches: branches:
- master - master
- 1.x
env:
SYMFONY_PHPUNIT_VERSION: 7.5
jobs: jobs:
coding-standards: coding-standards:
name: "CS Fixer & PHPStan" name: "CS Fixer & PHPStan"
runs-on: "ubuntu-latest" runs-on: "ubuntu-20.04"
strategy: strategy:
matrix: matrix:
php: php:
- "7.4" - "7.2"
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: "actions/checkout@v4" uses: "actions/checkout@v3"
- name: "Install PHP" - name: "Install PHP"
uses: "shivammathur/setup-php@v2" uses: "shivammathur/setup-php@v2"
@@ -37,11 +32,8 @@ jobs:
env: env:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Add PHPStan"
run: "composer require phpstan/phpstan phpstan/phpstan-phpunit --dev --no-progress --no-suggest"
- name: "Install dependencies with Composer" - name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v3" uses: "ramsey/composer-install@v2"
with: with:
composer-options: "--optimize-autoloader --prefer-dist" composer-options: "--optimize-autoloader --prefer-dist"
+10 -13
View File
@@ -4,11 +4,9 @@ on:
pull_request: pull_request:
branches: branches:
- "master" - "master"
- "1.x"
push: push:
branches: branches:
- "master" - "master"
- "1.x"
env: env:
fail-fast: true fail-fast: true
@@ -16,7 +14,7 @@ env:
jobs: jobs:
phpunit: phpunit:
name: "PHPUnit (PHP ${{ matrix.php }})" name: "PHPUnit (PHP ${{ matrix.php }})"
runs-on: "ubuntu-latest" runs-on: "ubuntu-20.04"
strategy: strategy:
matrix: matrix:
@@ -28,11 +26,10 @@ jobs:
- "8.1" - "8.1"
- "8.2" - "8.2"
- "8.3" - "8.3"
- "8.4"
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: "actions/checkout@v4" uses: "actions/checkout@v3"
with: with:
fetch-depth: 2 fetch-depth: 2
@@ -51,7 +48,7 @@ jobs:
run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update" run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update"
- name: "Install dependencies with Composer" - name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v3" uses: "ramsey/composer-install@v2"
with: with:
composer-options: "--optimize-autoloader --prefer-dist" composer-options: "--optimize-autoloader --prefer-dist"
@@ -63,7 +60,7 @@ jobs:
phpunit-coverage: phpunit-coverage:
name: "PHPUnit coverage (PHP ${{ matrix.php }})" name: "PHPUnit coverage (PHP ${{ matrix.php }})"
runs-on: "ubuntu-latest" runs-on: "ubuntu-20.04"
strategy: strategy:
matrix: matrix:
@@ -72,7 +69,7 @@ jobs:
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: "actions/checkout@v4" uses: "actions/checkout@v3"
with: with:
fetch-depth: 2 fetch-depth: 2
@@ -91,7 +88,7 @@ jobs:
run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update" run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update"
- name: "Install dependencies with Composer" - name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v3" uses: "ramsey/composer-install@v2"
with: with:
composer-options: "--optimize-autoloader --prefer-dist" composer-options: "--optimize-autoloader --prefer-dist"
@@ -114,16 +111,16 @@ jobs:
phpunit-lowest: phpunit-lowest:
name: "PHPUnit lowest deps (PHP ${{ matrix.php }})" name: "PHPUnit lowest deps (PHP ${{ matrix.php }})"
runs-on: "ubuntu-latest" runs-on: "ubuntu-20.04"
strategy: strategy:
matrix: matrix:
php: php:
- "7.4" - "7.2"
steps: steps:
- name: "Checkout" - name: "Checkout"
uses: "actions/checkout@v4" uses: "actions/checkout@v3"
with: with:
fetch-depth: 2 fetch-depth: 2
@@ -142,7 +139,7 @@ jobs:
run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update" run: "composer remove friendsofphp/php-cs-fixer --dev --no-progress --no-update"
- name: "Install dependencies with Composer" - name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v3" uses: "ramsey/composer-install@v2"
with: with:
composer-options: "--optimize-autoloader --prefer-dist" composer-options: "--optimize-autoloader --prefer-dist"
dependency-versions: "lowest" dependency-versions: "lowest"
+1 -1
View File
@@ -2,5 +2,5 @@ vendor/
coverage/ coverage/
composer.lock composer.lock
.php_cs.cache .php_cs.cache
.php-cs-fixer.cache
.phpunit.result.cache .phpunit.result.cache
phpstan.neon
-4
View File
@@ -26,10 +26,6 @@ return (new PhpCsFixer\Config())
'strict_comparison' => true, 'strict_comparison' => true,
'strict_param' => true, 'strict_param' => true,
'concat_space' => ['spacing' => 'one'], 'concat_space' => ['spacing' => 'one'],
// Pulled in by @Symfony, we cannot add property types until we bump PHP to ≥ 7.4
'no_null_property_initialization' => false,
// Pulled in by @Symfony with `const` but const visibility requires PHP ≥ 7.1
'visibility_required' => ['elements' => ['method', 'property']],
]) ])
->setFinder($finder) ->setFinder($finder)
; ;
-12
View File
@@ -1,12 +0,0 @@
tools:
external_code_coverage: false
build:
nodes:
analysis:
tests:
override:
- php-scrutinizer-run
environment:
php:
version: 7.2
+1 -1
View File
@@ -5,7 +5,7 @@
[![Total Downloads](https://poser.pugx.org/j0k3r/php-readability/downloads)](https://packagist.org/packages/j0k3r/php-readability) [![Total Downloads](https://poser.pugx.org/j0k3r/php-readability/downloads)](https://packagist.org/packages/j0k3r/php-readability)
[![License](https://poser.pugx.org/j0k3r/php-readability/license)](https://packagist.org/packages/j0k3r/php-readability) [![License](https://poser.pugx.org/j0k3r/php-readability/license)](https://packagist.org/packages/j0k3r/php-readability)
This is an extract of the Readability class from this [full-text-rss](https://github.com/Dither/full-text-rss) fork. It can be defined as a better version of the original [php-readability](https://bitbucket.org/fivefilters/php-readability/overview). This is an extract of the Readability class from this [full-text-rss](https://github.com/Dither/full-text-rss) fork. It can be defined as a better version of the original [php-readability](https://bitbucket.org/fivefilters/php-readability).
## Differences ## Differences
+8 -4
View File
@@ -24,15 +24,18 @@
"role": "Developer (original JS version)" "role": "Developer (original JS version)"
}], }],
"require": { "require": {
"php": ">=7.2", "php": ">=7.2.0",
"ext-mbstring": "*", "ext-mbstring": "*",
"psr/log": "^1.0.1 || ^2.0 || ^3.0", "psr/log": "^1.0.1 || ^2.0 || ^3.0",
"masterminds/html5": "^2.7" "masterminds/html5": "^2.7"
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^2.14", "friendsofphp/php-cs-fixer": "^3.0",
"monolog/monolog": "^1.24|^2.1|^3.0", "monolog/monolog": "^1.24|^2.1",
"symfony/phpunit-bridge": "^4.4|^5.3|^6.0|^7.0" "symfony/phpunit-bridge": "^4.4|^5.3|^6.0",
"phpstan/phpstan": "^1.3",
"phpstan/phpstan-phpunit": "^1.0",
"rector/rector": "^0.15.0"
}, },
"suggest": { "suggest": {
"ext-tidy": "Used to clean up given HTML and to avoid problems with bad HTML structure." "ext-tidy": "Used to clean up given HTML and to avoid problems with bad HTML structure."
@@ -46,6 +49,7 @@
"scripts": { "scripts": {
"fix": "php-cs-fixer fix --verbose --diff", "fix": "php-cs-fixer fix --verbose --diff",
"phpstan": "phpstan analyze --memory-limit 512M", "phpstan": "phpstan analyze --memory-limit 512M",
"rector": "rector process",
"test": "simple-phpunit -v" "test": "simple-phpunit -v"
} }
} }
+2
View File
@@ -8,6 +8,8 @@ parameters:
bootstrapFiles: bootstrapFiles:
- vendor/bin/.phpunit/phpunit/vendor/autoload.php - vendor/bin/.phpunit/phpunit/vendor/autoload.php
checkMissingIterableValueType: false
includes: includes:
- vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-phpunit/rules.neon - vendor/phpstan/phpstan-phpunit/rules.neon
+1 -1
View File
@@ -11,7 +11,7 @@
> >
<testsuites> <testsuites>
<testsuite name="Readability Test Suite"> <testsuite name="Readability">
<directory>./tests/</directory> <directory>./tests/</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
+31
View File
@@ -0,0 +1,31 @@
<?php
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([
__DIR__ . '/src',
__DIR__ . '/tests',
]);
// Path to phpstan with extensions, that PHPSTan in Rector uses to determine types
$rectorConfig->phpstanConfig(__DIR__ . '/phpstan.neon');
$rectorConfig->bootstrapFiles([
__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);
};
+287 -258
View File
@@ -7,68 +7,25 @@ use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
/**
* Arc90's Readability ported to PHP for FiveFilters.org
* Based on readability.js version 1.7.1 (without multi-page support)
* ------------------------------------------------------
* Original URL: http://lab.arc90.com/experiments/readability/js/readability.js
* Arc90's project URL: http://lab.arc90.com/experiments/readability/
* JS Source: http://code.google.com/p/arc90labs-readability
* Ported by: Keyvan Minoukadeh, http://www.keyvan.net
* Modded by: Dither, https://dithersky.wordpress.com
* More information: http://fivefilters.org/content-only/
* License: Apache License, Version 2.0
* Requires: PHP version 5.2.0+
* Date: 2013-08-02.
*
* Differences between the PHP port and the original
* ------------------------------------------------------
* Arc90's Readability is designed to run in the browser. It works on the DOM
* tree (the parsed HTML) after the page's CSS styles have been applied and
* Javascript code executed. This PHP port does not run inside a browser.
* We use PHP's ability to parse HTML to build our DOM tree, but we cannot
* rely on CSS or Javascript support. As such, the results will not always
* match Arc90's Readability. (For example, if a web page contains CSS style
* rules or Javascript code which hide certain HTML elements from display,
* Arc90's Readability will dismiss those from consideration but our PHP port,
* unable to understand CSS or Javascript, will not know any better.)
*
* Another significant difference is that the aim of Arc90's Readability is
* to re-present the main content block of a given web page so users can
* read it more easily in their browsers. Correct identification, clean up,
* and separation of the content block is only a part of this process.
* This PHP port is only concerned with this part, it does not include code
* that relates to presentation in the browser - Arc90 already do
* that extremely well, and for PDF output there's FiveFilters.org's
* PDF Newspaper: http://fivefilters.org/pdf-newspaper/.
*
* Finally, this class contains methods that might be useful for developers
* working on HTML document fragments. So without deviating too much from
* the original code (which I don't want to do because it makes debugging
* and updating more difficult), I've tried to make it a little more
* developer friendly. You should be able to use the methods here on
* existing DOMElement objects without passing an entire HTML document to
* be parsed.
*/
class Readability implements LoggerAwareInterface class Readability implements LoggerAwareInterface
{ {
// flags // flags
const FLAG_STRIP_UNLIKELYS = 1; public const FLAG_STRIP_UNLIKELYS = 1;
const FLAG_WEIGHT_ATTRIBUTES = 2; public const FLAG_WEIGHT_ATTRIBUTES = 2;
const FLAG_CLEAN_CONDITIONALLY = 4; public const FLAG_CLEAN_CONDITIONALLY = 4;
const FLAG_DISABLE_PREFILTER = 8; public const FLAG_DISABLE_PREFILTER = 8;
const FLAG_DISABLE_POSTFILTER = 16; public const FLAG_DISABLE_POSTFILTER = 16;
// constants // constants
const SCORE_CHARS_IN_PARAGRAPH = 100; public const SCORE_CHARS_IN_PARAGRAPH = 100;
const SCORE_WORDS_IN_PARAGRAPH = 20; public const SCORE_WORDS_IN_PARAGRAPH = 20;
const GRANDPARENT_SCORE_DIVISOR = 2; public const GRANDPARENT_SCORE_DIVISOR = 2;
const MIN_PARAGRAPH_LENGTH = 20; public const MIN_PARAGRAPH_LENGTH = 20;
const MIN_COMMAS_IN_PARAGRAPH = 6; public const MIN_COMMAS_IN_PARAGRAPH = 6;
const MIN_ARTICLE_LENGTH = 200; public const MIN_ARTICLE_LENGTH = 200;
const MIN_NODE_LENGTH = 80; public const MIN_NODE_LENGTH = 80;
const MAX_LINK_DENSITY = 0.25; public const MAX_LINK_DENSITY = 0.25;
public $convertLinksToFootnotes = false; public $convertLinksToFootnotes = false;
public $revertForcedParagraphElements = true; public $revertForcedParagraphElements = false;
public $articleTitle; public $articleTitle;
public $articleContent; public $articleContent;
public $original_html; public $original_html;
@@ -89,14 +46,27 @@ class Readability implements LoggerAwareInterface
* Defined up here so we don't instantiate them repeatedly in loops. * Defined up here so we don't instantiate them repeatedly in loops.
*/ */
public $regexps = [ public $regexps = [
'unlikelyCandidates' => '/display\s*:\s*none|ignore|\binfos?\b|annoy|clock|date|time|author|intro|hidd?e|about|archive|\bprint|bookmark|tags|tag-list|share|search|social|robot|published|combx|comment|mast(?:head)|subscri|community|category|disqus|extra|head|head(?:er|note)|floor|foot(?:er|note)|menu|tool\b|function|nav|remark|rss|shoutbox|widget|meta|banner|sponsor|adsense|inner-?ad|ad-|sponsor|\badv\b|\bads\b|agr?egate?|pager|sidebar|popup|tweet|twitter/i', 'unlikelyCandidates' => '/-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i',
'okMaybeItsACandidate' => '/article\b|contain|\bcontent|column|general|detail|shadow|lightbox|blog|body|entry|main|page|footnote/i', 'okMaybeItsACandidate' => '/article\b|contain|\bcontent|column|general|detail|shadow|lightbox|blog|body|entry|main|page|footnote|element/i',
'positive' => '/read|full|article|body|\bcontent|contain|entry|main|markdown|media|page|attach|pagination|post|text|blog|story/i', 'positive' => '/read|full|article|body|\bcontent|contain|entry|main|markdown|media|page|attach|pagination|post|text|blog|story/i',
'negative' => '/bottom|stat|info|discuss|e[\-]?mail|comment|reply|log.{2}(n|ed)|sign|single|combx|com-|contact|_nav|link|media|promo|\bad-|related|scroll|shoutbox|sidebar|sponsor|shopping|teaser|recommend/i', 'negative' => '/bottom|stat|info|discuss|e[\-]?mail|comment|reply|log.{2}(n|ed)|sign|single|combx|com-|contact|_nav|link|media|promo|\bad-|related|scroll|shoutbox|sidebar|sponsor|shopping|teaser|recommend/i',
'divToPElements' => '/<(?:blockquote|header|section|code|div|article|footer|aside|img|p|pre|dl|ol|ul)/mi', 'divToPElements' => '/<(?:blockquote|header|section|code|div|article|footer|aside|img|p|pre|dl|ol|ul)/mi',
'killBreaks' => '/(<br\s*\/?>([ \r\n\s]|&nbsp;?)*)+/', 'killBreaks' => '/(<br\s*\/?>([ \r\n\s]|&nbsp;?)*)+/',
'media' => '!//(?:[^\.\?/]+\.)?(?:youtu(?:be)?|giphy|soundcloud|dailymotion|vimeo|pornhub|xvideos|twitvid|rutube|openload\.co|viddler)\.(?:com|be|org|net)/!i', 'media' => '!//(?:[^\.\?/]+\.)?(?:youtu(?:be)?|giphy|soundcloud|dailymotion|vimeo|pornhub|xvideos|twitvid|rutube|openload\.co|viddler)\.(?:com|be|org|net)/!i',
'skipFootnoteLink' => '/^\s*(\[?[a-z0-9]{1,2}\]?|^|edit|citation needed)\s*$/i', 'skipFootnoteLink' => '/^\s*(\[?[a-z0-9]{1,2}\]?|^|edit|citation needed)\s*$/i',
'hasContent' => '/\S$/',
'isNotVisible' => '/display\s*:\s*none/',
];
public $defaultTagsToScore = ['section', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'td', 'pre'];
// The commented out elements qualify as phrasing content but tend to be
// removed by readability when put into paragraphs, so we ignore them here.
public $phrasingElements = [
// "CANVAS", "IFRAME", "SVG", "VIDEO",
'ABBR', 'AUDIO', 'B', 'BDO', 'BR', 'BUTTON', 'CITE', 'CODE', 'DATA',
'DATALIST', 'DFN', 'EM', 'EMBED', 'I', 'IMG', 'INPUT', 'KBD', 'LABEL',
'MARK', 'MATH', 'METER', 'NOSCRIPT', 'OBJECT', 'OUTPUT', 'PROGRESS', 'Q',
'RUBY', 'SAMP', 'SCRIPT', 'SELECT', 'SMALL', 'SPAN', 'STRONG', 'SUB',
'SUP', 'TEXTAREA', 'TIME', 'VAR', 'WBR',
]; ];
public $tidy_config = [ public $tidy_config = [
'tidy-mark' => false, 'tidy-mark' => false,
@@ -105,7 +75,7 @@ class Readability implements LoggerAwareInterface
'numeric-entities' => false, 'numeric-entities' => false,
// 'preserve-entities' => true, // 'preserve-entities' => true,
'break-before-br' => false, 'break-before-br' => false,
'clean' => true, 'clean' => false,
'output-xhtml' => true, 'output-xhtml' => true,
'logical-emphasis' => true, 'logical-emphasis' => true,
'show-body-only' => false, 'show-body-only' => false,
@@ -170,17 +140,16 @@ class Readability implements LoggerAwareInterface
* @param string $html UTF-8 encoded string * @param string $html UTF-8 encoded string
* @param string $url URL associated with HTML (for footnotes) * @param string $url URL associated with HTML (for footnotes)
* @param string $parser Which parser to use for turning raw HTML into a DOMDocument * @param string $parser Which parser to use for turning raw HTML into a DOMDocument
* @param bool $use_tidy Use tidy * @param bool $useTidy Use tidy
*/ */
public function __construct($html, $url = null, $parser = 'libxml', $use_tidy = true) public function __construct(string $html, string $url = null, string $parser = 'libxml', bool $useTidy = true)
{ {
$this->url = $url; $this->url = $url;
$this->html = $html; $this->html = $html;
$this->parser = $parser; $this->parser = $parser;
$this->useTidy = $use_tidy && \function_exists('tidy_parse_string'); $this->useTidy = $useTidy && \function_exists('tidy_parse_string');
$this->logger = new NullLogger(); $this->logger = new NullLogger();
$this->loadHtml();
} }
public function setLogger(LoggerInterface $logger): void public function setLogger(LoggerInterface $logger): void
@@ -214,7 +183,7 @@ class Readability implements LoggerAwareInterface
* @param string $filter RegExp for replace * @param string $filter RegExp for replace
* @param string $replacer Replacer * @param string $replacer Replacer
*/ */
public function addPreFilter($filter, $replacer = '') public function addPreFilter(string $filter, string $replacer = ''): void
{ {
$this->pre_filters[$filter] = $replacer; $this->pre_filters[$filter] = $replacer;
} }
@@ -225,7 +194,7 @@ class Readability implements LoggerAwareInterface
* @param string $filter RegExp for replace * @param string $filter RegExp for replace
* @param string $replacer Replacer * @param string $replacer Replacer
*/ */
public function addPostFilter($filter, $replacer = '') public function addPostFilter(string $filter, string $replacer = ''): void
{ {
$this->post_filters[$filter] = $replacer; $this->post_filters[$filter] = $replacer;
} }
@@ -242,8 +211,10 @@ class Readability implements LoggerAwareInterface
* *
* @return bool true if we found content, false otherwise * @return bool true if we found content, false otherwise
*/ */
public function init() public function init(): bool
{ {
$this->loadHtml();
if (!isset($this->dom->documentElement)) { if (!isset($this->dom->documentElement)) {
return false; return false;
} }
@@ -308,7 +279,7 @@ class Readability implements LoggerAwareInterface
/** /**
* Run any post-process modifications to article content as necessary. * Run any post-process modifications to article content as necessary.
*/ */
public function postProcessContent(\DOMElement $articleContent) public function postProcessContent(\DOMElement $articleContent): void
{ {
if ($this->convertLinksToFootnotes && !preg_match('/\bwiki/', $this->url)) { if ($this->convertLinksToFootnotes && !preg_match('/\bwiki/', $this->url)) {
$this->addFootnotes($articleContent); $this->addFootnotes($articleContent);
@@ -320,7 +291,7 @@ class Readability implements LoggerAwareInterface
* *
* @see http://www.roughtype.com/archives/2010/05/experiments_in.php * @see http://www.roughtype.com/archives/2010/05/experiments_in.php
*/ */
public function addFootnotes(\DOMElement $articleContent) public function addFootnotes(\DOMElement $articleContent): void
{ {
$footnotesWrapper = $this->dom->createElement('footer'); $footnotesWrapper = $this->dom->createElement('footer');
$footnotesWrapper->setAttribute('class', 'readability-footnotes'); $footnotesWrapper->setAttribute('class', 'readability-footnotes');
@@ -382,7 +353,7 @@ class Readability implements LoggerAwareInterface
* Prepare the article node for display. Clean out any inline styles, * Prepare the article node for display. Clean out any inline styles,
* iframes, forms, strip extraneous <p> tags, etc. * iframes, forms, strip extraneous <p> tags, etc.
*/ */
public function prepArticle(\DOMNode $articleContent) public function prepArticle(\DOMNode $articleContent): void
{ {
if (!$articleContent instanceof \DOMElement) { if (!$articleContent instanceof \DOMElement) {
return; return;
@@ -487,10 +458,8 @@ class Readability implements LoggerAwareInterface
* @param \DOMElement $e * @param \DOMElement $e
* @param bool $normalizeSpaces (default: true) * @param bool $normalizeSpaces (default: true)
* @param bool $flattenLines (default: false) * @param bool $flattenLines (default: false)
*
* @return string
*/ */
public function getInnerText($e, $normalizeSpaces = true, $flattenLines = false) public function getInnerText($e, bool $normalizeSpaces = true, bool $flattenLines = false): string
{ {
if (null === $e || !isset($e->textContent) || '' === $e->textContent) { if (null === $e || !isset($e->textContent) || '' === $e->textContent) {
return ''; return '';
@@ -499,9 +468,11 @@ class Readability implements LoggerAwareInterface
$textContent = trim($e->textContent); $textContent = trim($e->textContent);
if ($flattenLines) { if ($flattenLines) {
$textContent = mb_ereg_replace('(?:[\r\n](?:\s|&nbsp;)*)+', '', $textContent); return (string) mb_ereg_replace('(?:[\r\n](?:\s|&nbsp;)*)+', '', $textContent);
} elseif ($normalizeSpaces) { }
$textContent = mb_ereg_replace('\s\s+', ' ', $textContent);
if ($normalizeSpaces) {
return (string) mb_ereg_replace('\s\s+', ' ', $textContent);
} }
return $textContent; return $textContent;
@@ -509,43 +480,31 @@ class Readability implements LoggerAwareInterface
/** /**
* Remove the style attribute on every $e and under. * Remove the style attribute on every $e and under.
*
* @param \DOMElement $e
*/ */
public function cleanStyles($e) public function cleanStyles(\DOMElement $e): void
{ {
if (!\is_object($e)) { if (\is_object($e)) {
return;
}
$elems = $e->getElementsByTagName('*'); $elems = $e->getElementsByTagName('*');
foreach ($elems as $elem) { foreach ($elems as $elem) {
$elem->removeAttribute('style'); $elem->removeAttribute('style');
} }
} }
}
/** /**
* Get comma number for a given text. * Get comma number for a given text.
*
* @param string $text
*
* @return int
*/ */
public function getCommaCount($text) public function getCommaCount(string $text): int
{ {
return substr_count($text, ','); return \count(explode(',', $text));
} }
/** /**
* Get words number for a given text if words separated by a space. * Get words number for a given text if words separated by a space.
* Input string should be normalized. * Input string should be normalized.
*
* @param string $text
*
* @return int
*/ */
public function getWordCount($text) public function getWordCount(string $text): int
{ {
return substr_count($text, ' '); return substr_count($text, ' ');
} }
@@ -554,12 +513,8 @@ class Readability implements LoggerAwareInterface
* Get the density of links as a percentage of the content * Get the density of links as a percentage of the content
* This is the amount of text that is inside a link divided by the total text in the node. * This is the amount of text that is inside a link divided by the total text in the node.
* Can exclude external references to differentiate between simple text and menus/infoblocks. * Can exclude external references to differentiate between simple text and menus/infoblocks.
*
* @param bool $excludeExternal
*
* @return int
*/ */
public function getLinkDensity(\DOMElement $e, $excludeExternal = false) public function getLinkDensity(\DOMElement $e, bool $excludeExternal = false): float
{ {
$links = $e->getElementsByTagName('a'); $links = $e->getElementsByTagName('a');
$textLength = mb_strlen($this->getInnerText($e, true, true)); $textLength = mb_strlen($this->getInnerText($e, true, true));
@@ -581,10 +536,8 @@ class Readability implements LoggerAwareInterface
/** /**
* Get an element relative weight. * Get an element relative weight.
*
* @return int
*/ */
public function getWeight(\DOMElement $e) public function getWeight(\DOMElement $e): int
{ {
if (!$this->flagIsActive(self::FLAG_WEIGHT_ATTRIBUTES)) { if (!$this->flagIsActive(self::FLAG_WEIGHT_ATTRIBUTES)) {
return 0; return 0;
@@ -602,7 +555,7 @@ class Readability implements LoggerAwareInterface
/** /**
* Remove extraneous break tags from a node. * Remove extraneous break tags from a node.
*/ */
public function killBreaks(\DOMElement $node) public function killBreaks(\DOMElement $node): void
{ {
$html = $node->getInnerHTML(); $html = $node->getInnerHTML();
$html = preg_replace($this->regexps['killBreaks'], '<br />', $html); $html = preg_replace($this->regexps['killBreaks'], '<br />', $html);
@@ -614,10 +567,8 @@ class Readability implements LoggerAwareInterface
* (Unless it's a youtube/vimeo video. People love movies.). * (Unless it's a youtube/vimeo video. People love movies.).
* *
* Updated 2012-09-18 to preserve youtube/vimeo iframes * Updated 2012-09-18 to preserve youtube/vimeo iframes
*
* @param string $tag
*/ */
public function clean(\DOMElement $e, $tag) public function clean(\DOMElement $e, string $tag): void
{ {
$targetList = $e->getElementsByTagName($tag); $targetList = $e->getElementsByTagName($tag);
$isEmbed = ('audio' === $tag || 'video' === $tag || 'iframe' === $tag || 'object' === $tag || 'embed' === $tag); $isEmbed = ('audio' === $tag || 'video' === $tag || 'iframe' === $tag || 'object' === $tag || 'embed' === $tag);
@@ -648,10 +599,8 @@ class Readability implements LoggerAwareInterface
* Clean an element of all tags of type "tag" if they look fishy. * Clean an element of all tags of type "tag" if they look fishy.
* "Fishy" is an algorithm based on content length, classnames, * "Fishy" is an algorithm based on content length, classnames,
* link density, number of images & embeds, etc. * link density, number of images & embeds, etc.
*
* @param string $tag
*/ */
public function cleanConditionally(\DOMElement $e, $tag) public function cleanConditionally(\DOMElement $e, string $tag): void
{ {
if (!$this->flagIsActive(self::FLAG_CLEAN_CONDITIONALLY)) { if (!$this->flagIsActive(self::FLAG_CLEAN_CONDITIONALLY)) {
return; return;
@@ -672,6 +621,9 @@ class Readability implements LoggerAwareInterface
$contentScore = ($node->hasAttribute('readability')) ? (int) $node->getAttribute('readability') : 0; $contentScore = ($node->hasAttribute('readability')) ? (int) $node->getAttribute('readability') : 0;
$this->logger->debug('Start conditional cleaning of ' . $node->getNodePath() . ' (class=' . $node->getAttribute('class') . '; id=' . $node->getAttribute('id') . ')' . (($node->hasAttribute('readability')) ? (' with score ' . $node->getAttribute('readability')) : '')); $this->logger->debug('Start conditional cleaning of ' . $node->getNodePath() . ' (class=' . $node->getAttribute('class') . '; id=' . $node->getAttribute('id') . ')' . (($node->hasAttribute('readability')) ? (' with score ' . $node->getAttribute('readability')) : ''));
// XXX Incomplete implementation
$isList = \in_array($node->tagName, ['ul', 'ol'], true);
if ($weight + $contentScore < 0) { if ($weight + $contentScore < 0) {
$this->logger->debug('Removing...'); $this->logger->debug('Removing...');
$node->parentNode->removeChild($node); $node->parentNode->removeChild($node);
@@ -706,16 +658,16 @@ class Readability implements LoggerAwareInterface
$toRemove = false; $toRemove = false;
if ($this->lightClean) { if ($this->lightClean) {
if ($li > $p && 'ul' !== $tag && 'ol' !== $tag) { if (!$isList && $li > $p) {
$this->logger->debug(' too many <li> elements, and parent is not <ul> or <ol>'); $this->logger->debug(' too many <li> elements, and parent is not <ul> or <ol>');
$toRemove = true; $toRemove = true;
} elseif ($input > floor($p / 3)) { } elseif ($input > floor($p / 3)) {
$this->logger->debug(' too many <input> elements'); $this->logger->debug(' too many <input> elements');
$toRemove = true; $toRemove = true;
} elseif ($contentLength < 6 && (0 === $embedCount && (0 === $img || $img > 2))) { } elseif (!$isList && $contentLength < 6 && (0 === $embedCount && (0 === $img || $img > 2))) {
$this->logger->debug(' content length less than 6 chars, 0 embeds and either 0 images or more than 2 images'); $this->logger->debug(' content length less than 6 chars, 0 embeds and either 0 images or more than 2 images');
$toRemove = true; $toRemove = true;
} elseif ($weight < 25 && $linkDensity > 0.25) { } 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; $toRemove = true;
} elseif ($a > 2 && ($weight >= 25 && $linkDensity > 0.5)) { } elseif ($a > 2 && ($weight >= 25 && $linkDensity > 0.5)) {
@@ -729,16 +681,16 @@ class Readability implements LoggerAwareInterface
if ($img > $p) { if ($img > $p) {
$this->logger->debug(' more image elements than paragraph elements'); $this->logger->debug(' more image elements than paragraph elements');
$toRemove = true; $toRemove = true;
} elseif ($li > $p && 'ul' !== $tag && 'ol' !== $tag) { } elseif (!$isList && $li > $p) {
$this->logger->debug(' too many <li> elements, and parent is not <ul> or <ol>'); $this->logger->debug(' too many <li> elements, and parent is not <ul> or <ol>');
$toRemove = true; $toRemove = true;
} elseif ($input > floor($p / 3)) { } elseif ($input > floor($p / 3)) {
$this->logger->debug(' too many <input> elements'); $this->logger->debug(' too many <input> elements');
$toRemove = true; $toRemove = true;
} elseif ($contentLength < 10 && (0 === $img || $img > 2)) { } elseif (!$isList && $contentLength < 10 && (0 === $img || $img > 2)) {
$this->logger->debug(' content length less than 10 chars and 0 images, or more than 2 images'); $this->logger->debug(' content length less than 10 chars and 0 images, or more than 2 images');
$toRemove = true; $toRemove = true;
} elseif ($weight < 25 && $linkDensity > 0.2) { } 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; $toRemove = true;
} elseif ($weight >= 25 && $linkDensity > 0.5) { } elseif ($weight >= 25 && $linkDensity > 0.5) {
@@ -761,7 +713,7 @@ class Readability implements LoggerAwareInterface
/** /**
* Clean out spurious headers from an Element. Checks things like classnames and link density. * Clean out spurious headers from an Element. Checks things like classnames and link density.
*/ */
public function cleanHeaders(\DOMElement $e) public function cleanHeaders(\DOMElement $e): void
{ {
for ($headerIndex = 1; $headerIndex < 3; ++$headerIndex) { for ($headerIndex = 1; $headerIndex < 3; ++$headerIndex) {
$headers = $e->getElementsByTagName('h' . $headerIndex); $headers = $e->getElementsByTagName('h' . $headerIndex);
@@ -776,57 +728,26 @@ class Readability implements LoggerAwareInterface
/** /**
* Check if the given flag is active. * Check if the given flag is active.
*
* @param int $flag
*
* @return bool
*/ */
public function flagIsActive($flag) public function flagIsActive(int $flag): bool
{ {
return ($this->flags & $flag) > 0; return ($this->flags & $flag) > 0;
} }
/** /**
* Add a flag. * Add a flag.
*
* @param int $flag
*/ */
public function addFlag($flag) public function addFlag(int $flag): void
{ {
$this->flags |= $flag; $this->flags = $this->flags | $flag;
} }
/** /**
* Remove a flag. * Remove a flag.
*
* @param int $flag
*/ */
public function removeFlag($flag) public function removeFlag(int $flag): void
{
$this->flags &= ~$flag;
}
/**
* Debug.
*
* @deprecated use $this->logger->debug() instead
*
* @codeCoverageIgnore
*/
protected function dbg($msg)
{
$this->logger->debug($msg);
}
/**
* Dump debug info.
*
* @deprecated since Monolog gather log, we don't need it
*
* @codeCoverageIgnore
*/
protected function dump_dbg()
{ {
$this->flags = $this->flags & ~$flag;
} }
/** /**
@@ -875,7 +796,7 @@ class Readability implements LoggerAwareInterface
* Prepare the HTML document for readability to scrape it. * Prepare the HTML document for readability to scrape it.
* This includes things like stripping javascript, CSS, and handling terrible markup. * This includes things like stripping javascript, CSS, and handling terrible markup.
*/ */
protected function prepDocument() protected function prepDocument(): void
{ {
/* /*
* In some cases a body element can't be found (if the HTML is totally hosed for example) * In some cases a body element can't be found (if the HTML is totally hosed for example)
@@ -904,7 +825,7 @@ class Readability implements LoggerAwareInterface
* Initialize a node with the readability object. Also checks the * Initialize a node with the readability object. Also checks the
* className/id for special names to add to its score. * className/id for special names to add to its score.
*/ */
protected function initializeNode(\DOMElement $node) protected function initializeNode(\DOMElement $node): void
{ {
if (!isset($node->tagName)) { if (!isset($node->tagName)) {
return; return;
@@ -940,7 +861,7 @@ class Readability implements LoggerAwareInterface
case 'DD': case 'DD':
case 'DT': case 'DT':
case 'LI': case 'LI':
$readability->value -= 2 * round($this->getLinkDensity($node), 0, \PHP_ROUND_HALF_UP); $readability->value -= 3;
break; break;
case 'ASIDE': case 'ASIDE':
case 'FOOTER': case 'FOOTER':
@@ -972,11 +893,11 @@ class Readability implements LoggerAwareInterface
* Using a variety of metrics (content score, classname, element types), find the content that is * 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. * most likely to be the stuff a user wants to read. Then return it wrapped up in a div.
* *
* @param ?\DOMElement $page * @param \DOMElement $page
* *
* @return \DOMElement|false * @return \DOMElement|false
*/ */
protected function grabArticle($page = null) protected function grabArticle(\DOMElement $page = null)
{ {
if (!$page) { if (!$page) {
$page = $this->dom; $page = $this->dom;
@@ -991,7 +912,8 @@ class Readability implements LoggerAwareInterface
$allElements = $page->getElementsByTagName('*'); $allElements = $page->getElementsByTagName('*');
for ($nodeIndex = 0; $node = $allElements->item($nodeIndex); ++$nodeIndex) { for ($nodeIndex = 0; $allElements->item($nodeIndex); ++$nodeIndex) {
$node = $allElements->item($nodeIndex);
$tagName = $node->tagName; $tagName = $node->tagName;
$nodeContent = $node->getInnerHTML(); $nodeContent = $node->getInnerHTML();
@@ -1000,14 +922,35 @@ class Readability implements LoggerAwareInterface
continue; continue;
} }
// Remove invisible nodes
if (!$this->isNodeVisible($node)) {
$this->logger->debug('Removing invisible node ' . $node->getNodePath());
$node->parentNode->removeChild($node);
--$nodeIndex;
continue;
}
// 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)
) {
$this->logger->debug('Removing unlikely candidate (using conf) ' . $node->getNodePath() . ' by "' . $unlikelyMatchString . '"');
$node->parentNode->removeChild($node);
--$nodeIndex;
continue;
}
// Some well known site uses sections as paragraphs. // Some well known site uses sections as paragraphs.
if (0 === strcasecmp($tagName, 'p') || 0 === strcasecmp($tagName, 'td') || 0 === strcasecmp($tagName, 'pre') || 0 === strcasecmp($tagName, 'section')) { if (\in_array($tagName, $this->defaultTagsToScore, true)) {
$nodesToScore[] = $node; $nodesToScore[] = $node;
} }
// Turn divs into P tags where they have been used inappropriately // Turn divs into P tags where they have been used inappropriately
// (as in, where they contain no other block level elements). // (as in, where they contain no other block level elements).
if (0 === strcasecmp($tagName, 'div') || 0 === strcasecmp($tagName, 'article') || 0 === strcasecmp($tagName, 'section')) { if ('div' === $tagName) {
if (!preg_match($this->regexps['divToPElements'], $nodeContent)) { if (!preg_match($this->regexps['divToPElements'], $nodeContent)) {
$newNode = $this->dom->createElement('p'); $newNode = $this->dom->createElement('p');
@@ -1022,27 +965,46 @@ class Readability implements LoggerAwareInterface
} }
} else { } else {
// Will change these P elements back to text nodes after processing. // Will change these P elements back to text nodes after processing.
for ($i = 0, $il = $node->childNodes->length; $i < $il; ++$i) { $p = null;
$childNode = $node->childNodes->item($i); // foreach does not handle removeChild very well
// See https://www.php.net/manual/en/domnode.removechild.php#90292
// it looks like sometimes the loop is going too far and we are retrieving a non-existant child $childs = iterator_to_array($node->childNodes);
if (null === $childNode) { foreach ($childs as $childNode) {
continue;
}
// executable tags (<?php or <?xml) warning // executable tags (<?php or <?xml) warning
if (\is_object($childNode) && 'DOMProcessingInstruction' === \get_class($childNode)) { if ($childNode instanceof \DOMProcessingInstruction) {
$childNode->parentNode->removeChild($childNode); $childNode->parentNode->removeChild($childNode);
continue; continue;
} }
if (\XML_TEXT_NODE === $childNode->nodeType) { if ($childNode instanceof \DOMText && '' === $this->getInnerText($childNode, true, true)) {
$p = $this->dom->createElement('p'); /* $this->logger->debug('Remove empty text node'); */
$p->setInnerHtml($childNode->nodeValue); $childNode->parentNode->removeChild($childNode);
$p->setAttribute('data-readability-styled', 'true');
$childNode->parentNode->replaceChild($p, $childNode); continue;
} }
if ($this->isPhrasingContent($childNode)) {
if (null !== $p) {
$p->appendChild($childNode);
} elseif ('' !== $this->getInnerText($childNode, true, true)) {
$p = $this->dom->createElement('p');
$p->setAttribute('data-readability-styled', 'true');
$node->replaceChild($p, $childNode);
$p->appendChild($childNode);
}
} elseif (null !== $p) {
while ($p->lastChild && '' === $this->getInnerText($p->lastChild, true, true)) {
$p->removeChild($p->lastChild);
}
$p = null;
}
}
if ($this->hasSingleTagInsideElement($node, 'p') && $this->getLinkDensity($node) < 0.25) {
$newNode = $node->childNodes->item(0);
$node->parentNode->replaceChild($newNode, $node);
$nodesToScore[] = $newNode;
} }
} }
} }
@@ -1056,14 +1018,13 @@ class Readability implements LoggerAwareInterface
* Maybe eventually link density. * Maybe eventually link density.
*/ */
for ($pt = 0, $scored = \count($nodesToScore); $pt < $scored; ++$pt) { for ($pt = 0, $scored = \count($nodesToScore); $pt < $scored; ++$pt) {
$parentNode = $nodesToScore[$pt]->parentNode; $ancestors = $this->getAncestors($nodesToScore[$pt], 5);
// No parent node? Move on... // No parent node? Move on...
if (!$parentNode) { if (0 === \count($ancestors)) {
continue; continue;
} }
$grandParentNode = $parentNode->parentNode instanceof \DOMElement ? $parentNode->parentNode : null;
$innerText = $this->getInnerText($nodesToScore[$pt]); $innerText = $this->getInnerText($nodesToScore[$pt]);
// If this paragraph is less than MIN_PARAGRAPH_LENGTH (default:20) characters, don't even count it. // If this paragraph is less than MIN_PARAGRAPH_LENGTH (default:20) characters, don't even count it.
@@ -1071,17 +1032,6 @@ class Readability implements LoggerAwareInterface
continue; continue;
} }
// Initialize readability data for the parent.
if (!$parentNode->hasAttribute('readability')) {
$this->initializeNode($parentNode);
$parentNode->setAttribute('data-candidate', 'true');
}
// Initialize readability data for the grandparent.
if ($grandParentNode && !$grandParentNode->hasAttribute('readability') && isset($grandParentNode->tagName)) {
$this->initializeNode($grandParentNode);
$grandParentNode->setAttribute('data-candidate', 'true');
}
// Add a point for the paragraph itself as a base. // Add a point for the paragraph itself as a base.
$contentScore = 1; $contentScore = 1;
// Add points for any commas within this paragraph. // Add points for any commas within this paragraph.
@@ -1089,25 +1039,26 @@ class Readability implements LoggerAwareInterface
// For every SCORE_CHARS_IN_PARAGRAPH (default:100) characters in this paragraph, add another point. Up to 3 points. // For every SCORE_CHARS_IN_PARAGRAPH (default:100) characters in this paragraph, add another point. Up to 3 points.
$contentScore += min(floor(mb_strlen($innerText) / self::SCORE_CHARS_IN_PARAGRAPH), 3); $contentScore += min(floor(mb_strlen($innerText) / self::SCORE_CHARS_IN_PARAGRAPH), 3);
// For every SCORE_WORDS_IN_PARAGRAPH (default:20) words in this paragraph, add another point. Up to 3 points. // For every SCORE_WORDS_IN_PARAGRAPH (default:20) words in this paragraph, add another point. Up to 3 points.
$contentScore += min(floor($this->getWordCount($innerText) / self::SCORE_WORDS_IN_PARAGRAPH), 3); // $contentScore += min(floor($this->getWordCount($innerText) / self::SCORE_WORDS_IN_PARAGRAPH), 3);
/* TEST: For every positive/negative parent tag, add/substract half point. Up to 3 points. *\/
$up = $nodesToScore[$pt];
$score = 0;
while ($up->parentNode instanceof \DOMElement) {
$up = $up->parentNode;
if (preg_match($this->regexps['positive'], $up->getAttribute('class') . ' ' . $up->getAttribute('id'))) {
$score += 0.5;
} elseif (preg_match($this->regexps['negative'], $up->getAttribute('class') . ' ' . $up->getAttribute('id'))) {
$score -= 0.5;
}
}
$score = floor($score);
$contentScore += max(min($score, 3), -3);/**/
// Add the score to the parent. The grandparent gets half. foreach ($ancestors as $level => $ancestor) {
$parentNode->getAttributeNode('readability')->value += $contentScore; if (!$ancestor->nodeName || !$ancestor->parentNode) {
if ($grandParentNode) { return;
$grandParentNode->getAttributeNode('readability')->value += round($contentScore / self::GRANDPARENT_SCORE_DIVISOR); }
if (!$ancestor->hasAttribute('readability')) {
$this->initializeNode($ancestor);
$ancestor->setAttribute('data-candidate', 'true');
}
if (0 === $level) {
$scoreDivider = 1;
} elseif (1 === $level) {
$scoreDivider = 2;
} else {
$scoreDivider = $level * 3;
}
$ancestor->getAttributeNode('readability')->value += $contentScore / $scoreDivider;
} }
} }
@@ -1131,18 +1082,6 @@ class Readability implements LoggerAwareInterface
for ($c = $candidates->length - 1; $c >= 0; --$c) { for ($c = $candidates->length - 1; $c >= 0; --$c) {
$node = $candidates->item($c); $node = $candidates->item($c);
// 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)
) {
$this->logger->debug('Removing unlikely candidate (using conf) ' . $node->getNodePath() . ' by "' . $unlikelyMatchString . '" with readability ' . ($node->hasAttribute('readability') ? (int) $node->getAttributeNode('readability')->value : 0));
$node->parentNode->removeChild($node);
--$nodeIndex;
}
} }
unset($candidates); unset($candidates);
} }
@@ -1151,10 +1090,11 @@ class Readability implements LoggerAwareInterface
* After we've calculated scores, loop through all of the possible candidate nodes we found * After we've calculated scores, loop through all of the possible candidate nodes we found
* and find the one with the highest score. * and find the one with the highest score.
*/ */
$topCandidate = null; $topCandidates = array_fill(0, 5, null);
if ($xpath) { if ($xpath) {
// Using array of DOMElements after deletion is a path to DOOMElement. // Using array of DOMElements after deletion is a path to DOOMElement.
$candidates = $xpath->query('.//*[@data-candidate]', $page->documentElement); $candidates = $xpath->query('.//*[@data-candidate]', $page->documentElement);
$this->logger->debug('Candidates: ' . $candidates->length);
for ($c = $candidates->length - 1; $c >= 0; --$c) { for ($c = $candidates->length - 1; $c >= 0; --$c) {
$item = $candidates->item($c); $item = $candidates->item($c);
@@ -1165,14 +1105,25 @@ class Readability implements LoggerAwareInterface
$readability = $item->getAttributeNode('readability'); $readability = $item->getAttributeNode('readability');
$readability->value = round($readability->value * (1 - $this->getLinkDensity($item)), 0, \PHP_ROUND_HALF_UP); $readability->value = round($readability->value * (1 - $this->getLinkDensity($item)), 0, \PHP_ROUND_HALF_UP);
if (!$topCandidate || $readability->value > (int) $topCandidate->getAttribute('readability')) { for ($t = 0; $t < 5; ++$t) {
$aTopCandidate = $topCandidates[$t];
if (!$aTopCandidate || $readability->value > (int) $aTopCandidate->getAttribute('readability')) {
$this->logger->debug('Candidate: ' . $item->getNodePath() . ' (' . $item->getAttribute('class') . ':' . $item->getAttribute('id') . ') with score ' . $readability->value); $this->logger->debug('Candidate: ' . $item->getNodePath() . ' (' . $item->getAttribute('class') . ':' . $item->getAttribute('id') . ') with score ' . $readability->value);
$topCandidate = $item; array_splice($topCandidates, $t, 0, [$item]);
if (\count($topCandidates) > 5) {
array_pop($topCandidates);
}
break;
}
}
} }
} }
unset($candidates); $topCandidates = array_filter($topCandidates, function ($v, $idx) {
} return 0 === $idx || null !== $v;
}, \ARRAY_FILTER_USE_BOTH);
$topCandidate = $topCandidates[0];
/* /*
* If we still have no top candidate, just use the body as a last resort. * If we still have no top candidate, just use the body as a last resort.
@@ -1199,6 +1150,59 @@ class Readability implements LoggerAwareInterface
} }
$this->initializeNode($topCandidate); $this->initializeNode($topCandidate);
} elseif ($topCandidate) {
$alternativeCandidateAncestors = [];
foreach ($topCandidates as $candidate) {
if ((int) $candidate->getAttribute('readability') / (int) $topCandidate->getAttribute('readability') >= 0.75) {
$ancestors = $this->getAncestors($candidate);
$this->logger->debug('Adding ' . \count($ancestors) . ' alternative ancestors for ' . $candidate->getNodePath());
$alternativeCandidateAncestors[] = $ancestors;
}
}
if (\count($alternativeCandidateAncestors) >= 3) {
$parentOfTopCandidate = $topCandidate->parentNode;
while ('body' !== $parentOfTopCandidate->nodeName) {
$listsContainingThisAncestor = 0;
for ($ancestorIndex = 0; $ancestorIndex < \count($alternativeCandidateAncestors) && $listsContainingThisAncestor < 3; ++$ancestorIndex) {
$listsContainingThisAncestor += (int) \in_array($parentOfTopCandidate, $alternativeCandidateAncestors[$ancestorIndex], true);
}
if ($listsContainingThisAncestor >= 3) {
$topCandidate = $parentOfTopCandidate;
break;
}
$parentOfTopCandidate = $parentOfTopCandidate->parentNode;
}
}
if (!$topCandidate->hasAttribute('readability')) {
$this->initializeNode($topCandidate);
}
$parentOfTopCandidate = $topCandidate->parentNode;
$lastScore = (int) $topCandidate->getAttribute('readability');
$scoreThreshold = $lastScore / 3;
while ('body' !== $parentOfTopCandidate->nodeName) {
if (!$parentOfTopCandidate->hasAttribute('readability')) {
$parentOfTopCandidate = $parentOfTopCandidate->parentNode;
continue;
}
$parentScore = (int) $parentOfTopCandidate->getAttribute('readability');
if ($parentScore < $scoreThreshold) {
break;
}
if ($parentScore > $lastScore) {
$topCandidate = $parentOfTopCandidate;
break;
}
$lastScore = (int) $parentOfTopCandidate->getAttribute('readability');
$parentOfTopCandidate = $parentOfTopCandidate->parentNode;
}
$parentOfTopCandidate = $topCandidate->parentNode;
while ('body' !== $parentOfTopCandidate->nodeName && 1 === $parentOfTopCandidate->childNodes->length) {
$topCandidate = $parentOfTopCandidate;
$parentOfTopCandidate = $topCandidate->parentNode;
}
if (!$topCandidate->hasAttribute('readability')) {
$this->initializeNode($topCandidate);
}
} }
// Set table as the main node if resulted data is table element. // Set table as the main node if resulted data is table element.
@@ -1224,9 +1228,10 @@ class Readability implements LoggerAwareInterface
$articleContent = $this->dom->createElement('div'); $articleContent = $this->dom->createElement('div');
$articleContent->setAttribute('class', 'readability-content'); $articleContent->setAttribute('class', 'readability-content');
$siblingScoreThreshold = max(10, ((int) $topCandidate->getAttribute('readability')) * 0.2); $siblingScoreThreshold = max(10, ((int) $topCandidate->getAttribute('readability')) * 0.2);
$siblingNodes = $topCandidate->parentNode->childNodes; $parentOfTopCandidate = $topCandidate->parentNode;
$siblingNodes = $parentOfTopCandidate->childNodes;
if (null === $siblingNodes) { if (0 === $siblingNodes->length) {
$siblingNodes = new \stdClass(); $siblingNodes = new \stdClass();
$siblingNodes->length = 0; $siblingNodes->length = 0;
} }
@@ -1239,8 +1244,7 @@ class Readability implements LoggerAwareInterface
if ($siblingNode->isSameNode($topCandidate)) { if ($siblingNode->isSameNode($topCandidate)) {
$append = true; $append = true;
} } else {
$contentBonus = 0; $contentBonus = 0;
// Give a bonus if sibling nodes and top candidates have the same classname. // Give a bonus if sibling nodes and top candidates have the same classname.
@@ -1250,18 +1254,17 @@ class Readability implements LoggerAwareInterface
if (\XML_ELEMENT_NODE === $siblingNode->nodeType && $siblingNode->hasAttribute('readability') && (((int) $siblingNode->getAttribute('readability')) + $contentBonus) >= $siblingScoreThreshold) { if (\XML_ELEMENT_NODE === $siblingNode->nodeType && $siblingNode->hasAttribute('readability') && (((int) $siblingNode->getAttribute('readability')) + $contentBonus) >= $siblingScoreThreshold) {
$append = true; $append = true;
} } elseif (0 === strcasecmp($siblingNodeName, 'p')) {
$linkDensity = (int) $this->getLinkDensity($siblingNode);
if (0 === strcasecmp($siblingNodeName, 'p')) {
$linkDensity = $this->getLinkDensity($siblingNode);
$nodeContent = $this->getInnerText($siblingNode, true, true); $nodeContent = $this->getInnerText($siblingNode, true, true);
$nodeLength = mb_strlen($nodeContent); $nodeLength = mb_strlen($nodeContent);
if (($nodeLength > self::MIN_NODE_LENGTH && $linkDensity < self::MAX_LINK_DENSITY) if (($nodeLength > self::MIN_NODE_LENGTH && $linkDensity < self::MAX_LINK_DENSITY)
|| ($nodeLength < self::MIN_NODE_LENGTH && 0 === $linkDensity && preg_match('/\.( |$)/', $nodeContent))) { || ($nodeLength < self::MIN_NODE_LENGTH && 0 === $nodeLength && 0 === $linkDensity && preg_match('/\.( |$)/', $nodeContent))) {
$append = true; $append = true;
} }
} }
}
if ($append) { if ($append) {
$this->logger->debug('Appending node: ' . $siblingNode->getNodePath()); $this->logger->debug('Appending node: ' . $siblingNode->getNodePath());
@@ -1335,12 +1338,8 @@ class Readability implements LoggerAwareInterface
/** /**
* Get an element weight by attribute. * Get an element weight by attribute.
* Uses regular expressions to tell if this element looks good or bad. * Uses regular expressions to tell if this element looks good or bad.
*
* @param string $attribute
*
* @return int
*/ */
protected function weightAttribute(\DOMElement $element, $attribute) protected function weightAttribute(\DOMElement $element, string $attribute): int
{ {
if (!$element->hasAttribute($attribute)) { if (!$element->hasAttribute($attribute)) {
return 0; return 0;
@@ -1371,7 +1370,7 @@ class Readability implements LoggerAwareInterface
/** /**
* Will recreate previously deleted body property. * Will recreate previously deleted body property.
*/ */
protected function reinitBody() protected function reinitBody(): void
{ {
if (!isset($this->body->childNodes)) { if (!isset($this->body->childNodes)) {
$this->body = $this->dom->createElement('body'); $this->body = $this->dom->createElement('body');
@@ -1383,20 +1382,15 @@ class Readability implements LoggerAwareInterface
* Load HTML in a DOMDocument. * Load HTML in a DOMDocument.
* Apply Pre filters * Apply Pre filters
* Cleanup HTML using Tidy (or not). * Cleanup HTML using Tidy (or not).
*
* @todo This should be called in init() instead of from __construct
*/ */
private function loadHtml() private function loadHtml(): void
{ {
$this->original_html = $this->html; $this->original_html = $this->html;
$this->logger->debug('Parsing URL: ' . $this->url); $this->logger->debug('Parsing URL: ' . $this->url);
if ($this->url) { if ($this->url) {
$host = parse_url($this->url, \PHP_URL_HOST); $this->domainRegExp = '/' . strtr((string) preg_replace('/www\d*\./', '', (string) parse_url($this->url, \PHP_URL_HOST)), ['.' => '\.']) . '/';
if (null !== $host) {
$this->domainRegExp = '/' . strtr(preg_replace('/www\d*\./', '', $host), ['.' => '\.']) . '/';
}
} }
mb_internal_encoding('UTF-8'); mb_internal_encoding('UTF-8');
@@ -1432,7 +1426,7 @@ class Readability implements LoggerAwareInterface
unset($tidy); unset($tidy);
} }
$this->html = self::entitizeNonAscii((string) $this->html); $this->html = '<meta charset="utf-8">' . (string) $this->html;
if ('html5lib' === $this->parser || 'html5' === $this->parser) { if ('html5lib' === $this->parser || 'html5' === $this->parser) {
$this->dom = (new HTML5())->loadHTML($this->html); $this->dom = (new HTML5())->loadHTML($this->html);
@@ -1448,23 +1442,58 @@ class Readability implements LoggerAwareInterface
libxml_use_internal_errors(false); libxml_use_internal_errors(false);
} }
$this->dom->registerNodeClass('DOMElement', 'Readability\JSLikeHTMLElement'); $this->dom->registerNodeClass(\DOMElement::class, \Readability\JSLikeHTMLElement::class);
}
private function getAncestors(\DOMElement $node, int $maxDepth = 0): array
{
$ancestors = [];
$i = 0;
while ($node->parentNode instanceof \DOMElement) {
$ancestors[] = $node->parentNode;
if (++$i === $maxDepth) {
break;
}
$node = $node->parentNode;
}
return $ancestors;
}
private function isPhrasingContent($node): bool
{
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));
}
private function hasSingleTagInsideElement(\DOMElement $node, string $tag): bool
{
if (1 !== $node->childNodes->length || $node->childNodes->item(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));
});
return 0 === \count($a);
} }
/** /**
* Converts non-ASCII UTF-8 characters to numeric HTML entities. * Return whether a given node is visible or not.
* *
* `DOMDocument::loadHTML` will parse HTML documents as ISO-8859-1 if there is no `meta[charset]` tag. * Tidy must be configured to not clean the input for this function to
* This means that UTF-8-encoded HTML fragments such as those coming from JSON-LD `articleBody` field would be parsed with incorrect encoding. * work as expected, see $this->tidy_config['clean']
*
* @param string $html UTF-8 encoded document
*/ */
private static function entitizeNonAscii($html) private function isNodeVisible(\DOMElement $node): bool
{ {
$convmap = [ return !($node->hasAttribute('style')
0x80, 0x1FFFFF, 0, 0x10FFFF, && preg_match($this->regexps['isNotVisible'], $node->getAttribute('style'))
]; )
&& !$node->hasAttribute('hidden');
return mb_encode_numericentity($html, $convmap, 'utf8', true);
} }
} }
+140 -134
View File
@@ -4,127 +4,138 @@ namespace Tests\Readability;
use Monolog\Handler\TestHandler; use Monolog\Handler\TestHandler;
use Monolog\Logger; use Monolog\Logger;
use Psr\Log\LoggerInterface;
use Readability\JSLikeHTMLElement;
use Readability\Readability; use Readability\Readability;
class ReadabilityTest extends \PHPUnit\Framework\TestCase class ReadabilityTest extends \PHPUnit\Framework\TestCase
{ {
/** @var TestHandler */
public $logHandler; public $logHandler;
/** @var LoggerInterface */
public $logger; public $logger;
/** /**
* @requires extension tidy * @requires extension tidy
*/ */
public function testConstructDefault() public function testConstructDefault(): void
{ {
$readability = $this->getReadability(''); $readability = $this->getReadability('');
$readability->init();
$this->assertNull($readability->url); $this->assertNull($readability->url);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom); $this->assertInstanceOf('DomDocument', $readability->dom);
} }
public function testConstructHtml5Parser() public function testConstructHtml5Parser(): void
{ {
$readability = $this->getReadability('<html/>', 'http://0.0.0.0', 'html5lib'); $readability = $this->getReadability('<html/>', 'http://0.0.0.0', 'html5lib');
$readability->init();
$this->assertSame('http://0.0.0.0', $readability->url); $this->assertSame('http://0.0.0.0', $readability->url);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom); $this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertSame('<html/>', $readability->original_html); $this->assertSame('<html/>', $readability->original_html);
} }
/** /**
* @requires extension tidy * @requires extension tidy
*/ */
public function testConstructSimple() public function testConstructSimple(): void
{ {
$readability = $this->getReadability('<html/>', 'http://0.0.0.0'); $readability = $this->getReadability('<html/>', 'http://0.0.0.0');
$readability->init();
$this->assertSame('http://0.0.0.0', $readability->url); $this->assertSame('http://0.0.0.0', $readability->url);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom); $this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertSame('<html/>', $readability->original_html); $this->assertSame('<html/>', $readability->original_html);
$this->assertTrue($readability->tidied); $this->assertTrue($readability->tidied);
} }
public function testConstructDefaultWithoutTidy() public function testConstructDefaultWithoutTidy(): void
{ {
$readability = $this->getReadability('', null, 'libxml', false); $readability = $this->getReadability('', null, 'libxml', false);
$readability->init();
$this->assertNull($readability->url); $this->assertNull($readability->url);
$this->assertSame('', $readability->original_html); $this->assertSame('', $readability->original_html);
$this->assertFalse($readability->tidied); $this->assertFalse($readability->tidied);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom); $this->assertInstanceOf('DomDocument', $readability->dom);
} }
public function testConstructSimpleWithoutTidy() public function testConstructSimpleWithoutTidy(): void
{ {
$readability = $this->getReadability('<html/>', 'http://0.0.0.0', 'libxml', false); $readability = $this->getReadability('<html/>', 'http://0.0.0.0', 'libxml', false);
$readability->init();
$this->assertSame('http://0.0.0.0', $readability->url); $this->assertSame('http://0.0.0.0', $readability->url);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom); $this->assertInstanceOf('DomDocument', $readability->dom);
$this->assertSame('<html/>', $readability->original_html); $this->assertSame('<html/>', $readability->original_html);
$this->assertFalse($readability->tidied); $this->assertFalse($readability->tidied);
} }
public function testInitNoContent() public function testInitNoContent(): void
{ {
$readability = $this->getReadability('<html/>', 'http://0.0.0.0'); $readability = $this->getReadability('<html/>', 'http://0.0.0.0');
$res = $readability->init(); $res = $readability->init();
$this->assertFalse($res); $this->assertFalse($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('Sorry, Readability was unable to parse this page for content.', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('Sorry, Readability was unable to parse this page for content.', $readability->getContent()->getInnerHtml());
} }
public function testInitP() public function testInitP(): void
{ {
$readability = $this->getReadability(str_repeat('<p>This is the awesome content :)</p>', 7), 'http://0.0.0.0'); $readability = $this->getReadability(str_repeat('<p>This is the awesome content :)</p>', 7), 'http://0.0.0.0');
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is the awesome content :)', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is the awesome content :)', $readability->getContent()->getInnerHtml());
} }
public function testInitDivP() public function testInitDivP(): void
{ {
$readability = $this->getReadability('<div>' . str_repeat('<p>This is the awesome content :)</p>', 7) . '</div>', 'http://0.0.0.0'); $readability = $this->getReadability('<div>' . str_repeat('<p>This is the awesome content :)</p>', 7) . '</div>', 'http://0.0.0.0');
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is the awesome content :)', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is the awesome content :)', $readability->getContent()->getInnerHtml());
} }
public function testInitDiv() public function testInitDiv(): void
{ {
$readability = $this->getReadability('<div>' . str_repeat('This is the awesome content :)', 7) . '</div>', 'http://0.0.0.0'); $readability = $this->getReadability('<div>' . str_repeat('This is the awesome content :)', 7) . '</div>', 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is the awesome content :)', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is the awesome content :)', $readability->getContent()->getInnerHtml());
} }
public function testWithFootnotes() 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 = $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; $readability->convertLinksToFootnotes = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
@@ -132,15 +143,16 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertStringContainsString('readabilityLink-3', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('readabilityLink-3', $readability->getContent()->getInnerHtml());
} }
public function testStandardClean() 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 = $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; $readability->lightClean = false;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
@@ -148,178 +160,187 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertStringNotContainsString('<h2>', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('<h2>', $readability->getContent()->getInnerHtml());
} }
public function testWithIframe() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<div readability=', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringContainsString('nofollow', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('nofollow', $readability->getContent()->getInnerHtml());
} }
public function testWithArticle() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('alt="article"', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('alt="article"', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringContainsString('nofollow', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('nofollow', $readability->getContent()->getInnerHtml());
} }
public function testWithAside() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('<aside>', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('<aside>', $readability->getContent()->getInnerHtml());
$this->assertStringContainsString('<footer readability="5"/>', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<footer readability="9"/>', $readability->getContent()->getInnerHtml());
} }
public function testWithClasses() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('alt="article"', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('alt="article"', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('This text should be removed', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('This text should be removed', $readability->getContent()->getInnerHtml());
} }
public function testWithClassesWithoutLightClean() 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 = $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; $readability->lightClean = false;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('alt="article"', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('alt="article"', $readability->getContent()->getInnerHtml());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('This text should be removed', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('This text should be removed', $readability->getContent()->getInnerHtml());
} }
public function testWithTd() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
} }
public function testWithSameClasses() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml());
} }
public function testWithScript() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertEmpty($readability->getTitle()->getInnerHtml()); $this->assertEmpty($readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml());
} }
public function testTitle() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertSame('this is my title', $readability->getTitle()->getInnerHtml()); $this->assertSame('this is my title', $readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml());
} }
public function testTitleWithDash() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertSame('title2 - title3', $readability->getTitle()->getInnerHtml()); $this->assertSame('title2 - title3', $readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml());
} }
public function testTitleWithDoubleDot() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertSame('title2 : title3', $readability->getTitle()->getInnerHtml()); $this->assertSame('title2 : title3', $readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml());
} }
public function testTitleTooShortUseH1() 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 = $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(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertSame('this is my h1 title !', $readability->getTitle()->getInnerHtml()); $this->assertSame('this is my h1 title !', $readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This is an awesome text with some links, here there are', $readability->getContent()->getInnerHtml());
$this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml()); $this->assertStringNotContainsString('This text is also an awesome text and you should know that', $readability->getContent()->getInnerHtml());
} }
public function testAutoClosingIframeNotThrowingException(): void
{
$oldErrorReporting = error_reporting(\E_ALL | \E_STRICT);
$oldDisplayErrors = ini_set('display_errors', '1');
// dummy function to be used to the next test // dummy function to be used to the next test
public function error2Exception($code, $string, $file, $line) set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline, array $errcontext) {
{ throw new \Exception($errstr, $errno);
throw new \Exception($string, $code); }, \E_ALL | \E_STRICT);
}
public function testAutoClosingIframeNotThrowingException()
{
$oldErrorReporting = error_reporting(\E_ALL);
$oldDisplayErrors = ini_set('display_errors', true);
set_error_handler([$this, 'error2Exception']);
try { try {
$data = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> $data = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
@@ -344,18 +365,17 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</body> </body>
</html>'; </html>';
$readability = $this->getReadability($data, 'http://iosgames.ru/?p=22030'); $readability = $this->getReadability($data, 'http://iosgames.ru/?p=22030');
$readability->debug = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertStringContainsString('<iframe src="https://www.youtube.com/embed/PUep6xNeKjA" width="560" height="315" frameborder="0" allowfullscreen="allowfullscreen"> </iframe>', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<iframe src="https://www.youtube.com/embed/PUep6xNeKjA" width="560" height="315" frameborder="0" allowfullscreen="allowfullscreen"> </iframe>', $readability->getContent()->getInnerHtml());
$this->assertStringContainsString('3D Touch', $readability->getTitle()->getInnerHtml()); $this->assertStringContainsString('3D Touch', $readability->getTitle()->getInnerHtml());
} finally { } finally {
@@ -370,7 +390,7 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
/** /**
* This should generate an Exception "DOMElement::setAttribute(): ID post-60 already defined". * This should generate an Exception "DOMElement::setAttribute(): ID post-60 already defined".
*/ */
public function testAppendIdAlreadyHere() public function testAppendIdAlreadyHere(): void
{ {
$data = '<!DOCTYPE html> $data = '<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
@@ -417,15 +437,16 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
</html>'; </html>';
$readability = $this->getReadability($data, 'http://0.0.0.0'); $readability = $this->getReadability($data, 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getTitle()); $this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
} }
public function testPostFilters() public function testPostFilters(): void
{ {
$readability = $this->getReadability('<div>' . str_repeat('<p>This <strong>is</strong> the awesome content :)</p>', 10) . '</div>', 'http://0.0.0.0'); $readability = $this->getReadability('<div>' . str_repeat('<p>This <strong>is</strong> the awesome content :)</p>', 10) . '</div>', 'http://0.0.0.0');
$readability->addPostFilter('!<strong[^>]*>(.*?)</strong>!is', ''); $readability->addPostFilter('!<strong[^>]*>(.*?)</strong>!is', '');
@@ -436,10 +457,8 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertStringContainsString('This the awesome content :)', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This the awesome content :)', $readability->getContent()->getInnerHtml());
} }
public function testPreFilters() public function testPreFilters(): void
{ {
$this->markTestSkipped('Won\'t work until loadHtml() is moved in init() instead of __construct()');
$readability = $this->getReadability('<div>' . str_repeat('<p>This <b>is</b> the awesome and WONDERFUL content :)</p>', 7) . '</div>', 'http://0.0.0.0'); $readability = $this->getReadability('<div>' . str_repeat('<p>This <b>is</b> the awesome and WONDERFUL content :)</p>', 7) . '</div>', 'http://0.0.0.0');
$readability->addPreFilter('!<b[^>]*>(.*?)</b>!is', ''); $readability->addPreFilter('!<b[^>]*>(.*?)</b>!is', '');
@@ -449,24 +468,26 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertStringContainsString('This the awesome and WONDERFUL content :)', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('This the awesome and WONDERFUL content :)', $readability->getContent()->getInnerHtml());
} }
public function testChildNodeGoneNull() public function testChildNodeGoneNull(): void
{ {
// from http://www.ayyaantuu.net/ethiopia-targets-opposition-lawmakers/ // from http://www.ayyaantuu.net/ethiopia-targets-opposition-lawmakers/
$html = file_get_contents('tests/fixtures/childNodeGoesNull.html'); $html = (string) file_get_contents('tests/fixtures/childNodeGoesNull.html');
$readability = $this->getReadability($html, 'http://0.0.0.0'); $readability = $this->getReadability($html, 'http://0.0.0.0');
$readability->debug = true;
$readability->convertLinksToFootnotes = true; $readability->convertLinksToFootnotes = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
} }
public function testKeepFootnotes() public function testKeepFootnotes(): void
{ {
// from https://www.schreibdichte.de/blog/feed-aggregator-und-spaeter-lesen-dienst-im-team // from https://www.schreibdichte.de/blog/feed-aggregator-und-spaeter-lesen-dienst-im-team
$html = file_get_contents('tests/fixtures/keepFootnotes.html'); $html = (string) file_get_contents('tests/fixtures/keepFootnotes.html');
$readability = $this->getReadability($html, 'http://0.0.0.0'); $readability = $this->getReadability($html, 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
@@ -474,77 +495,62 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertStringContainsString('<a href="#fnref1:fnfeed_2" rev="footnote"', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<a href="#fnref1:fnfeed_2" rev="footnote"', $readability->getContent()->getInnerHtml());
} }
public function testWithWipedBody() public function testWithWipedBody(): void
{ {
// from https://www.cs.cmu.edu/~rgs/alice-table.html // from https://www.cs.cmu.edu/~rgs/alice-table.html
$html = file_get_contents('tests/fixtures/wipedBody.html'); $html = (string) file_get_contents('tests/fixtures/wipedBody.html');
$readability = $this->getReadability($html, 'http://0.0.0.0', 'libxml', false); $readability = $this->getReadability($html, 'http://0.0.0.0', 'libxml', false);
$readability->debug = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); $this->assertTrue($res);
$this->assertStringContainsString('<a href="alice-I.html">Down the Rabbit-Hole</a>', $readability->getContent()->getInnerHtml()); $this->assertStringContainsString('<a href="alice-I.html">Down the Rabbit-Hole</a>', $readability->getContent()->getInnerHtml());
} }
// https://github.com/wallabag/wallabag/issues/8158 public function dataForVisibleNode(): array
public function testCharsetAfterTitle()
{
$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()
{ {
return [ return [
'meta' => [ 'visible node' => [
'<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>', '<div>' . str_repeat('<p>This <b>is</b> the awesome and WONDERFUL content :)</p>', 7) . '</div>',
'fr', true,
], ],
'head' => [ 'display=none' => [
'<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>', '<div style="display:none;">' . str_repeat('<p>This <b>is</b> the awesome and WONDERFUL content :)</p>', 7) . '</div>',
'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, false,
], ],
'fragment' => [ 'display=inline' => [
'<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>', '<div style="display:inline;">' . str_repeat('<p>This <b>is</b> the awesome and WONDERFUL content :)</p>', 7) . '</div>',
'', true,
// tidy would add <html>. ],
'hidden attribute' => [
'<div hidden>' . str_repeat('<p>This <b>is</b> the awesome and WONDERFUL content :)</p>', 7) . '</div>',
false, false,
], ],
'missing display' => [
'<div style="color:#ccc;">' . str_repeat('<p>This <b>is</b> the awesome and WONDERFUL content :)</p>', 7) . '</div>',
true,
],
]; ];
} }
/** /**
* @dataProvider dataForHtmlLang * @dataProvider dataForVisibleNode
*/ */
public function testHtmlLang($html, $lang, $useTidy = true) public function testVisibleNode(string $content, bool $shouldBeVisible): void
{ {
$readability = $this->getReadability($html, 'http://0.0.0.0', 'libxml', $useTidy); $readability = $this->getReadability($content, 'http://0.0.0.0');
$readability->debug = true;
$res = $readability->init(); $res = $readability->init();
$this->assertTrue($res); if ($shouldBeVisible) {
$this->assertInstanceOf(\DOMDocument::class, $readability->dom); $this->assertStringContainsString('WONDERFUL content', $readability->getContent()->getInnerHtml());
$this->assertSame($lang, $readability->dom->documentElement->getAttribute('lang')); } else {
$this->assertInstanceOf('Readability\JSLikeHTMLElement', $readability->getContent()); $this->assertStringNotContainsString('WONDERFUL content', $readability->getContent()->getInnerHtml());
$this->assertStringContainsString('êtres', $readability->getContent()->getInnerHtml()); }
} }
private function getReadability($html, $url = null, $parser = 'libxml', $useTidy = true) private function getReadability(string $html, string $url = null, string $parser = 'libxml', bool $useTidy = true): Readability
{ {
$readability = new Readability($html, $url, $parser, $useTidy); $readability = new Readability($html, $url, $parser, $useTidy);