SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / storage_formats / FeedDocumentBundle.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\library\storage_formats;
use seekquarry\yioop\library as L;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\library\data_structures\BloomFilterFile;
use seekquarry\yioop\library\data_structures\UndoLog;
use seekquarry\yioop\library\language_processing\PhraseParser;

/**
 * Used for crawlLog, crawlHash, and garbageCollect
 */
require_once __DIR__ . '/../Utility.php';
/**
 * FeedDocumentBundle is the index of news feed items, kept in the
 * cache folder named IndexDataFeed, that FeedsUpdateJob adds items to
 * and searches of news read. It is an IndexDocumentBundle with two
 * Bloom filters, files that answer quickly whether an item's id was seen
 * before, so an item is added once.
 *
 * @author Chris Pollett
 */
class FeedDocumentBundle extends IndexDocumentBundle
{
   /**
     * OLD_ITEM_TIME is how long, in seconds, a feed item is kept before it
     * counts as too old to index.
     */
    const OLD_ITEM_TIME = 4 * C\ONE_WEEK;
    /**
     * $db is the database connection the bundle writes the counts of
     * trending terms to.
     * @var DatasourceManager
     */
    public $db;
    /**
     * $feeds holds the settings of the news feeds whose items are stored
     * in this bundle, such as each feed's language and category.
     * FeedsUpdateJob sets it before the bundle builds a partition.
     * @var array
     */
    public $feeds;
    /**
     * $filter_a is the Bloom filter that holds the ids of the feed items
     * stored in this bundle, and is the one asked whether an item is new.
     * Once it holds more than half of URL_FILTER_SIZE ids, each id is also
     * added to $filter_b. Once it holds more than URL_FILTER_SIZE ids,
     * $filter_b takes its place, so the filter never grows too full to
     * answer well.
     * @var BloomFilterFile
     */
    public $filter_a;
    /**
     * $filter_b is the second Bloom filter of feed item ids. It is started
     * once $filter_a is more than half full, gets every id added after
     * that, and replaces $filter_a once $filter_a is full. It is null
     * until it is started.
     * @var BloomFilterFile
     */
    public $filter_b;
    /**
     * __construct opens the feed bundle in $dir_name, making it if it is
     * not there, and loads its Bloom filters from their files.
     * FeedsUpdateJob::getFeedBundle calls it each time MediaUpdater starts.
     *
     * @param string $dir_name folder name to store this bundle
     * @param object $db datasource handle used by the feed bundle to
     *  read/write its associated FEED_ITEM table rows
     * @param bool $read_only_archive whether to open archive only for reading
     *  or reading and writing
     * @param string $description a text name/serialized info about this
     *      IndexDocumentBundle
     * @param int $num_docs_per_partition the number of pages to be stored
     *      in a single shard
     */
    public function __construct($dir_name, $db, $read_only_archive = true,
        $description = null, $num_docs_per_partition =
        C\NUM_DOCS_PER_PARTITION)
    {
        parent::__construct($dir_name, $read_only_archive, $description,
            $num_docs_per_partition);
        $this->feeds = []; // set by FeedsUpdateJob
        $this->db = $db;
        if (file_exists($dir_name . "/filter_a.ftr")) {
            $this->filter_a = BloomFilterFile::load($dir_name .
                "/filter_a.ftr");
            $this->filter_a->filename = $dir_name . "/filter_a.ftr";
        } else {
            $this->filter_a = new BloomFilterFile($dir_name . "/filter_a.ftr",
                C\URL_FILTER_SIZE);
            set_error_handler(null);
            @chmod($dir_name . "/filter_a.ftr", 0755);
            restore_error_handler();
        }
        if (file_exists($dir_name . "/filter_b.ftr")) {
            $this->filter_b = BloomFilterFile::load($dir_name .
                "/filter_b.ftr");
            $this->filter_b->filename = $dir_name . "/filter_b.ftr";
        } else {
            $this->filter_b = null;
        }
    }
    /**
     * addPagesAndSeenKeys adds the pages of feed items to the bundle and
     * adds each item's id to the Bloom filters, so the item is not added
     * again. FeedsUpdateJob::addFoundItemsPartition calls it.
     *
     * @param array $pages array of feed items
     * @param int $visited_urls_count number of feed items
     * @return bool whether or not succeeded in adding pages
     */
    public function addPagesAndSeenKeys($pages, $visited_urls_count)
    {
        foreach ($pages as $page) {
            $key = $page[self::SUMMARY][self::HASH];
            $this->addFilters($key);
        }
        return parent::addPages($pages, $visited_urls_count);
    }
    /**
     * addFilters adds the id of a feed item to the Bloom filters. The id
     * always goes to $filter_a. Once $filter_a is more than half full,
     * $filter_b is started and gets the id too. Once $filter_a is full,
     * $filter_b's file replaces $filter_a's and $filter_b becomes
     * $filter_a.
     *
     * @param string $key the id of a feed item
     */
    public function addFilters($key)
    {
        if ($this->filter_a->count > C\URL_FILTER_SIZE/2 &&
            !$this->filter_b) {
            if (file_exists($this->dir_name . "/filter_b.ftr")) {
                $this->filter_b = BloomFilterFile::load($this->dir_name .
                    "/filter_b.ftr");
                $this->filter_b->filename = $this->dir_name . "/filter_b.ftr";
            } else {
                $this->filter_b = new BloomFilterFile(
                    $this->dir_name . "/filter_b.ftr", C\URL_FILTER_SIZE);
            }
        }
        if ($this->filter_a->count > C\URL_FILTER_SIZE && $this->filter_b) {
            $this->filter_b->save();
            rename($this->dir_name . "/filter_b.ftr",
                $this->dir_name . "/filter_a.ftr");
            $this->filter_a = $this->filter_b;
            $this->filter_a->filename = $this->dir_name . "/filter_a.ftr";
            $this->filter_b = null;
        }
        $this->filter_a->add($key);
        $this->filter_b?->add($key);
    }
    /**
     * buildInvertedIndexPartition builds the inverted index of a partition
     * of feed items: the word lists of each item's title and description,
     * its meta words, and the trending term counts. The four files of the
     * index are saved as one step of the undo log, so a build ended part
     * way is undone the next time the bundle is opened. forceSave calls
     * it.
     *
     * @param int $partition bundle partition to build inverted index for
     * @param string $taking_too_long_touch name of file to touch if building
     *  inverted index takes too long (whether SCHEDULES_DIR/ .
     *  "/{$this->channel}-" . CrawmConstants::crawl_status_file
     *  has been recently modified) is used in crawling to see if have run out
     *  of new data and the crawl can stopped.
     * @param bool $just_stats whether to just compute stats on the inverted
     *      or to actually  save the results
     * @return mixed false if the partition has no items, an array with
     *      NUM_DOCS, NUM_LINKS and TERM_STATISTICS if $just_stats, and
     *      nothing otherwise
     */
    public function buildInvertedIndexPartition($partition = -1,
        $taking_too_long_touch = null, $just_stats = false)
    {
        $age = self::OLD_ITEM_TIME;
        $pre_feeds = $this->feeds ?? [];
        if ($partition < 0) {
            $partition = $this->documents->parameters["SAVE_PARTITION"];
        }
        $dockeys_filename = $this->documents->getPartitionIndex($partition);
        if(!file_exists($dockeys_filename)) {
            L\crawlLog("----.. No new items to index returning.");
            return false;
        }
        L\crawlLog("----.. Loading guids of items to index.");
        $doc_index = $this->documents->loadPartitionIndex($partition, true);
        $doc_ids = array_keys($doc_index);
        $num_ids = count($doc_ids);
        L\crawlLog("----.. Loaded $num_ids items in partition.");
        $feeds = [];
        foreach ($pre_feeds as $pre_feed) {
            if (!isset($pre_feed['NAME'])) {
                continue;
            }
            $feeds[$pre_feed['NAME']] = $pre_feed;
        }
        $time = time();
        $num_sites = 0;
        $completed = true;
        L\crawlLog("----.. Creating inverted index of items.");
        $i = 0;
        $term_counts = [];
        $seen_url_count = 0;
        $base_folder = $this->getPartitionBaseFolder($partition);
        if (!file_exists($base_folder)) {
            mkdir($base_folder);
        }
        $doc_map_tools = $this->doc_map_tools;
        $postings_tools = $this->postings_tools;
        $last_entries_tools = $this->last_entries_tools;
        $doc_map_filename = $base_folder . "/" . self::DOC_MAP_FILENAME;
        $postings_filename = $base_folder . "/" . self::POSTINGS_FILENAME;
        $positions_filename = $base_folder . "/" . self::POSITIONS_FILENAME;
        $last_entries_filename =  $base_folder . "/" .
            self::LAST_ENTRIES_FILENAME;
        $this->doc_map = "";
        $this->doc_map_counter = 0;
        $this->postings = [];
        $this->posting_counts = [];
        $this->positions = "";
        $this->last_entries = [];
        $this->last_rows = [];
        for ($i = 0; $i < $num_ids; $i++) {
            $doc_id = $doc_ids[$i];
            $item = $this->getSummary($doc_id, $partition);
            if (L\crawlTimeoutLog(
                "----..have added %s items to new index.", $i) &&
                $taking_too_long_touch) {
                if (file_exists($taking_too_long_touch)) {
                    touch($taking_too_long_touch);
                }
            }
            if (!isset($item[self::SOURCE_NAME])) {
                continue;
            }
            $source_name = $item[self::SOURCE_NAME];
            if (isset($feeds[$source_name])) {
                $lang = $feeds[$source_name]['LANGUAGE'];
                $media_category = $feeds[$source_name]['CATEGORY'];
                $source_stop_regex =
                    $feeds[$source_name]['TRENDING_STOP_REGEX'];
            } else {
                $lang = C\p('DEFAULT_LOCALE');
                $media_category = "news";
                $source_stop_regex = "";
            }
            /* r6t was chosen as short enough not to be
               changed by chargramming, but rare enough
               that can be used as a useful splitter
             */
            $phrase_string = $item[self::TITLE] . " r6t " .
                $item[self::DESCRIPTION];
            $word_and_qa_lists = PhraseParser::extractPhrasesInLists(
                $phrase_string, $lang);
            $meta_ids = $this->calculateMetas($lang, $item[self::PUBDATE],
                $source_name, $item[self::HASH], $media_category);
            $len = strlen($phrase_string);
            $word_list = $word_and_qa_lists["WORD_LIST"];
            if (PhraseParser::computeSafeSearchScore($phrase_string,
                $item[self::URL]) < PhraseParser::SAFE_PHRASE_THRESHOLD) {
                $meta_ids[] = "safe:true";
                $meta_ids[] = "safe:all";
            } else {
                $meta_ids[] = "safe:false";
                $meta_ids[] = "safe:all";
            }
            $title_length = 0;
            if (!empty($word_list["r6t"][0])) {
                $title_length = $word_list["r6t"][0] + 1;
                unset($word_list["r6t"]);
            }
            $num_words = 0;
            foreach($word_list as $word => $position_list)
            {
                $num_words += count($position_list);
            }
            $this->addScoresDocMap($doc_id, $num_words,
                intval($item[self::PUBDATE]), 0, $title_length, $title_length,
                [], []);
            $this->addTermPostingLists(0, $word_list, $meta_ids,
                $this->doc_map_counter);
            $this->doc_map_counter++;
            $this->updateTrendingTermCounts($term_counts, $phrase_string,
                $word_list, $media_category, $source_name, $lang,
                $item[self::PUBDATE], $source_stop_regex);
        }
        $this->joinPostings();
        if ($just_stats) {
            $term_stats = [];
            foreach ($this->postings as $term => $postings) {
                $posting_records = $postings_tools->unpack($postings);
                $term_stats[$term] = count($posting_records);
            }
            $statistics = [
                "NUM_DOCS" => count($this->doc_map),
                "NUM_LINKS" => 0,
                "TERM_STATISTICS" => $term_stats
            ];
            return $statistics;
        }
        $undo_log = new UndoLog($this->dir_name);
        $step_id = $undo_log->begin("build of feed partition $partition", [],
            [$doc_map_filename, $postings_filename, $last_entries_filename,
            $positions_filename]);
        $doc_map_tools->save($doc_map_filename, $this->doc_map, false);
        /* The dictionary takes a partition's terms in the order the
           postings file holds them and finds a term by halving, so the
           terms are saved in order, as IndexDocumentBundle saves them. */
        ksort($this->postings);
        $postings_tools->save($postings_filename, $this->postings, false);
        $this->packLastEntries();
        $last_entries_tools->save($last_entries_filename, $this->last_entries,
            false);
        L\filePutContentsWhole($positions_filename, $this->positions, false);
        $undo_log->finish($step_id, [$doc_map_filename, $postings_filename,
            $last_entries_filename, $positions_filename]);
        unset($term_counts['seen']);
        $this->addTermCountsTrendingTable($term_counts);
    }
    /**
     * computeDocId works out the document id of a feed item from its url
     * and its id, in the same form FeedsUpdateJob gives the items it adds:
     * a hash of the url, the item id, a byte marking a feed item, and a
     * hash of the url's host.
     *
     * @param array $site the feed item, with its URL and HASH fields
     * @return string the item's document id
     */
    public static function computeDocId($site)
    {
        $raw_guid = L\unbase64Hash($site[self::HASH]);
        // chr(24) refers to 'feed' doc type
        $doc_id = L\crawlHash($site[self::URL], true) .
            $raw_guid . chr(24) . substr(L\crawlHash(
            UrlParser::getHost($site[self::URL]) . "/", true), 1);
        return $doc_id;
    }
    /**
     * calculateMetas works out the meta words of a feed item, for example
     * media:news and date:2026-10-05, which let a search pick items by
     * kind, source, date and language. buildInvertedIndexPartition calls
     * it for each item.
     *
     * @param string $lang the locale_tag of the feed item
     * @param int $pubdate UNIX timestamp publication date of item
     * @param string $source_name the name of the feed
     * @param string $guid the guid of the item
     * @param string $media_category determines what media: metas to inject.
     *      Default is news.
     *
     * @return array $meta_ids meta words found
     */
    public function calculateMetas($lang, $pubdate, $source_name, $guid,
        $media_category = "news")
    {
        $meta_ids = ["media:all", "media:$media_category",
            "media:$media_category:" . urlencode( mb_strtolower($source_name)),
            "guid:" . strtolower($guid)];
        $meta_ids[] = 'date:all';
        $meta_ids[] = 'date:' . date('Y', $pubdate);
        $meta_ids[] = 'date:' . date('Y-m', $pubdate);
        $meta_ids[] = 'date:' . date('Y-m-d', $pubdate);
        $meta_ids[] = 'date:' . date('Y-m-d-H', $pubdate);
        $meta_ids[] = 'date:' . date('Y-m-d-H-i', $pubdate);
        $meta_ids[] = 'date:' . date('Y-m-d-H-i-s', $pubdate);
        $meta_ids[] = 'lang:all';
        if ($lang != "") {
            $lang_parts = explode("-", $lang);
            $meta_ids[] = 'lang:' . $lang_parts[0];
            if (isset($lang_parts[1])) {
                $meta_ids[] = 'lang:' . $lang;
            }
        }
        return $meta_ids;
    }
    /**
     * contains says if $filter_a holds the id of a feed item, that is if
     * the item is in the bundle already. FeedsUpdateJob asks it before it
     * adds an item.
     *
     * @param string $key the id of the feed item
     * @return bool true if it is in the archive, false otherwise
     */
    public function contains($key)
    {
        return $this->filter_a->contains($key);
    }
    /**
     * forceSave builds the inverted index of the partition being filled
     * and saves the Bloom filters, opening their files to every user.
     * FeedsUpdateJob calls it when the dictionary has no partition to
     * add.
     */
    public function forceSave()
    {
        $this->buildInvertedIndexPartition();
        $this->filter_a->save();
        L\setWorldPermissions($this->dir_name . "/filter_a.ftr");
        if ($this->filter_b) {
            $this->filter_b->save();
            L\setWorldPermissions($this->dir_name . "/filter_b.ftr");
        }
    }
    /**
     * updateTrendingTermCounts adds the words of one feed item to the
     * counts of trending terms, by language and category, leaving out
     * the source's name and the words its stop pattern matches.
     * buildInvertedIndexPartition calls it for each item.
     *
     * @param array &$term_counts lang => [term => occurrences]
     * @param string $source_phrase original non-stemmed phrase from feed
     *      item to adjust $term_counts with. Used to remember non-stemmed
     *      terms. We assume we have already extracted position lists from
     * @param array $word_or_phrase_list associate array of
     *      stemmed_word_or_phrase => positions in feed item of where occurs
     * @param string $media_category of feed source the item case from. We
     *      trending counts grouped by media category
     * @param string $source_name of feed source the item case from. We exclude
     *      from counts the name of the feed source
     * @param string $lang locale_tag for this feed item
     * @param int $pubdate timestamp when string was published (used in
     *      weighting)
     * @param string $source_stop_regex a regex to remove terms which occur
     *      frequently for this particular source
     */
    public function updateTrendingTermCounts(&$term_counts, $source_phrase,
        $word_or_phrase_list, $media_category, $source_name, $lang, $pubdate,
        $source_stop_regex = "")
    {
        $time = time();
        if ($time - $pubdate > C\ONE_DAY) {
            return;
        }
        $time_weight = max(ceil(($time - $pubdate)/C\ONE_HOUR), 1);
        $tokenizer = PhraseParser::getTokenizer($lang);
        $stop_words = (empty($tokenizer::$stop_words)) ? [] :
            $tokenizer::$stop_words;
        $stop_words[] = "tztzlzngth";
        $composites = [];
        foreach ($word_or_phrase_list as $term => $positions) {
            if (str_contains($term, "-")) {
                $sub_terms = explode("-", $term);
                $composites += $sub_terms;
            }
        }
        foreach($word_or_phrase_list as $term => $positions) {
            if ((!empty($source_stop_regex) &&
                preg_match($source_stop_regex, $term)) ||
                !is_string($term) || $term == "") {
                continue;
            }
            $occurrences = count($positions);
            $weight = substr_count($term, "-");
            if (!empty($term_counts['seen'][$term])) {
                $term = $term_counts['seen'][$term];
                if (empty($term_counts[$lang][$media_category][$term])) {
                    if (empty($term_counts[$lang])) {
                        $term_counts[$lang] = [];
                    }
                    if (empty($term_counts[$lang][$media_category])) {
                        $term_counts[$lang][$media_category] = [];
                    }
                    $term_counts[$lang][$media_category][$term] = 0;
                }
                $term_counts[$lang][$media_category][$term] +=
                    ($weight * $occurrences) / $time_weight;
            } else if (!str_contains($term, " ") &&
                !in_array($term, $composites) &&
                !in_array($term[0], ["#", "_", "-"] ) &&
                !in_array($term, $stop_words) &&
                !str_starts_with($term, "http")) {
                $original_term = $term;
                if (preg_match("/\b" . preg_quote($term, "/") .
                    '[\w|\-]*\b/ui', $source_phrase, $match)) {
                    $term = $match[0];
                    if (!empty($source_stop_regex) &&
                        preg_match($source_stop_regex, $term)) {
                        continue;
                    }
                } else {
                    $term = str_replace("-", " ", $term);
                    if( mb_stristr($source_name, $term) !== false) {
                        continue;
                    }
                    if (substr($term, -6) == "_pos_s") {
                        $term = substr($term, 0, -6);
                        $term_pattern = $term .
                            "\s*(\'|\&apos\;)\s*";
                    } else {
                        $term_pattern = preg_quote($term, "/");
                    }
                    if (preg_match("/\b$term_pattern".'[\w|\-]*\b/ui',
                        $source_phrase, $match)) {
                        $term = $match[0];
                    } else {
                        $trunc_term = mb_substr($term, 0, -1);
                        $trunc_original = mb_substr($original_term, 0, -1);
                        if (preg_match("/\b" . preg_quote($trunc_term, "/") .
                            '[\w|\-]*\b/ui', $source_phrase, $match)) {
                            $term = $match[0];
                        } else if (preg_match("/\b" .
                            preg_quote($trunc_original, "/") .
                            '[\w|\-]*\b/ui', $source_phrase, $match)) {
                            $term = $match[0];
                        } else {
                            continue;
                        }
                    }
                    if (!empty($source_stop_regex) &&
                        preg_match($source_stop_regex, $term)) {
                        continue;
                    }
                }
                $lower_term = mb_strtolower($term);
                $lower_parts = preg_split("/\s+|\-/ui", $lower_term);
                $uncommon = false;
                foreach ($lower_parts as $lower_part) {
                    if (!empty($lower_part) &&
                        !in_array($lower_part, $stop_words)) {
                        $uncommon = true;
                        break;
                    }
                }
                if ($uncommon) {
                    $term = preg_replace("/\-/", " ", $lower_term);
                    $term = mb_convert_case($term, MB_CASE_TITLE, 'UTF-8');
                    if (empty($term_counts[$lang][$media_category][$term])) {
                        if (empty($term_counts[$lang])) {
                            $term_counts[$lang] = [];
                        }
                        if (empty($term_counts[$lang][$media_category])) {
                            $term_counts[$lang][$media_category] = [];
                        }
                        $term_counts[$lang][$media_category][$term] = 0;
                    }
                    $term_counts['seen'][$original_term] = $term;
                    if (!empty($term_counts['seen'][$lower_term])) {
                        $term = $term_counts['seen'][$lower_term];
                        if (empty($term_counts[$lang][$media_category][$term])){
                            $term_counts[$lang][$media_category][$term] = 0;
                        }
                    }
                    $term_counts['seen'][$lower_term] = $term;
                    $term_counts[$lang][$media_category][$term] +=
                        ($weight * $occurrences) / $time_weight;
                }
            }
        }
    }
    /**
     * addTermCountsTrendingTable writes the trending term counts of a
     * partition's build to the TRENDING_TERM table, then sums the hourly
     * counts into daily, weekly, monthly and yearly ones and deletes the
     * rows too old to keep. buildInvertedIndexPartition calls it once its
     * files are saved.
     *
     * @param array $term_counts for the most recent update of the
     *      feed index, it should be an array [$lang => [$term => $occurrences]]
     *      for the top NUM_TRENDING terms per language
     */
    public function addTermCountsTrendingTable($term_counts)
    {
        $db = $this->db;
        $time = time();
        $update_intervals = [
            C\ONE_HOUR => C\ONE_DAY,
            C\ONE_DAY => C\ONE_WEEK,
            C\ONE_WEEK => C\ONE_MONTH,
            C\ONE_MONTH => C\ONE_YEAR
        ];
        $reset_interval_sql = "DELETE FROM TRENDING_TERM WHERE " .
            "UPDATE_PERIOD = ? AND LANGUAGE = ? AND ".
            "CATEGORY = ? AND TIMESTAMP >= ? ";
        $interval_sql = "SELECT TERM, SUM(OCCURRENCES) AS OCCURRENCES ".
            "FROM TRENDING_TERM WHERE UPDATE_PERIOD = ? AND " .
            "TIMESTAMP >= ? AND LANGUAGE = ? AND CATEGORY = ? GROUP BY TERM ".
            "ORDER BY OCCURRENCES DESC ".
            $db->limitOffset(C\NUM_TRENDING);
        $insert_sql = "INSERT INTO TRENDING_TERM (TERM, OCCURRENCES, " .
            "UPDATE_PERIOD, TIMESTAMP, LANGUAGE, CATEGORY) VALUES ".
            "(?, ?, ?, ?, ?, ?)";
        $cull_sql = "DELETE FROM TRENDING_TERM WHERE UPDATE_PERIOD = ? " .
            " AND TIMESTAMP < ?";
        $have_culled = [];
        foreach ($term_counts as $lang => $category_term_occurrences) {
            L\crawlLog("Updating Language $lang trending terms");
            foreach ($category_term_occurrences as $category =>
                $term_occurrences) {
                /* Sort the word occurrence list by occurrence
                   and insert into database.
                 */
                $num_inserted = 0;
                arsort($term_occurrences);
                foreach ($term_occurrences as $term => $occurrences) {
                    $db->execute($insert_sql, [$term, $occurrences, C\ONE_HOUR,
                        $time, $lang, $category]);
                    $num_inserted++;
                    if ($num_inserted >= C\NUM_TRENDING) {
                        break;
                    }
                }
                foreach ($update_intervals as $sub_interval => $interval) {
                    $interval_start = $time - $interval;
                    $recent_timestamp = floor($interval_start/$interval) *
                        $interval;
                    $db->execute($reset_interval_sql,
                        [$interval, $lang, $category, $recent_timestamp]);
                    $result = $db->execute($interval_sql,
                        [$sub_interval, $recent_timestamp, $lang, $category]);
                    while ($interval_info = $db->fetchArray($result)) {
                        $db->execute($insert_sql, [$interval_info['TERM'],
                            $interval_info['OCCURRENCES'], $interval,
                            $recent_timestamp, $lang, $category]);
                    }
                    if (empty($have_culled[$sub_interval])) {
                        $db->execute($cull_sql, [$sub_interval,
                            $recent_timestamp]);
                        $have_culled[$sub_interval] = true;
                    }
                }
            }
        }
    }
}