*/ 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->maxRowsInTable = GeneralConfig::getIntegerConfigValue('datatable_archiving_maximum_rows_bots', 0); $this->maxRowsInSubtable = GeneralConfig::getIntegerConfigValue('datatable_archiving_maximum_rows_subtable_bots', 0); $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) ->setColumnToSortByBeforeTruncation(Metrics::COLUMN_REQUESTS), Record::make(Record::TYPE_BLOB, Archiver::AI_CHATBOTS_DOCUMENTS_RECORD) ->setColumnToSortByBeforeTruncation(Metrics::COLUMN_REQUESTS), 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 $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 */ 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 $tables * @param array $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 = GeneralConfig::getIntegerConfigValue('archiving_ranking_query_row_limit', 0); $configLimit = max($configLimit, 10 * $maxRowsInTable); if ($configLimit === 0) { return 0; } return max($configLimit, $maxRowsInTable, $maxRowsInSubtable); } /** * @param array $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 = <<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); } }