SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / HtmlProcessorTest.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants as CC;
use seekquarry\yioop\library\processors\HtmlProcessor;
use seekquarry\yioop\library\UnitTest;

/**
 * HtmlProcessorTest checks the title and description HtmlProcessor, with
 * its default ScrapeSummarizer, takes from small pages, each showing one
 * way a page may carry its text.
 */
class HtmlProcessorTest extends UnitTest
{
    /**
     * setUp makes the processor the cases share.
     */
    public function setUp()
    {
        $this->test_objects['PROCESSOR'] = new HtmlProcessor([], 2000);
    }
    /**
     * tearDown has nothing to remove.
     */
    public function tearDown()
    {
    }
    /**
     * Runs a page through the processor.
     *
     * @param string $page the page's HTML
     * @return array [title, description with its spaces collapsed]
     */
    protected function titleAndDescription($page)
    {
        $summary = $this->test_objects['PROCESSOR']->process($page,
            "https://example.com/page.html");
        return [$summary[CC::TITLE], trim(preg_replace('/\s+/u', ' ',
            $summary[CC::DESCRIPTION]))];
    }
    /**
     * textIsSeparatedAndDecodedTestCase checks that a line break separates
     * the words on each side of it, and that entities such as &egrave; are
     * read as the letters they stand for.
     */
    public function textIsSeparatedAndDecodedTestCase()
    {
        [, $description] = $this->titleAndDescription("<html><head>" .
            "<title>Contact</title></head><body><p>Hours<br>Mon to Fri</p>" .
            "<p>Cr&egrave;me br&ucirc;l&eacute;e</p></body></html>");
        $this->assertTrue(str_contains($description, "Hours Mon to Fri"),
            "a line break separates words");
        $this->assertTrue(str_contains($description, "Crème brûlée"),
            "entities are decoded");
    }
    /**
     * titleIsWholeTestCase checks that a long title is cut by characters,
     * leaving valid UTF-8, and that its line breaks become single spaces.
     */
    public function titleIsWholeTestCase()
    {
        [$title] = $this->titleAndDescription("<html><head><title>" .
            str_repeat("東京の天気予報", 20) . "</title></head>" .
            "<body><p>x</p></body></html>");
        $this->assertTrue(mb_check_encoding($title, "UTF-8"),
            "a cut title is valid UTF-8");
        $this->assertEqual(mb_strlen($title), HtmlProcessor::MAX_TITLE_LEN,
            "it is cut at the limit in characters");
        [$title] = $this->titleAndDescription("<html><head><title>\n  " .
            "Annual   Report\n 2025\n</title></head><body></body></html>");
        $this->assertEqual($title, "Annual Report 2025",
            "a title's line breaks and runs of spaces become one space");
    }
    /**
     * pageDescriptionsAreReadTestCase checks that an Open Graph
     * description, a JSON-LD headline and description, image alt text,
     * and text in main and figcaption all reach the description.
     */
    public function pageDescriptionsAreReadTestCase()
    {
        [, $description] = $this->titleAndDescription("<html><head>" .
            "<title>T</title><meta property='og:description' " .
            "content='Guide to the loop trail'><script " .
            "type='application/ld+json'>{\"headline\": \"Council approves " .
            "bike lanes\"}</script></head><body><main>Nine miles long." .
            "</main><img alt='Red ceramic teapot with handle'>" .
            "<img alt='logo'><figure><figcaption>Photo of the summit" .
            "</figcaption></figure></body></html>");
        foreach (["Guide to the loop trail", "Council approves bike lanes",
            "Nine miles long", "Red ceramic teapot with handle",
            "Photo of the summit"] as $text) {
            $this->assertTrue(str_contains($description, $text),
                "the description has: $text");
        }
        $this->assertFalse(str_contains($description, "logo"),
            "alt text of one word is left out");
    }
    /**
     * fullStopIsNotDoubledTestCase checks that a sentence ending in 。 gets
     * no period added after it.
     */
    public function fullStopIsNotDoubledTestCase()
    {
        [, $description] = $this->titleAndDescription("<html lang='ja'>" .
            "<head><title>天気</title></head><body><p>天気の情報です。</p>" .
            "</body></html>");
        $this->assertFalse(str_contains($description, "。."),
            "no period follows 。");
    }
    /**
     * pagesAreConvertedFromTheirEncodingTestCase checks convertToUtf8,
     * which the fetcher uses on a page that is not UTF-8: it converts from
     * the declared encoding, and reads an unknown or missing one as
     * Windows-1252.
     */
    public function pagesAreConvertedFromTheirEncodingTestCase()
    {
        foreach ([["ISO-8859-1", "ISO-8859-1", "Crème brûlée"],
            ["Shift_JIS", "SJIS", "東京の天気"],
            ["KOI8-R", "KOI8-R", "Привет мир"],
            ["", "Windows-1252", "Café"],
            ["no-such-encoding", "Windows-1252", "Naïve"]] as
            [$declared, $written_as, $text]) {
            $bytes = mb_convert_encoding($text, $written_as, "UTF-8");
            $this->assertEqual(L\convertToUtf8($bytes, $declared), $text,
                "text declared as '$declared' is converted");
        }
    }
    /**
     * declaredLanguageWinsATieTestCase checks a page that says lang="en"
     * and holds only a few words, laid out as google.com's home page lays
     * them out: links with no space between them. Its one stopword, "in",
     * is a stopword of German, English, Italian and Dutch alike, so the text
     * points to none of them; the page was stored as de, the first of the
     * tied locales, and dropped from every query joined with lang:en.
     */
    public function declaredLanguageWinsATieTestCase()
    {
        $summary = $this->test_objects['PROCESSOR']->process('<!doctype ' .
            'html><html lang="en"><head><meta content="text/html; ' .
            'charset=UTF-8" http-equiv="Content-Type"><title>Google</title>' .
            '</head><body><a href="/mail">Gmail</a><a href="/imghp">Images' .
            '</a><a href="/login">Sign in</a>&nbsp;<a href="/advanced">' .
            'Advanced search</a><a href="/ads">Advertising</a><a ' .
            'href="/services">Business Solutions</a><a href="/about">About ' .
            'Google</a><p>&copy; 2026 - <a href="/privacy">Privacy</a> - ' .
            '<a href="/terms">Terms</a></p><div>Google apps</div></body>' .
            '</html>', "https://www.google.com/");
        $this->assertTrue(str_starts_with($summary[CC::LANG], "en"),
            "the declared English is kept, not " . $summary[CC::LANG]);
    }
    /**
     * jsonLdTextIsKeptWholeTestCase checks the JSON-LD text a page carries,
     * which HtmlProcessor puts at the start of the body. A dollar amount
     * in it, such as $250, must keep its digits, and the body must keep
     * its attributes, since a scraper signature such as phpBB's tests the
     * body's id.
     */
    public function jsonLdTextIsKeptWholeTestCase()
    {
        $processor = new HtmlProcessor([], 2000);
        $processor->scrapers = [["NAME" => "PHPBB",
            "SIGNATURE" => "//body[@id='phpbb']", "TEXT_PATH" => "",
            "DELETE_PATHS" => "", "EXTRACT_FIELDS" => "", "PRIORITY" => 0]];
        $summary = $processor->process("<html><head><title>Clay shoot" .
            "</title><script type='application/ld+json'>{\"description\": " .
            "\"Tickets cost $250 each.\"}</script></head><body id='phpbb'>" .
            "<p>Join us at the range.</p></body></html>",
            "https://example.com/shoot.html");
        $this->assertTrue(str_contains($summary[CC::DESCRIPTION],
            "Tickets cost $250 each"), "the dollar amount keeps its digits");
        $this->assertEqual($summary[CC::SCRAPER_LABEL] ?? "", "PHPBB",
            "the body keeps the id the scraper signature tests");
    }
    /**
     * largeInlineStyleKeepsTheTextTestCase checks that a page whose head
     * holds a megabyte of inline style, as the Gatsby pages of
     * corporate.charter.com do, still gives its text. A lazy pattern taking
     * out style elements gave up on such a page and the processor found
     * no text at all. A script element whose close tag has a space before
     * its ">" is taken out too, and an element of another kind is left.
     */
    public function largeInlineStyleKeepsTheTextTestCase()
    {
        $rule = ".a{color:red}";
        $style = "<style data-href='/styles.css'>" . str_repeat($rule,
            intdiv(self::LARGE_STYLE, strlen($rule))) . "</style>";
        [, $description] = $this->titleAndDescription("<html><head>" .
            "<title>T</title>$style</head><body><p>Spectrum expanded its " .
            "fiber network in Polk County.</p></body></html>");
        $this->assertTrue(str_contains($description,
            "Spectrum expanded its fiber network in Polk County"),
            "the text after a megabyte of style is read");
        $this->assertEqual(L\removeTagSpans("a<SCRIPT x>b</script >c" .
            "<style>d", "script", " ", true), "a c<style>d",
            "a script with a spaced close tag goes and an element of " .
            "another kind stays");
    }
    /**
     * pageCutInsideScriptGivesNoCodeTestCase checks that a page whose
     * end was cut off inside a script, as a fetcher keeps only the start
     * of a long page, gives the page's text and none of the script's
     * code. Pages of vx-3.com cut this way gave the summarizer a thousand
     * sentences of a store's bundle list in JSON, and each took seconds.
     */
    public function pageCutInsideScriptGivesNoCodeTestCase()
    {
        $bundles = "";
        for ($i = 0; $i < 50; $i++) {
            $bundles .= "\"$i\":{\"bundle_id\":50$i,\"handle\":" .
                "\"rugby-bundle-$i\",\"type\":\"multi\"},";
        }
        [, $description] = $this->titleAndDescription("<html><head>" .
            "<title>T</title></head><body><p>Redefine outdoor comfort " .
            "with the sports robe.</p><script>var bundles={$bundles}");
        $this->assertTrue(str_contains($description,
            "Redefine outdoor comfort with the sports robe"),
            "the page's text is read");
        $this->assertTrue(!str_contains($description, "bundle"),
            "the cut off script's code is not");
        $this->assertEqual(L\removeTagSpans("a<script>b</script>c" .
            "<script>d", "script"), "a c ",
            "a script with no close tag goes with the rest of the page");
    }
    /**
     * LARGE_STYLE is how many bytes of style the large inline style case
     * puts in a page's head: about the megabyte the charter pages carry,
     * past what pcre.backtrack_limit lets a lazy pattern read.
     */
    const LARGE_STYLE = 1100000;
}