File size: 21,635 Bytes
78185c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
<?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;

use Piwik\Archive;
use Piwik\Container\StaticContainer;
use Piwik\DataTable;
use Piwik\DataTable\DataTableInterface;
use Piwik\Date;
use Piwik\Piwik;
use Piwik\Plugins\BotTracking\Dao\BotRequestsDao;
use Piwik\Plugins\BotTracking\Metrics;
use Piwik\Plugins\BotTracking\RecordBuilders\AIChatbotReports;
use Piwik\Plugin\ReportsProvider;
use Piwik\Plugins\BotTracking\Reports\Get;
use Piwik\Plugins\Referrers\AIAssistant;
use Piwik\Site;

/**
 * Provides API methods for bot and AI chatbot reporting.
 *
 * @method static \Piwik\Plugins\BotTracking\API getInstance()
 */
class API extends \Piwik\Plugin\API
{
    public const REAL_TIME_DEFAULT_LOOKBACK_MINUTES = 30;
    private const REAL_TIME_MIN_LOOKBACK_MINUTES = 1;
    private const REAL_TIME_MAX_LOOKBACK_MINUTES = 720;

    /**
     * Returns the main bot tracking report.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
     *                                                    containing the specified date.
     * @param string $date The date or date range to process.
     *                     'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
     *                     or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
     * @param string|string[]|null $columns Optional metric names to include in the report.
     * @return DataTable|DataTable\Map Bot tracking metrics for the requested site selection and period.
     */
    public function get($idSite, string $period, string $date, $columns = null): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        $archive = Archive::build($idSite, $period, $date, '');

        $metrics = Metrics::getReportMetricColumns();

        if ($period !== 'day') {
            $metrics = array_filter($metrics, function ($metric) {
                return !in_array($metric, [Metrics::METRIC_AI_CHATBOTS_UNIQUE_DOCUMENT_URLS, Metrics::METRIC_AI_CHATBOTS_UNIQUE_PAGE_URLS]);
            });
        }

        $requestedColumns = Piwik::getArrayFromApiParameter($columns);

        /** @var Get $report */
        $report  = ReportsProvider::factory('BotTracking', 'get');
        $columns = $report->getMetricsRequiredForReport($metrics, $requestedColumns);

        $dataTable = $archive->getDataTableFromNumeric($columns);

        if (!empty($requestedColumns)) {
            $dataTable->queueFilter('ColumnDelete', [$columnsToRemove = [], $requestedColumns]);
        }

        return $dataTable;
    }

    /**
     * Returns a report about AI chatbot requests.
     * Depending on the provided secondary dimension the subtables will either contain all requested page urls or document urls.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
     *                                                    containing the specified date.
     * @param string $date The date or date range to process.
     *                     'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
     *                     or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
     * @param bool $expanded Whether subtables should be expanded in the response.
     * @param bool $flat Whether subtable rows should be flattened into a single table.
     * @param 'pages'|'documents'|null $secondaryDimension Optional secondary dimension for subtable rows.
     *                                                     Use `pages` for page URLs or `documents` for document URLs.
     * @return DataTable|DataTable\Map Requests per AI chatbot for the selected secondary dimension.
     */
    public function getAIChatbotRequests($idSite, string $period, string $date, bool $expanded = false, bool $flat = false, ?string $secondaryDimension = null): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        $archiveName = Archiver::AI_CHATBOTS_PAGES_RECORD;

        if ($secondaryDimension === 'documents') {
            $archiveName = Archiver::AI_CHATBOTS_DOCUMENTS_RECORD;
        }

        $dataTable = Archive::createDataTableFromArchive($archiveName, $idSite, $period, $date, '', $expanded, $flat);

        // When flattening a report, remove all main table rows, where no subtable exists
        if ($flat) {
            $dataTable->filter(function (DataTable $table) {
                foreach ($table->getRows() as $key => $row) {
                    if (!$row->getIdSubDataTable()) {
                        $table->deleteRow($key);
                    }
                }
            });
        }

        $this->decorateAIChatbotLabels($dataTable);

        return $dataTable;
    }

    /**
     * Returns AI chatbot activity grouped by chatbot for a real-time lookback window.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     * @param int|string $lastMinutes Number of minutes to look back from now. The value must stay
     *                                within the real-time safety window so the API cannot scan too
     *                                much raw data.
     * @return DataTable AI chatbot requests, unique page URLs, and error counts for the selected
     *                   real-time lookback window.
     */
    public function getAIChatbotsRealTime($idSite, $lastMinutes = self::REAL_TIME_DEFAULT_LOOKBACK_MINUTES): DataTable
    {
        Piwik::checkUserHasViewAccess($idSite);

        [$startDate, $endDate] = $this->getRealTimeDateRange($lastMinutes);
        $idSites               = Site::getIdSitesFromIdSitesString($idSite, false, true);

        $table = (new BotRequestsDao())->getAIChatbotActivityForDateRange($idSites, $startDate, $endDate);

        $this->decorateAIChatbotLabels($table);

        return $table;
    }

    /**
     * Returns page URLs requested by AI chatbots for a real-time lookback window.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     * @param int|string $lastMinutes Number of minutes to look back from now. The value must stay
     *                                within the real-time safety window so the API cannot scan too
     *                                much raw data.
     * @return DataTable Flat page URL table ordered by chatbot requests for the selected real-time
     *                   lookback window.
     */
    public function getTopPageUrlsRealTime($idSite, $lastMinutes = self::REAL_TIME_DEFAULT_LOOKBACK_MINUTES): DataTable
    {
        Piwik::checkUserHasViewAccess($idSite);

        [$startDate, $endDate] = $this->getRealTimeDateRange($lastMinutes);
        $idSites               = Site::getIdSitesFromIdSitesString($idSite, false, true);

        $table = (new BotRequestsDao())->getAIChatbotTopPageUrlsForDateRange($idSites, $startDate, $endDate);

        return $this->decorateUrlLabels($table);
    }

    /**
     * Returns page URLs requested by a specific AI chatbot.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
     *                                                    containing the specified date.
     * @param string $date The date or date range to process.
     *                     'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
     *                     or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
     * @param int $idSubtable Subtable ID for the AI chatbot row to expand.
     * @return DataTable|DataTable\Map Page URLs requested by the selected AI chatbot.
     */
    public function getPageUrlsForAIChatbot($idSite, string $period, string $date, int $idSubtable): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        return Archive::createDataTableFromArchive(Archiver::AI_CHATBOTS_PAGES_RECORD, $idSite, $period, $date, '', false, false, $idSubtable);
    }

    /**
     * Returns document URLs requested by a specific AI chatbot.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
     *                                                    containing the specified date.
     * @param string $date The date or date range to process.
     *                     'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
     *                     or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
     * @param int $idSubtable Subtable ID for the AI chatbot row to expand.
     * @return DataTable|DataTable\Map Document URLs requested by the selected AI chatbot.
     */
    public function getDocumentUrlsForAIChatbot($idSite, string $period, string $date, int $idSubtable): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        return Archive::createDataTableFromArchive(Archiver::AI_CHATBOTS_DOCUMENTS_RECORD, $idSite, $period, $date, '', false, false, $idSubtable);
    }

    /**
     * Returns page URLs accessed by AI chatbots across all chatbots, with server time and response size metrics.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
     *                                                    containing the specified date.
     * @param string $date The date or date range to process.
     *                     'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
     *                     or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
     * @return DataTable|DataTable\Map Flat table of page URLs with Requests, Avg. Server Time, and Avg. Response Size.
     */
    public function getAIChatbotContentPages($idSite, string $period, string $date): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        return Archive::createDataTableFromArchive(Archiver::AI_CHATBOTS_REQUESTED_PAGES_RECORD, $idSite, $period, $date, '', false, false);
    }

    /**
     * Returns document URLs accessed by AI chatbots across all chatbots, with server time and response size metrics.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
     *                                                    containing the specified date.
     * @param string $date The date or date range to process.
     *                     'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
     *                     or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
     * @return DataTable|DataTable\Map Flat table of document URLs with Requests, Avg. Server Time, and Avg. Response Size.
     */
    public function getAIChatbotContentDocuments($idSite, string $period, string $date): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        return Archive::createDataTableFromArchive(Archiver::AI_CHATBOTS_REQUESTED_DOCUMENTS_RECORD, $idSite, $period, $date, '', false, false);
    }

    /**
     * Returns page and document URLs accessed by AI chatbots that returned HTTP errors (4xx/5xx).
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
     *                                                    containing the specified date.
     * @param string $date The date or date range to process.
     *                     'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
     *                     or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
     * @return DataTable|DataTable\Map Flat table of broken URLs with 5XX Requests and Page Not Found (404) Requests counts.
     */
    public function getAIChatbotBrokenContent($idSite, string $period, string $date): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        return Archive::createDataTableFromArchive(Archiver::AI_CHATBOTS_BROKEN_CONTENT_RECORD, $idSite, $period, $date, '', false, false);
    }

    /**
     * Returns page URLs visited far more by humans than requested by AI chatbots.
     *
     * Each row carries Unique Human Pageviews, AI Chatbot Requests and the Human-Favoured
     * Discrepancy Score (a bounded 0–100 index materialised on the table).
     *
     * Note: the "exclude low population" filter that the UI applies by default is a ViewDataTable
     * decoration only β€” a direct API call returns every row (including pages with no human
     * pageviews). Segmentation is not supported: any `segment` parameter is ignored and the
     * standard, unsegmented data is returned.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process.
     * @param string $date The date or date range to process.
     * @return DataTable|DataTable\Map Flat table of URLs with the two source metrics and the score.
     */
    public function getAIChatbotHumanFavouredPages($idSite, string $period, string $date): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        // The scored data is archived (see AIChatbotFavouredPages); just read it back. The empty
        // segment is intentional β€” these reports are unsegmented, so a requested segment is ignored.
        $table = Archive::createDataTableFromArchive(Archiver::AI_CHATBOTS_HUMAN_FAVOURED_PAGES_RECORD, $idSite, $period, $date, '', false, false);

        return $this->skipScoreInReportTotals($table);
    }

    /**
     * Returns page URLs requested far more by AI chatbots than visited by humans.
     *
     * Each row carries Unique Human Pageviews, AI Chatbot Requests and the AI-Favoured
     * Discrepancy Score (a bounded 0–100 index materialised on the table).
     *
     * Note: the "exclude low population" filter that the UI applies by default is a ViewDataTable
     * decoration only β€” a direct API call returns every row (including pages with no AI chatbot
     * requests). Segmentation is not supported: any `segment` parameter is ignored and the
     * standard, unsegmented data is returned.
     *
     * @param int|string|int[] $idSite Website ID(s) to query.
     *                         - Single site ID (e.g. 1)
     *                         - Multiple site IDs (e.g. [1, 4, 5])
     *                         - Comma-separated list ("1,4,5") or "all"
     * @param 'day'|'week'|'month'|'year'|'range' $period The period to process.
     * @param string $date The date or date range to process.
     * @return DataTable|DataTable\Map Flat table of URLs with the two source metrics and the score.
     */
    public function getAIChatbotAIFavouredPages($idSite, string $period, string $date): DataTableInterface
    {
        Piwik::checkUserHasViewAccess($idSite);

        // See getAIChatbotHumanFavouredPages: the scored data is archived; read it back unsegmented.
        $table = Archive::createDataTableFromArchive(Archiver::AI_CHATBOTS_AI_FAVOURED_PAGES_RECORD, $idSite, $period, $date, '', false, false);

        return $this->skipScoreInReportTotals($table);
    }

    /**
     * Marks the discrepancy_score column 'skip' so the report totals row leaves it blank β€” summing a
     * per-page 0–100 index is meaningless. The scorer sets this op at archive time, but a DataTable's
     * column-aggregation metadata is transient and is not part of the serialised blob, so it is lost on
     * load and must be re-applied here on read. Recurses into DataTable\Map (multi-period / multi-site).
     *
     * @param DataTable|DataTable\Map $table
     * @return DataTable|DataTable\Map
     */
    private function skipScoreInReportTotals(DataTableInterface $table): DataTableInterface
    {
        if ($table instanceof DataTable\Map) {
            foreach ($table->getDataTables() as $childTable) {
                $this->skipScoreInReportTotals($childTable);
            }

            return $table;
        }

        if ($table instanceof DataTable) {
            $ops = $table->getMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME);
            if (!is_array($ops)) {
                $ops = [];
            }
            $ops[Metrics::COLUMN_DISCREPANCY_SCORE] = 'skip';
            $table->setMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME, $ops);
        }

        return $table;
    }

    /**
     * @param int|string $lastMinutes
     * @return array{0: string, 1: string}
     */
    private function getRealTimeDateRange($lastMinutes): array
    {
        if (!is_int($lastMinutes) && (!is_string($lastMinutes) || !ctype_digit($lastMinutes))) {
            throw new \InvalidArgumentException($this->getRealTimeLookbackErrorMessage());
        }

        $lastMinutes = (int) $lastMinutes;

        if ($lastMinutes < self::REAL_TIME_MIN_LOOKBACK_MINUTES || $lastMinutes > self::REAL_TIME_MAX_LOOKBACK_MINUTES) {
            throw new \InvalidArgumentException($this->getRealTimeLookbackErrorMessage());
        }

        $now = $this->getRealTimeNowTimestamp();

        return [
            Date::factory($now - $lastMinutes * 60)->getDatetime(),
            Date::factory($now)->getDatetime(),
        ];
    }

    private function getRealTimeNowTimestamp(): int
    {
        try {
            $testNow = StaticContainer::get('Tests.now');
            if (!empty($testNow)) {
                return (int) $testNow;
            }
        } catch (\Exception $exception) {
            // Tests.now is only available in some test containers.
        }

        return Date::getNowTimestamp();
    }

    private function getRealTimeLookbackErrorMessage(): string
    {
        return sprintf(
            'lastMinutes only accepts values between %d and %d',
            self::REAL_TIME_MIN_LOOKBACK_MINUTES,
            self::REAL_TIME_MAX_LOOKBACK_MINUTES
        );
    }

    private function decorateAIChatbotLabels(DataTableInterface $table): void
    {
        $table->filter(function (DataTable $table): void {
            foreach ($table->getRows() as $row) {
                $label = $row->getColumn('label');
                if (!is_string($label)) {
                    continue;
                }

                if (!empty(AIChatbotReports::CHATBOT_MAPPING[$label])) {
                    $row->setColumn('label', AIChatbotReports::CHATBOT_MAPPING[$label]);
                }
            }
        });

        $table->queueFilter('ColumnCallbackAddMetadata', [
            'label',
            'url',
            function ($label) {
                return AIAssistant::getInstance()->getMainUrlFromName($label);
            },
        ]);
        $table->queueFilter('MetadataCallbackAddMetadata', [
            'url',
            'logo',
            function ($url) {
                return AIAssistant::getInstance()->getLogoFromUrl($url ?: '');
            },
        ]);
    }

    private function decorateUrlLabels(DataTable $table): DataTable
    {
        $table->filter(function (DataTable $table): void {
            foreach ($table->getRows() as $row) {
                $label = $row->getColumn('label');
                if (is_string($label) && $label !== '') {
                    $row->setMetadata('url', 'https://' . $label);
                }
            }
        });

        return $table;
    }
}