HEX
Server: Microsoft-IIS/10.0
System: Windows NT ITPWINWEBSVR22 10.0 build 20348 (Windows Server 2022) AMD64
User: www.conferencesearch.co.uk (0)
PHP: 8.3.30
Disabled: NONE
Upload Files
File: D:/web/matomo.vdk/plugins/BotTracking/RecordBuilders/AIChatbotReports.php
<?php

/**
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */

declare(strict_types=1);

namespace Piwik\Plugins\BotTracking\RecordBuilders;

use Piwik\ArchiveProcessor;
use Piwik\ArchiveProcessor\Record;
use Piwik\ArchiveProcessor\RecordBuilder;
use Piwik\Common;
use Piwik\Config\GeneralConfig;
use Piwik\DataAccess\LogAggregator;
use Piwik\DataTable;
use Piwik\Db;
use Piwik\Plugins\BotTracking\Archiver;
use Piwik\Plugins\BotTracking\BotDetector;
use Piwik\Plugins\BotTracking\Dao\BotRequestsDao;
use Piwik\Plugins\BotTracking\Metrics;
use Piwik\RankingQuery;
use Piwik\Tracker\Action;
use Piwik\Tracker\PageUrl;

class AIChatbotReports extends RecordBuilder
{
    /**
     * @var array<string, string>
     */
    public const CHATBOT_MAPPING = [
        'ChatGPT-User'         => 'ChatGPT',
        'MistralAI-User'       => 'Le Chat',
        'Gemini-Deep-Research' => 'Gemini',
        'Claude-User'          => 'Claude',
        'Perplexity-User'      => 'Perplexity',
        'Google-NotebookLM'    => 'NotebookLM',
    ];

    /**
     * @var int
     */
    private $rankingQueryLimit;

    public function __construct()
    {
        parent::__construct();

        $this->columnToSortByBeforeTruncation = Metrics::COLUMN_REQUESTS;
        $this->maxRowsInTable                 = (int)GeneralConfig::getConfigValue('datatable_archiving_maximum_rows_bots');
        $this->maxRowsInSubtable              = (int)GeneralConfig::getConfigValue('datatable_archiving_maximum_rows_subtable_bots');
        $this->rankingQueryLimit              = $this->getRankingQueryLimit();
        $this->columnAggregationOps           = [
            Metrics::METRIC_AI_CHATBOTS_UNIQUE_PAGE_URLS     => 'skip',
            Metrics::METRIC_AI_CHATBOTS_UNIQUE_DOCUMENT_URLS => 'skip',
        ];
    }

    public function getRecordMetadata(ArchiveProcessor $archiveProcessor): array
    {
        return [
            Record::make(Record::TYPE_BLOB, Archiver::AI_CHATBOTS_PAGES_RECORD),
            Record::make(Record::TYPE_BLOB, Archiver::AI_CHATBOTS_DOCUMENTS_RECORD),
            Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_CHATBOTS_UNIQUE_CHATBOTS)
                ->setIsCountOfBlobRecordRows(Archiver::AI_CHATBOTS_PAGES_RECORD),
            Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_CHATBOTS_REQUESTS),
            Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_CHATBOTS_ACQUIRED_VISITS),
            Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_CHATBOTS_UNIQUE_PAGE_URLS),
            Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_CHATBOTS_UNIQUE_DOCUMENT_URLS),
            Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_CHATBOTS_NOT_FOUND_REQUESTS),
            Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_CHATBOTS_SERVER_ERROR_REQUESTS),
        ];
    }

    public function isEnabled(ArchiveProcessor $archiveProcessor): bool
    {
        // don't process reports for any segment
        return $archiveProcessor->getParams()->getSegment()->isEmpty();
    }

    protected function aggregate(ArchiveProcessor $archiveProcessor): array
    {
        $tables = [
            Archiver::AI_CHATBOTS_PAGES_RECORD     => new DataTable(),
            Archiver::AI_CHATBOTS_DOCUMENTS_RECORD => new DataTable(),
        ];

        $this->populateTables($archiveProcessor, $tables);
        $this->populateNumerics($archiveProcessor, $tables);

        return $tables;
    }

    /**
     * @param array<string, DataTable> $tables
     */
    private function populateTables(ArchiveProcessor $archiveProcessor, array &$tables): void
    {
        $logAggregator = $archiveProcessor->getLogAggregator();
        $visits = $this->queryAcquiredVisitsByAIChatbot($logAggregator);

        $this->populateChatbotTableForActionType($tables, Action::TYPE_PAGE_URL, $logAggregator, $visits);
        $this->populateChatbotTableForActionType($tables, Action::TYPE_DOWNLOAD, $logAggregator, $visits);
    }

    /**
     * @return array<string,int>
     */
    private function queryAcquiredVisitsByAIChatbot(LogAggregator $logAggregator): array
    {
        $where    = $logAggregator->getWhereStatement('log_visit', 'visit_last_action_time');
        $bindBase = $logAggregator->getGeneralQueryBindParams();

        $sql = sprintf(
            "SELECT `referer_name`, COUNT(*) AS `visits`
             FROM %s AS `log_visit`
             WHERE `referer_type` = %d
               AND `referer_name` <> ''
               AND %s
             GROUP BY `referer_name`",
            Common::prefixTable('log_visit'),
            Common::REFERRER_TYPE_AI_ASSISTANT,
            $where
        );

        $stmt   = Db::query($sql, $bindBase);
        $result = [];

        while ($row = $stmt->fetch()) {
            /**
             * @var array{visits: string|int, referer_name: string} $row
             */
            if (in_array($row['referer_name'], self::CHATBOT_MAPPING)) {
                $key          = (string)array_search($row['referer_name'], self::CHATBOT_MAPPING);
                $result[$key] = (int)$row['visits'];
            }
        }

        return $result;
    }

    /**
     * @param array<string, DataTable> $tables
     * @param array<string, int> $visits
     */
    private function populateChatbotTableForActionType(array $tables, int $actionType, LogAggregator $logAggregator, array $visits): void
    {
        $resultSet  = $this->queryBotRequests($logAggregator, $actionType);

        while ($row = $resultSet->fetch()) {
            /**
             * @var array{requests: int, bot_name: ?string, url: ?string} $row
             */
            $label = $row['bot_name'];
            $url   = $row['url'];

            if ($label === null) {
                // top-level rollup result
                continue;
            }

            if ($url === null) {
                // second-level rollup result
                $metrics = [
                    Metrics::COLUMN_REQUESTS          => $row['requests'],
                    Metrics::COLUMN_DOCUMENT_REQUESTS => $actionType === Action::TYPE_DOWNLOAD ? $row['requests'] : 0,
                    Metrics::COLUMN_PAGE_REQUESTS     => $actionType === Action::TYPE_PAGE_URL ? $row['requests'] : 0,
                    Metrics::COLUMN_ACQUIRED_VISITS   => $visits[$label] ?? 0,
                ];

                $tables[Archiver::AI_CHATBOTS_PAGES_RECORD]->sumRowWithLabel($label, $metrics, [Metrics::COLUMN_ACQUIRED_VISITS => 'max']);
                $tables[Archiver::AI_CHATBOTS_DOCUMENTS_RECORD]->sumRowWithLabel($label, $metrics, [Metrics::COLUMN_ACQUIRED_VISITS => 'max']);
                continue;
            }


            $table = $tables[Archiver::AI_CHATBOTS_PAGES_RECORD];

            if ($actionType === Action::TYPE_DOWNLOAD) {
                $table = $tables[Archiver::AI_CHATBOTS_DOCUMENTS_RECORD];
            }

            $tableRow = $table->getRowFromLabel($label);

            if (false === $tableRow) {
                // non-rollup row but rollup row is missing
                // should not happen, but don't break
                continue;
            }

            if (
                $url === RankingQuery::LABEL_SUMMARY_ROW
                && !$tableRow->isSubtableLoaded()
            ) {
                // skip creating the subtable if:
                // - we are using rollups
                // - the only row would be "Others"
                continue;
            }

            $normalized = PageUrl::normalizeUrl($url);
            $url        = $normalized['url'];

            $tableRow->sumRowWithLabelToSubtable($url, [
                Metrics::COLUMN_REQUESTS => $row['requests'],
            ]);
        }
    }

    private function queryBotRequests(LogAggregator $logAggregator, int $actionType)
    {
        $where  = $logAggregator->getWhereStatement('bot', 'server_time');

        $sql = sprintf(
            "SELECT * FROM (SELECT bot.bot_name, log_action.name AS url, COUNT(*) AS requests
             FROM `%s` AS bot
             INNER JOIN `%s` AS log_action ON log_action.idaction = bot.idaction_url
             WHERE log_action.name IS NOT NULL
               AND log_action.name <> ''
               AND log_action.type = %d
               AND bot.bot_type = ?
               AND %s
             GROUP BY bot.bot_name, url WITH ROLLUP) AS rollupQuery
             ORDER BY requests DESC, bot_name, url",
            BotRequestsDao::getPrefixedTableName(),
            Common::prefixTable('log_action'),
            $actionType,
            $where
        );

        if ($this->rankingQueryLimit > 0) {
            $rankingQuery = new RankingQuery($this->rankingQueryLimit);
            $rankingQuery->addLabelColumn(['bot_name', 'url']);
            $rankingQuery->addColumn('requests', 'sum');
            $sql = $rankingQuery->generateRankingQuery($sql, true);
        }

        return Db::query($sql, array_merge([BotDetector::BOT_TYPE_AI_CHATBOT], $logAggregator->getGeneralQueryBindParams()));
    }

    private function getRankingQueryLimit(): int
    {
        $maxRowsInTable    = (int)$this->maxRowsInTable;
        $maxRowsInSubtable = (int)$this->maxRowsInSubtable;

        $configLimit = (int)GeneralConfig::getConfigValue('archiving_ranking_query_row_limit');
        $configLimit = max($configLimit, 10 * $maxRowsInTable);

        if ($configLimit === 0) {
            return 0;
        }

        return max($configLimit, $maxRowsInTable, $maxRowsInSubtable);
    }

    /**
     * @param array<string, DataTable> $tables
     */
    private function populateNumerics(ArchiveProcessor $archiveProcessor, array &$tables): void
    {
        $logAggregator = $archiveProcessor->getLogAggregator();

        $table       = BotRequestsDao::getPrefixedTableName();
        $visitTable  = Common::prefixTable('log_visit');
        $actionTable = Common::prefixTable('log_action');

        $where = $logAggregator->getWhereStatement('bot', 'server_time');

        $sql = <<<SQL
SELECT
    COUNT(*) AS requests,
    SUM(CASE WHEN bot.http_status_code IN (404, 410) THEN 1 ELSE 0 END) AS not_found_requests,
    SUM(CASE WHEN bot.http_status_code BETWEEN 500 AND 599 THEN 1 ELSE 0 END) AS server_error_requests,
    COUNT(DISTINCT bot.bot_name) AS uniq_bots,
    COUNT(DISTINCT(CASE WHEN log_action.type = ? THEN log_action.name END)) AS uniq_pages,
    COUNT(DISTINCT(CASE WHEN log_action.type = ? THEN log_action.name END)) AS uniq_downloads
FROM `$table` AS bot
LEFT JOIN `$actionTable` AS log_action ON log_action.idaction = bot.idaction_url
WHERE bot.bot_type = ? AND $where
SQL;

        $bind = [
            Action::TYPE_PAGE_URL,
            Action::TYPE_DOWNLOAD,
            BotDetector::BOT_TYPE_AI_CHATBOT,
        ];
        $bind = array_merge($bind, $logAggregator->getGeneralQueryBindParams());

        $row = Db::fetchRow($sql, $bind) ?: [];

        $visitBind = [
            Common::REFERRER_TYPE_AI_ASSISTANT,
        ];
        $visitBind = array_merge($visitBind, $logAggregator->getGeneralQueryBindParams());

        $where = $logAggregator->getWhereStatement('log_visit', 'visit_last_action_time');

        $visitsSql = sprintf(
            "SELECT COUNT(*) FROM `%s` log_visit WHERE referer_type = ? AND $where",
            $visitTable
        );

        $acquiredVisits = (int)Db::fetchOne($visitsSql, $visitBind);

        $tables[Metrics::METRIC_AI_CHATBOTS_UNIQUE_CHATBOTS]       = (int)($row['uniq_bots'] ?? 0);
        $tables[Metrics::METRIC_AI_CHATBOTS_UNIQUE_PAGE_URLS]      = (int)($row['uniq_pages'] ?? 0);
        $tables[Metrics::METRIC_AI_CHATBOTS_UNIQUE_DOCUMENT_URLS]  = (int)($row['uniq_downloads'] ?? 0);
        $tables[Metrics::METRIC_AI_CHATBOTS_REQUESTS]              = (int)($row['requests'] ?? 0);
        $tables[Metrics::METRIC_AI_CHATBOTS_ACQUIRED_VISITS]       = $acquiredVisits;
        $tables[Metrics::METRIC_AI_CHATBOTS_NOT_FOUND_REQUESTS]    = (int)($row['not_found_requests'] ?? 0);
        $tables[Metrics::METRIC_AI_CHATBOTS_SERVER_ERROR_REQUESTS] = (int)($row['server_error_requests'] ?? 0);
    }
}