File size: 15,692 Bytes
c938f2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
<?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\tests\Fixtures;

use MatomoTracker;
use Piwik\Date;
use Piwik\Tests\Framework\Fixture;

class BotTraffic extends Fixture
{
    public $dateTime = '2025-02-02 03:00:00';
    public $idSite = 1;

    public function setUp(): void
    {
        parent::setUp();

        self::createSuperUser();
        $this->setUpWebsite();
        $this->trackBotRequests();
        $this->trackRealTimeBotRequests();
        $this->trackAcquiredVisits();
        $this->trackHumanPageOverlaps();
        $this->trackEventOnlyPage();
        $this->trackHumanPageviewInBotFreePeriod();
    }

    public function tearDown(): void
    {
        // nothing to clean up
    }

    private function setUpWebsite(): void
    {
        if (!self::siteCreated($this->idSite)) {
            self::createWebsite($this->dateTime, 1, 'https://example.com');
        }
    }

    private function trackBotRequests(): void
    {
        $pages = [
            'https://example.com/article/1/page/1',
            'https://example.com/article/1/page/2',
            'https://example.com/article/2',
            'https://example.com/article/3',
            'https://example.com/article/4/page/1',
            'https://example.com/article/4/page/2',
            'https://example.com/overview',
        ];

        $downloads = [
            'https://example.com/resources/doc.pdf',
            'https://example.com/resources/guide.pdf',
            'https://example.com/resources/whitepaper.pdf',
            'https://example.com/resources/datasheet.pdf',
            'https://example.com/resources/case-study.pdf',
        ];

        // Each tuple: [userAgent, url, httpStatus, bytes|null, isDownload, serverTimeMs|null]
        //
        // - serverTimeMs: integer milliseconds passed as `pf_srv` to the tracker, or null to omit
        //   the parameter entirely (leaving response_time_ms as SQL NULL in log_bot_request).
        //   At least a handful of requests intentionally omit server time so that
        //   nb_server_time < requests for some URLs, exercising the avg computation guard.
        //
        // - bytes: integer bytes passed as `bw_bytes`, or null to omit the parameter entirely
        //   (leaving response_size_bytes as SQL NULL). A couple of tuples use null bytes to produce
        //   at least one URL where nb_response_size < requests.
        $dailyPlans = [
            0 => [
                ['ChatGPT-User/1.0', $pages[0], 200, 12005, false, 250],
                ['Gemini-Deep-Research/1.0', $pages[1], 200, 29658, false, 1500],
                ['Perplexity-User/1.0', $downloads[0], 503, 1365955, true, 3200],
                ['Google-NotebookLM/1.0', $downloads[1], 404, 36522, true, null],
                ['ChatGPT-User/1.0', $pages[0], 200, 12584, false, 180],
                ['Gemini-Deep-Research/1.0', $pages[1], 200, 36598, false, 950],
                ['Perplexity-User/1.0', $downloads[0], 200, 99562, true, null],
                ['Google-NotebookLM/1.0', $pages[2], 200, 25489, false, 2100],
            ],
            1 => [
                // Extra broken entries placed early so their hour offset stays within Feb 3
                // (each tuple index N maps to addHour((N+1)*2); Nβ‰₯9 would spill into Feb 4).
                // doc.pdf 404 and guide.pdf 503 ensure ranking_limit tests (limit=2) produce
                // an "Others" row for the Broken report on this day.
                ['MistralAI-User/2.0', $downloads[0], 404, null, true, null],
                ['Perplexity-User/1.0', $downloads[1], 503, null, true, 150],
                ['MistralAI-User/2.0', $pages[2], 200, 32485, false, 1800],
                ['Claude-User/3.0', $downloads[2], 200, 123456, true, 4500],
                ['ChatGPT-User/1.0', $pages[1], 500, null, false, 320],
                // Same URL as the 500 above but a 200 β€” proves total_broken_requests = 1 (not COUNT=2) after F18 fix
                ['Gemini-Deep-Research/1.0', $pages[1], 200, 29658, false, 1200],
                ['ChatGPT-User/1.0', $downloads[1], 200, 33658, true, 750],
                ['Perplexity-User/1.0', $pages[2], 200, 36985, false, 2800],
                ['Perplexity-User/1.0', $pages[2], 200, 36985, false, 3100],
                ['MistralAI-User/2.0', $pages[3], 200, 85236, false, null],
                ['Claude-User/3.0', $downloads[3], 200, 12456, true, 600],
                ['Claude-User/3.0', $downloads[4], 200, 35562, true, 1200],
            ],
            2 => [
                ['Perplexity-User/1.0', $downloads[3], 200, 84269, true, 900],
                ['Gemini-Deep-Research/1.0', $pages[3], 200, 3265, false, 400],
                ['Google-NotebookLM/1.0', $pages[6], 200, 33366, false, 1100],
                ['ChatGPT-User/1.0', $pages[3], 200, 5454, false, null],
                ['Perplexity-User/1.0', $downloads[2], 200, 69856, true, 2200],
                ['Gemini-Deep-Research/1.0', $pages[4], 200, 63256, false, 3800],
                ['Google-NotebookLM/1.0', $pages[6], 200, 25486, false, 850],
            ],
            3 => [
                ['MistralAI-User/2.0', $pages[4], 200, 12568, false, 1400],
                ['Google-NotebookLM/1.0', $downloads[4], 404, 25648, true, null],
                ['ChatGPT-User/1.0', $pages[4], 200, 12548, false, 670],
                ['Claude-User/3.0', $pages[5], 503, null, false, 200],
                ['Perplexity-User/1.0', $downloads[0], 200, 225445, true, 5000],
                ['MistralAI-User/2.0', $pages[2], 200, 12456, false, 1650],
                ['Google-NotebookLM/1.0', $downloads[1], 200, 258741, true, 2900],
            ],
            4 => [
                ['Perplexity-User/1.0', $downloads[1], 200, 36985, true, 480],
                ['Gemini-Deep-Research/1.0', $pages[5], 200, 95147, false, 3300],
                ['ChatGPT-User/1.0', $pages[0], 200, 25412, false, 210],
                ['Claude-User/3.0', $pages[3], 200, 36985, false, null],
                ['Perplexity-User/1.0', $downloads[4], 200, 145811, true, 1700],
            ],
        ];

        foreach ($dailyPlans as $dayOffset => $requests) {
            foreach ($requests as $index => $request) {
                [$userAgent, $url, $status, $bytes, $isDownload, $serverTimeMs] = $request;
                $date = Date::factory($this->dateTime)
                    ->addDay($dayOffset)
                    ->addHour(($index + 1) * 2)
                    ->getDatetime();

                if ($isDownload) {
                    $this->logBotDownload($userAgent, $url, $status, $bytes, $date, $serverTimeMs);
                } else {
                    $this->logBot($userAgent, $url, $status, $bytes, $date, $serverTimeMs);
                }
            }
        }
    }

    private function trackRealTimeBotRequests(): void
    {
        $now = Date::factory('now');

        // Each tuple: [minutes before now, userAgent, url, httpStatus, bytes|null, isDownload, serverTimeMs|null].
        $requests = [
            [10, 'ChatGPT-User/1.0', 'https://example.com/realtime/a', 200, 10000, false, 150],
            [20, 'ChatGPT-User/1.0', 'https://example.com/realtime/b', 404, 12000, false, 250],
            [25, 'Claude-User/3.0', 'https://example.com/realtime/doc.pdf', 503, null, true, 300],
            [40, 'MistralAI-User/2.0', 'https://example.com/realtime/missing.pdf', 404, null, true, null],
            [60, 'Perplexity-User/1.0', 'https://example.com/realtime/a', 200, 11000, false, 180],
            [90, 'Claude-User/3.0', 'https://example.com/realtime/c', 200, 13000, false, 210],
            [300, 'Gemini-Deep-Research/1.0', 'https://example.com/realtime/a', 500, 14000, false, 450],
            [780, 'ChatGPT-User/1.0', 'https://example.com/realtime/out-of-window', 200, 15000, false, 120],
        ];

        foreach ($requests as $request) {
            [$minutesAgo, $userAgent, $url, $status, $bytes, $isDownload, $serverTimeMs] = $request;
            $date = $now->subPeriod($minutesAgo, 'minute')->getDatetime();

            if ($isDownload) {
                $this->logBotDownload($userAgent, $url, $status, $bytes, $date, $serverTimeMs);
            } else {
                $this->logBot($userAgent, $url, $status, $bytes, $date, $serverTimeMs);
            }
        }
    }

    private function trackAcquiredVisits(): void
    {
        $sources = [
            'https://chatgpt.com/thread/12345',
            'https://perplexity.ai/share/6789',
            'https://copilot.microsoft.com/answer/abc',
            'https://claude.ai/share/987',
            'https://gemini.google.com/share/notes',
            'https://chat.qwen.ai/share/insight',
            'https://chatgpt.com/thread/8888',
            'https://perplexity.ai/share/2222',
            'https://copilot.microsoft.com/answer/xyz',
            'https://claude.ai/share/1111',
        ];

        // Each in-week AI-landing page gets a DISTINCT number of acquired visits on a single day, so
        // every Favoured Pages row has a unique Discrepancy Score (no ties). Core truncates these
        // records by score and its sort leaves tied scores in a PHP/DB-dependent order, so distinct
        // scores keep the ranking-limit tests deterministic; keeping each page on one day also makes
        // its weekly count unambiguous when day blobs are summed. There is also a visit on every bot
        // day (Feb 2-6) so each day is archived and its bot requests count in the week/month totals.
        $plans = [
            [0, 'https://example.com/article-1', 1], // 2025-02-02 (prior week; keeps Feb 2 archived)
            [1, 'https://example.com/article-1', 1], // 2025-02-03
            [2, 'https://example.com/article-2', 2], // 2025-02-04
            [3, 'https://example.com/article-3', 3], // 2025-02-05
            [4, 'https://example.com/article-4', 4], // 2025-02-06
        ];

        $referrerIndex = 0;
        foreach ($plans as [$dayOffset, $url, $visits]) {
            for ($i = 0; $i < $visits; $i++) {
                $date = Date::factory($this->dateTime)
                    ->addDay($dayOffset)
                    ->addHour($referrerIndex + 1)
                    ->getDatetime();
                $tracker = self::getTracker($this->idSite, $date, true);
                $tracker->setUrl($url);
                $tracker->setUrlReferrer($sources[$referrerIndex % count($sources)]);
                self::checkResponse($tracker->doTrackPageView('Article From AI Chatbot ' . ($referrerIndex + 1)));
                $referrerIndex++;
            }
        }
    }

    /**
     * Tracks human pageviews to URLs the bots also request, so the Favoured Pages records have rows
     * where BOTH metrics are non-zero. example.com/article/2 spans Feb 3-5 on the human side and is
     * also bot-requested on several of those days, giving Row Evolution a real multi-day series for
     * both sides. The "www." URL collides, after Matomo's action-name normalization, with the bot
     * label example.com/article/3 β€” confirming both sides key on the same log_action.name.
     */
    private function trackHumanPageOverlaps(): void
    {
        // [url, dayOffset, number of human visits] β€” these URLs are also requested by bots in
        // trackBotRequests(). Day offset 1 = 2025-02-03 (the date the system tests query for `day`);
        // offsets 1-3 all fall inside the queried `week`.
        $overlaps = [
            ['https://example.com/article/2', 1, 2],
            ['https://example.com/article/2', 2, 2],
            ['https://example.com/article/2', 3, 1],
            ['https://www.example.com/article/3', 1, 1],
        ];

        foreach ($overlaps as [$url, $dayOffset, $visits]) {
            for ($i = 0; $i < $visits; $i++) {
                $date = Date::factory($this->dateTime)
                    ->addDay($dayOffset)
                    ->addHour($i + 1)
                    ->getDatetime();
                $tracker = self::getTracker($this->idSite, $date, true);
                $tracker->setUrl($url);
                self::checkResponse($tracker->doTrackPageView('Human Overlap Page ' . ($i + 1)));
            }
        }
    }

    /**
     * Tracks a human event on a page that gets NO pageview (and no bot request). The Favoured Pages
     * reports must not count this URL as a human pageview β€” events are excluded, mirroring the Actions
     * Pages report (see AIChatbotFavouredPages::queryHumanPageviews / Actions' not-an-event clause).
     */
    private function trackEventOnlyPage(): void
    {
        $date = Date::factory($this->dateTime)
            ->addDay(1) // 2025-02-03, the date the system tests query
            ->addHour(5)
            ->getDatetime();
        $tracker = self::getTracker($this->idSite, $date, true);
        $tracker->setUrl('https://example.com/event-only');
        self::checkResponse($tracker->doTrackEvent('Media', 'Play'));
    }

    /**
     * A human pageview on 2025-02-20 β€” well after the bot requests (Feb 2-6), so that period has no
     * AI chatbot activity. Used to assert the Favoured Pages records stay empty there (the human
     * pageviews scan is skipped when there are no bot requests).
     */
    private function trackHumanPageviewInBotFreePeriod(): void
    {
        $date = Date::factory($this->dateTime)->addDay(18)->getDatetime();
        $tracker = self::getTracker($this->idSite, $date, true);
        $tracker->setUrl('https://example.com/bot-free-page');
        self::checkResponse($tracker->doTrackPageView('Bot Free Page'));
    }

    private function logBot(string $userAgent, string $url, int $statusCode, ?int $bytes, string $dateTime, ?int $serverTimeMs): void
    {
        $tracker = self::getTracker($this->idSite, $dateTime, true);
        $tracker->setUrl($url);
        $this->applyCommonTrackingParameters($tracker, $userAgent, $statusCode, $bytes, $serverTimeMs);
        self::checkResponse($tracker->doTrackPageView(''));
    }

    private function logBotDownload(string $userAgent, string $url, int $statusCode, ?int $bytes, string $dateTime, ?int $serverTimeMs): void
    {
        $tracker = self::getTracker($this->idSite, $dateTime, true);
        $this->applyCommonTrackingParameters($tracker, $userAgent, $statusCode, $bytes, $serverTimeMs);
        self::checkResponse($tracker->doTrackAction($url, 'download'));
    }

    /**
     * Applies the tracking parameters shared between page and download bot requests:
     * user-agent, recMode flag, http_status, and the optional bw_bytes / pf_srv parameters.
     *
     * When $bytes or $serverTimeMs is null the corresponding parameter is intentionally omitted
     * so that response_size_bytes / response_time_ms stores SQL NULL in log_bot_request β€”
     * this exercises nb_response_size < requests and nb_server_time < requests in the averages guard.
     */
    private function applyCommonTrackingParameters(MatomoTracker $tracker, string $userAgent, int $statusCode, ?int $bytes, ?int $serverTimeMs): void
    {
        $tracker->setUserAgent($userAgent);
        $tracker->setCustomTrackingParameter('recMode', '1');
        $tracker->setCustomTrackingParameter('http_status', (string) $statusCode);
        if ($bytes !== null) {
            $tracker->setCustomTrackingParameter('bw_bytes', (string) $bytes);
        }
        if ($serverTimeMs !== null) {
            $tracker->setCustomTrackingParameter('pf_srv', (string) $serverTimeMs);
        }
    }
}