'/banner|combx|comment|community|disqus|extra|foot|header|menu|modal|related|remark|rss|share|shoutbox|sidebar|skyscraper|sponsor|ad-break|agegate|pagination|pager|popup/i', 'okMaybeItsACandidate' => '/and|article|body|column|main|shadow/i', 'extraneous' => '/print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i', 'byline' => '/byline|author|dateline|writtenby|p-author/i', 'replaceFonts' => '/<(\/?)font[^>]*>/gi', 'normalize' => '/\s{2,}/g', 'videos' => '/\/\/(www\.)?(dailymotion|youtube|youtube-nocookie|player\.vimeo)\.com/i', 'nextLink' => '/(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i', 'prevLink' => '/(prev|earl|old|new|<|«)/i', 'whitespace' => '/^\s*$/', 'hasContent' => '/\S$/', ]; /** * Constructor. * @param array $options Options to override the default ones */ public function __construct(array $options = []) { $defaults = array( 'maxTopCandidates' => 5, // Max amount of top level candidates ); $this->environment = Environment::createDefaultEnvironment($defaults); $this->environment->getConfig()->merge($options); $this->dom = new DOMDocument('1.0', 'utf-8'); // To avoid having a gazillion of errors on malformed HTMLs libxml_use_internal_errors(true); } /** * Parse the html. This is the main entry point of the HTMLParser. * * @param string $html Full html of the website, page, etc. * * #return ? TBD */ public function parse($html) { $this->loadHTML($html); $this->removeScripts(); $this->metadata = $this->getMetadata(); $this->title = $this->getTitle(); if (!($root = $this->dom->getElementsByTagName('body')->item(0))) { throw new \InvalidArgumentException('Invalid HTML was provided'); } $root = new Readability($root); $this->getNodes($root); $this->rateNodes($this->elementsToScore); } /** * @param string $html */ private function loadHTML($html) { $this->dom->loadHTML($html); $this->dom->encoding = 'UTF-8'; } /** * @return Configuration */ public function getConfig() { return $this->environment->getConfig(); } /** * Removes all the scripts of the html. * * @TODO is this really necessary? Readability.js uses it to chop any script that might interfere with their * system. Is it necessary here? */ private function removeScripts() { while ($script = $this->dom->getElementsByTagName('script')) { if ($script->item(0)) { $script->item(0)->parentNode->removeChild($script->item(0)); } else { break; } } } /** * Tries to guess relevant info from metadata of the html. * * @return array Metadata info. May have title, excerpt and or byline. */ private function getMetadata() { $metadata = []; foreach ($this->dom->getElementsByTagName('meta') as $meta) { /* @var Readability $meta */ $name = $meta->getAttribute('name'); $property = $meta->getAttribute('property'); // Select either name or property $item = ($name ? $name : $property); if ($item == 'og:title' || $item == 'twitter:title') { $metadata['title'] = $meta->getAttribute('content'); } if ($item == 'og:description' || $item == 'twitter:description') { $metadata['excerpt'] = $meta->getAttribute('content'); } if ($item == 'author') { $metadata['byline'] = $meta->getAttribute('content'); } } return $metadata; } /** * 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. * * @param Readability $readability * * @return int */ public function getLinkDensity($readability) { $linkLength = 0; $textLength = strlen($readability->getTextContent()); if (!$textLength) { return 0; } $links = $readability->getAllLinks(); if ($links) { foreach ($links as $link) { // TODO This is not very pretty, $link should be a Element type $linkLength += strlen($link->C14N()); } } return $linkLength / $textLength; } /** * Returns the title of the html. Prioritizes the title from the metadata against the title tag. * * @return string|null */ private function getTitle() { if (isset($this->metadata['title'])) { return $this->metadata['title']; } $title = $this->dom->getElementsByTagName('title'); if ($title) { return $title->item(0)->nodeValue; } return null; } /** * Gets nodes from the root element. * * @param $node ReadabilityInterface */ private function getNodes(ReadabilityInterface $node) { $matchString = $node->getAttribute('class') . ' ' . $node->getAttribute('id'); // Avoid elements that are unlikely to have any useful information. if ( preg_match($this->regexps['unlikelyCandidates'], $matchString) && !preg_match($this->regexps['okMaybeItsACandidate'], $matchString) && !$node->tagNameEqualsTo('body') && !$node->tagNameEqualsTo('a') ) { return; } // Loop over the element if it has children if ($node->hasChildren()) { foreach ($node->getChildren() as $child) { $this->getNodes($child); } } // Check for nodes that have only on P node as a child and convert them to a single P node if ($node->hasSinglePNode()) { $pNode = $node->getChildren(); $node = $pNode[0]; } // If there's any info on the node, add it to the elements to score in the next step. if (trim($node->getValue())) { $this->elementsToScore[] = $node; } } /** * Assign scores to each node. This function will rate each node and return a Readability object for each one. * * @param array $nodes */ private function rateNodes($nodes) { $candidates = []; /** @var Readability $node */ foreach ($nodes as $node) { // Discard nodes with less than 25 characters if (strlen($node->getValue()) < 25) { continue; } $ancestors = $node->getNodeAncestors(); // Exclude nodes with no ancestor if ($ancestors === 0) { continue; } // Start with a point for the paragraph itself as a base. $contentScore = 1; // Add points for any commas within this paragraph. $contentScore += count(explode(', ', $node->getValue())); // For every 100 characters in this paragraph, add another point. Up to 3 points. $contentScore += min(floor(strlen($node->getValue()) / 100), 3); // Initialize and score ancestors. /** @var Readability $ancestor */ foreach ($ancestors as $level => $ancestor) { $readability = $ancestor->initializeNode(); /* * Node score divider: * - parent: 1 (no division) * - grandparent: 2 * - great grandparent+: ancestor level * 3 */ if ($level === 0) { $scoreDivider = 1; } elseif ($level === 1) { $scoreDivider = 2; } else { $scoreDivider = $level * 3; } $currentScore = $readability->getContentScore(); $readability->setContentScore($currentScore + ($contentScore / $scoreDivider)); $candidates[] = $readability; } } /* * After we've calculated scores, loop through all of the possible * candidate nodes we found and find the one with the highest score. */ $topCandidates = []; foreach ($candidates as $candidate) { /* * Scale the final candidates score based on link density. Good content * should have a relatively small link density (5% or less) and be mostly * unaffected by this operation. */ $candidate->setContentScore($candidate->getContentScore() * (1 - $this->getLinkDensity($candidate))); for ($i = 1; $i < $this->getConfig()->getOption('maxTopCandidates'); $i++) { $aTopCandidate = isset($topCandidates[$i]) ? $topCandidates[$i] : null; if (!$aTopCandidate || $candidate->getContentScore() > $aTopCandidate->getContentScore()) { array_splice($topCandidates, $i, 0, [$candidate]); if (count($topCandidates) > $this->getConfig()->getOption('maxTopCandidates')) { array_pop($topCandidates); } break; } } } $topCandidate = isset($topCandidates[0]) ? $topCandidates[0] : null; $neededToCreateTopCandidate = false; /* * If we still have no top candidate, just use the body as a last resort. * We also have to copy the body node so it is something we can modify. */ if ($topCandidate === null || $topCandidate->tagNameEqualsTo('body')) { //TODO } elseif ($topCandidate) { /* * Because of our bonus system, parents of candidates might have scores * themselves. They get half of the node. There won't be nodes with higher * scores than our topCandidate, but if we see the score going *up* in the first * few steps up the tree, that's a decent sign that there might be more content * lurking in other places that we want to unify in. The sibling stuff * below does some of that - but only if we've looked high enough up the DOM * tree. */ $parentOfTopCandidate = $topCandidate->getParent(); $lastScore = $topCandidate->getContentScore(); // The scores shouldn't get too low. $scoreThreshold = $lastScore / 3; while ($parentOfTopCandidate) { /** @var Readability $parentOfTopCandidate */ $parentScore = $parentOfTopCandidate->getContentScore(); if ($parentScore < $scoreThreshold) { break; } if ($parentScore > $lastScore) { // Alright! We found a better parent to use. $topCandidate = $parentOfTopCandidate; break; } $lastScore = $parentOfTopCandidate->getContentScore(); $parentOfTopCandidate = $parentOfTopCandidate->getParent(); } } /* * Now that we have the top candidate, look through its siblings for content * that might also be related. Things like preambles, content split by ads * that we removed, etc. */ } }