Mit der folgenden Implementierung eines Console Commands wird der Cache der indizierten Suche (EXT:indexed_search) gelöscht und anschließend automatisiert neu generiert. Dieser Ansatz basiert auf der Voraussetzung, dass für die TYPO3 Instanz eine entsprechende Konfiguration zur automatischen Generierung von XML Sitemaps (EXT:seo) vorhanden ist, was bei jedem ordentlichen Projekt der Fall sein sollte (die Sitemaps sollten natürlich auch sämtliche Datensätze aus individuellen Erweiterungen beinhalten). Der Neuaufbau des Caches erfolgt, indem auf jede der in den Sitemaps enthaltenen URLs ein regulärer Frontend Request ausgeführt wird. Die Implementierung funktioniert sowohl in Multidomain Umgebungen als auch in mehrsprachigen Instanzen, da die URLs der verschiedenen Sitemaps (bzw. Sitemap Index Files) dynamisch auf Basis der SiteConfiguration über die Klassen Site sowie SiteLanguage generiert werden.

Console Command

<?php
namespace Vendor\Extension\Command;


use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Extbase\Utility\DebuggerUtility;

/**
 * IndexedSearchCacheCommand
 */
class IndexedSearchCacheCommand extends Command {

    /**
     * @var array
     */
    private $tablesTruncate = [
        'index_phash',
        'index_fulltext',
        'index_rel',
        'index_words',
        'index_section',
        'index_grlist'
    ];
    
    /**
     * @var array
     */
    private $urls = [];
    
    /**
     * @var array
     */
    private $statusCodes = [];
    
    /**
     * @var RequestFactory
     */
    protected $requestFactory;

    /**
     * @var SiteFinder
     */
    protected $siteFinder;
    
    /**
     * @param RequestFactory $requestFactory
     */
    public function injectRequestFactory(RequestFactory $requestFactory)
    {
        $this->requestFactory = $requestFactory;
    }

    /**
     * @param SiteFinder $siteFinder
     */
    public function injectSiteFinder(SiteFinder $siteFinder)
    {
        $this->siteFinder = $siteFinder;
    }

    /**
     * Configures the command
     */
    protected function configure()
    {
        $this->setDescription('Clears and rebuilds the indexed_search cache by crawling sitemap.xml.');
        $this->setHelp('Clears and rebuilds the indexed_search cache by crawling sitemap.xml for all sites and languages.' . LF . 'If you want to get more detailed information, use the --verbose option.');
        $this->addOption('dry-run', 'd', InputOption::VALUE_NONE, 'Performs a dry run without truncating "indexed_search" tables and without requesting the urls.');
        $this->addOption('silent', 's', InputOption::VALUE_NONE, 'Do not show status status when requesting the urls.');
    }

    /**
     * Executes the command
     *
     * @param InputInterface $input
     * @param OutputInterface $output
     * @return int error code
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        
        $io = new SymfonyStyle($input, $output);

        // For each site
        $sites = $this->getSites();

        if(!empty($sites)) {

            $io->writeln('Found ' . count($sites) . ' sites.');

            /** @var Site $site */
            foreach($sites as $site) {

                $io->newLine();
                $io->writeln('Processing site "' . $site->getIdentifier() . '" (' . $site->getBase() . ').');

                // For each language
                $siteLanguages = $site->getLanguages();

                if(!empty($siteLanguages)) {

                    $io->writeln('Found ' . count($siteLanguages) . ' site languages.');

                    /** @var SiteLanguage $siteLanguage */
                    foreach($siteLanguages as $siteLanguage) {

                        $io->newLine();
                        $io->writeln('Processing site language "' . $siteLanguage->getTitle() . '" (' . $siteLanguage->getBase() . ').');

                        // Crawling the sitemaps
                        $sitemapIndexUrl = $this->getSitemapIndexUrl($site, $siteLanguage);
                        $sitemapIndex = $this->getSitemap($sitemapIndexUrl);

                        if(!empty($sitemapIndex->sitemap)) {

                            // Sitemap index was found
                            $io->writeln('Found ' . count($sitemapIndex->sitemap) . ' sitemaps in sitemap index "' . $sitemapIndexUrl . '".');

                            // Process each sitemap
                            foreach($sitemapIndex->sitemap as $item) {
                                $sitemapUrl = strval($item->loc);
                                $sitemap = $this->getSitemap($sitemapUrl);
                                if(!empty($sitemap->url)) {
                                    $io->writeln('Found ' . count($sitemap->url) . ' urls in sitemap "' . $sitemapUrl . '".');
                                    // Process each url
                                    foreach($sitemap->url as $item) {
                                        $this->urls[] = strval($item->loc);
                                    }
                                } else {
                                    $io->writeln('Sitemap  "' . $sitemapUrl . '" could not be fetched.');
                                }
                            }

                        } else {
                            $io->writeln('Sitemap index "' . $sitemapIndexUrl . '" could not be fetched.');
                        }

                    }

                } else {
                    $io->writeln('Not site languages found.');
                }

            }

            // Only proceed if any urls had been fetched before
            if(is_array($this->urls)  && !empty($this->urls)) {
                $io->newLine();
                $io->writeln('Fetched ' . count($this->urls) . ' urls total for processing.');
                // Truncate indexed_search tables (only when not performing dry run)
                if($input->getOption('dry-run') != true) {
                    $this->truncateIndexedSearchTables();
                    $io->writeln('Truncated ' . count($this->tablesTruncate) . ' "indexed_search" tables.');
                } else {
                    $io->writeln('Skipping truncating "indexed_search" tables (performing dry run).');
                }
                // Process each url (make request to rebuild cache)
                $io->writeln('Processing ' . count($this->urls) . ' urls now.');
                $io->newLine();
                foreach($this->urls as $url) {
                    if($input->getOption('dry-run') != true) {
                        $statusCode = $this->requestUrl($url);
                    } else {
                        $statusCode = 'dry-run';
                    }
                    $this->statusCodes[$statusCode]++;
                    if($input->getOption('silent') != true) {
                        $io->writeln('Got status code "' . $statusCode . '" when requesting "' . $url . '".');
                    }
                }
                // Summary
                if(is_array($this->statusCodes) && !empty($this->statusCodes)) {
                    foreach($this->statusCodes as $statusCode => $count) {
                        $io->writeln('Finished processing ' . $count . ' urls with status code "' . $statusCode . '".');
                    }
                }
            } else {
                $io->writeln('No urls found for processing.');
            }

        } else {
            $io->writeln('Not sites found.');
        }

        return 0;
    }
    
    /**
     * Performs a request
     * 
     * @param string $url
     * @return int
     */
    private function requestUrl($url) {
        try {
            $response = $this->requestFactory->request($url);
            $statusCode = $response->getStatusCode();
        } catch(\Exception $ex) {
            $statusCode = 999;
        }
        return $statusCode;        
    }


    /**
     * Gets all sites
     *
     * @return Site[]
     */
    private function getSites()
    {
        $allSites = $this->siteFinder->getAllSites(false);
        $sites = [];
        if(!empty($allSites)) {
            /** @var Site $site */
            foreach($allSites as $site) {
                // Skip top level (pseudo) site
                if($site->getRootPageId() != 1) {
                    $sites[] = $site;
                }
            }
        }
        return $sites;
    }
    
    /**
     * Gets a sitemap
     * 
     * @param string $url
     * @return object|array
     */
    private function getSitemap($url) {
        try {
            $response = $this->requestFactory->request($url);
            if ($response->getStatusCode() === 200) {
                if (strpos($response->getHeaderLine('Content-Type'), 'application/xml') === 0) {
                   $content = simplexml_load_string($response->getBody()->getContents());
                }
            }
        } catch(\Exception $ex) {
            
        }        
        return ($content ? $content : []);
    }
    
    /**
     * Gets a sitemap index url
     *
     * @param Site $site
     * @param SiteLanguage $siteLanguage
     * @return string
     */    
    private function getSitemapIndexUrl(Site $site, SiteLanguage $siteLanguage)
    {
        $parameters = [
            'type' => 9876,
            '_language' => $siteLanguage
        ];
        return (string) $site->getRouter()->generateUri($site->getRootPageId(), $parameters);
    }
    
    /**
     * Truncates the indexed_search tables
     */
    private function truncateIndexedSearchTables() {
        foreach($this->tablesTruncate as $table) {
            GeneralUtility::makeInstance(ConnectionPool::class)
                ->getConnectionForTable($table)
                ->truncate($table);
        }
    }
    
}

Service (Services.yaml)

services:
  Vendor\Extension\Command\IndexedSearchCacheCommand:
    tags:
      - name: 'console.command'
        command: 'vendor:indexedsearchcache'
        schedulable: true

Aufruf via Console

vendor/bin/typo3cms vendor:indexedsearchcache
vendor/bin/typo3cms vendor:indexedsearchcache --dry-run --silent