From 628b63701a770a55215f2a76f4be6ccba0a86931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Wed, 14 Oct 2026 13:56:39 +0200 Subject: [PATCH] Show page views of people, with a switch to include bots Page views counted crawlers and programs among people: 279 of genhttp.org's 1,244 page views were bots'. The views of programs are now counted apart (bot_views in every analytics table, botViews in the answer), and the editor shows the pages people opened unless the owner switches on "Include bots" beside the time ranges. The switch is worked out from the one answer, so it changes the figure, its chart and the page views' cards at once, and is remembered in the browser. Where visitors came from stays people's either way. V2 fills in the days already counted where it can: their totals and devices from the devices' bots and others, the crawlers as bots. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 ++ README.md | 9 +- src/Frontend/src/api.ts | 6 +- src/Frontend/src/control/AnalyticsTab.tsx | 131 +++++++++++++++--- .../src/locales/de/editor/analytics.tsx | 4 + src/Frontend/src/locales/de/guide.tsx | 4 +- .../src/locales/en/editor/analytics.tsx | 7 + src/Frontend/src/locales/en/guide.tsx | 3 +- .../src/locales/es/editor/analytics.tsx | 4 + src/Frontend/src/locales/es/guide.tsx | 3 +- .../src/locales/fr/editor/analytics.tsx | 4 + src/Frontend/src/locales/fr/guide.tsx | 2 + .../src/locales/id/editor/analytics.tsx | 4 + src/Frontend/src/locales/id/guide.tsx | 3 +- .../src/locales/it/editor/analytics.tsx | 4 + src/Frontend/src/locales/it/guide.tsx | 7 +- .../src/locales/ja/editor/analytics.tsx | 4 + src/Frontend/src/locales/ja/guide.tsx | 2 +- .../src/locales/ko/editor/analytics.tsx | 4 + src/Frontend/src/locales/ko/guide.tsx | 3 +- .../src/locales/nl/editor/analytics.tsx | 4 + src/Frontend/src/locales/nl/guide.tsx | 3 +- .../src/locales/pl/editor/analytics.tsx | 4 + src/Frontend/src/locales/pl/guide.tsx | 3 +- .../src/locales/pt-pt/editor/analytics.tsx | 4 + src/Frontend/src/locales/pt-pt/guide.tsx | 2 +- .../src/locales/pt/editor/analytics.tsx | 4 + src/Frontend/src/locales/pt/guide.tsx | 4 +- .../src/locales/tr/editor/analytics.tsx | 4 + src/Frontend/src/locales/tr/guide.tsx | 10 +- src/GenHTTP.Lambda/Api/Mcp/McpTools.cs | 4 +- src/GenHTTP.Lambda/Api/Model/Analytics.cs | 24 +++- .../Services/Analytics/AnalyticsModel.cs | 12 +- .../Services/Analytics/AnalyticsStore.cs | 32 +++-- .../Analytics/Migrations/V2__Bot_views.sql | 30 ++++ .../Services/Analytics/VisitTally.cs | 33 +++-- .../Analytics/AnalyticsTests.cs | 39 ++++++ 37 files changed, 359 insertions(+), 76 deletions(-) create mode 100644 src/GenHTTP.Lambda/Services/Analytics/Migrations/V2__Bot_views.sql diff --git a/CLAUDE.md b/CLAUDE.md index d5607ba..348efff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -480,6 +480,17 @@ section. counted once a day with the first page they opened. A request for anything else - a script, an API call, `robots.txt`, a path that was not there - makes nobody a visitor, so there are never more visitors than page views. +- **Page views are people's, bots behind a switch - decided by the owner.** + The views of programs are counted apart (`bot_views`, in every table), and + the editor shows the pages people opened unless the owner switches on + **Include bots** - beside the ranges, since it changes the figure, its chart + and the page views' cards alike; remembered in their browser, and worked out + from the one answer rather than asked for again, and marked "incl. bots" + wherever it counts them. Where visitors came from is people's whatever the + switch says - a bot comes from nowhere and would read as typed in - and + requests and crawlers count every request either way. Days counted before `V2` keep their bots among + the people of their pages, referrers and countries; their totals and devices + were filled in. - **Nothing that names a visitor is kept.** Counts only; the referrer as its host; visitors told apart for a day by a hash under a random key per day and lambda, held in memory and never written. No cookie, no script in the diff --git a/README.md b/README.md index 574d13a..ab51bf4 100644 --- a/README.md +++ b/README.md @@ -828,7 +828,14 @@ feature's preview. - **What a visit is counted as.** A *hit* is every request answered; a *view* is somebody opening a page - a browser's navigation (`Sec-Fetch-Dest: document`, a revalidation included) or, from a program that does not send - that header, a successful `GET` answered with HTML. A path is counted where + that header, a successful `GET` answered with HTML. Of the views, those + of programs - crawlers, and whatever names no browser - are counted apart + as well (`bot_views`, `botViews` in the answer), everywhere a view is: the + editor shows the pages people opened and adds the bots' behind a switch + (**Include bots**), off by default and remembered in the browser, worked + out from the one answer so it switches at once. The referring sites stay + people's either way: a bot names no page it came from and would be counted + as typed in. A path is counted where it was answered without an error, at most 200 a day, and the rest under `(other)`, so a scanner cannot fill a day. A referrer is kept as its host, without `www.`, and only where it is another site than the one visited (100 diff --git a/src/Frontend/src/api.ts b/src/Frontend/src/api.ts index 9540e0b..55a3e27 100644 --- a/src/Frontend/src/api.ts +++ b/src/Frontend/src/api.ts @@ -85,8 +85,10 @@ export interface Activation { export interface AnalyticsTotals { /** Every request answered - pages, the files they load, calls of an API. */ hits: number; - /** The requests that were somebody opening a page. */ + /** The requests that were somebody opening a page - people and programs alike. */ views: number; + /** Of the views, those of programs - crawlers, and whatever names no browser - rather than people. */ + botViews: number; /** Different visitors, each counted once a day. */ visitors: number; failed: number; @@ -107,6 +109,8 @@ export interface AnalyticsValue { value: string; hits: number; views: number; + /** Of the views, those programs opened rather than people. */ + botViews: number; } /** How a lambda's visitors reach it, as its owner is shown. */ diff --git a/src/Frontend/src/control/AnalyticsTab.tsx b/src/Frontend/src/control/AnalyticsTab.tsx index e269f9f..e4c724b 100644 --- a/src/Frontend/src/control/AnalyticsTab.tsx +++ b/src/Frontend/src/control/AnalyticsTab.tsx @@ -10,7 +10,7 @@ import { useEditorT, useLanguage } from '../i18n'; import { tagOf } from '../i18n/languages'; import type { Control } from './context'; import { count, percent } from './format'; -import { Section, Sparkline, pill } from './ui'; +import { Section, Sparkline, Switch, pill } from './ui'; /** The time ranges offered; those longer than the tier shows are offered locked. */ const RANGES = [7, 30, 90, 365]; @@ -35,6 +35,17 @@ const DEVICES: Record = { const DEVICE_ORDER = ['desktop', 'mobile', 'tablet', 'bot', 'other']; +/** Whether the owner counts the pages bots opened as well, remembered in their browser. */ +const BOTS = 'lambda-analytics-bots'; + +function rememberedBots(): boolean { + try { + return localStorage.getItem(BOTS) === 'yes'; + } catch { + return false; + } +} + /** * Who visits the lambda: how many people come each day, which pages they * open, which sites send them, from which countries and on what. @@ -66,6 +77,25 @@ export function AnalyticsTab({ control }: { control: Control }) { const [measure, setMeasure] = useState('visitors'); const [switching, setSwitching] = useState(false); const [confirming, setConfirming] = useState(false); + const [bots, setBots] = useState(rememberedBots); + + const toggleBots = () => { + setBots((were) => { + try { + localStorage.setItem(BOTS, were ? 'no' : 'yes'); + } catch { + // a browser that keeps nothing starts with people only, which is fine + } + + return !were; + }); + }; + + // the pages people opened, unless the owner asked for those of bots as + // well: worked out here rather than asked for again, so the switch answers + // at once + const people = useMemo(() => state && withoutBots(state), [state]); + const shown = bots ? state : people; /* * Read when opened and when the range changes, and again when the page is @@ -126,7 +156,7 @@ export function AnalyticsTab({ control }: { control: Control }) { } } - if (!state) { + if (!state || !shown || !people) { return (
{failure ? ( @@ -150,8 +180,9 @@ export function AnalyticsTab({ control }: { control: Control }) { const date = (day: string, options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }) => new Date(`${day}T00:00:00Z`).toLocaleDateString(tag, { ...options, timeZone: 'UTC' }); - // pages opened where there are any; an app that only answers calls is - // described by what it was asked for + // pages opened where there are any, by bots included, so the switch never + // turns the page into another; an app that only answers calls is described + // by what it was asked for const by: 'views' | 'hits' = state.totals.views > 0 ? 'views' : 'hits'; const hidden = state.earliest != null && state.earliest < state.from; @@ -180,15 +211,32 @@ export function AnalyticsTab({ control }: { control: Control }) { ); + /* + * Beside the ranges rather than in the page views' own group, since it + * changes the figure at the top and its chart as well. Only where pages + * are opened: requests and visitors are what they are either way. + */ + const toolbar = ( + <> + {ranges} + {by === 'views' && ( + + )} + + ); + return ( -
+
{hidden && lambda.tier !== 'Premium' && ( )} - + - + {/* * In two groups by what their numbers count, since the two can be far @@ -197,31 +245,32 @@ export function AnalyticsTab({ control }: { control: Control }) { * people open is described by its requests alone. */} {by === 'views' && ( - - + + p.views > 0)} + values={(shown.pages ?? []).filter((p) => p.views > 0)} by="views" mono name={(value) => (value === '(other)' ? said.other : value)} /> + {/* people's, whatever the switch says: a bot comes from nowhere, and would be counted as typed in */} r.views > 0)} + values={(people.referrers ?? []).filter((r) => r.views > 0)} by="views" name={(value) => (value === '(other)' ? said.other : value)} icon={} - rest={{ label: said.direct, value: Math.max(0, state.totals.views - sum(state.referrers, 'views')) }} + rest={{ label: said.direct, value: Math.max(0, people.totals!.views - sum(people.referrers, 'views')) }} /> - c.views > 0)} by="views" tag={tag} /> + c.views > 0)} by="views" tag={tag} /> - + )} @@ -365,7 +414,13 @@ function Nudge({ since, address }: { since: string; address: string }) { * range, how it changed against the days before where those are shown, and * its days in small. */ -function Figures({ state, measure, onPick, dark }: { state: LambdaAnalytics; measure: Measure; onPick: (m: Measure) => void; dark: boolean }) { +function Figures({ state, measure, onPick, dark, bots }: { + state: LambdaAnalytics; + measure: Measure; + onPick: (m: Measure) => void; + dark: boolean; + bots: boolean; +}) { const said = useEditorT().analytics; const figures: { id: Measure; label: string; hint: string }[] = [ @@ -397,7 +452,11 @@ function Figures({ state, measure, onPick, dark }: { state: LambdaAnalytics; mea className="absolute inset-x-0 top-0 h-0.5" style={{ background: active ? ACCENT[dark ? 1 : 0] : 'transparent' }} /> -
{figure.label}
+
+ {figure.label} + {/* so a figure read on its own, or in a screenshot, says what it counts */} + {figure.id === 'views' && bots && {said.withBots}} +
{count(value)}
@@ -631,16 +690,24 @@ function useWidth(initial: number) { } /** The cards whose numbers count the same thing, under the name of the figure they count. */ -function Group({ title, hint, children }: { title: string; hint: string; children: ReactNode }) { +function Group({ title, hint, badge, children }: { title: string; hint: string; badge?: string; children: ReactNode }) { return (
-

{title}

+

+ {title} + {badge && {badge}} +

{hint}

{children}
); } +/** A word beside a heading that says what its numbers count besides the usual. */ +function Badge({ children }: { children: ReactNode }) { + return {children}; +} + /** A card of a group. */ function Card({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) { return ( @@ -868,5 +935,33 @@ function niceCeiling(value: number) { return 10 * power; } +/** + * The pages people opened: what the analytics are without the views of bots, + * everywhere a view is counted - the totals, the days and every breakdown. + */ +function withoutBots(state: LambdaAnalytics): LambdaAnalytics { + const people = (counted: T): T => ({ + ...counted, + views: Math.max(0, counted.views - (counted.botViews ?? 0)), + botViews: 0, + }); + + const list = (values: AnalyticsValue[] | null) => values?.map(people) ?? null; + + return { + ...state, + totals: state.totals && people(state.totals), + previous: state.previous && people(state.previous), + daily: state.daily?.map((day) => ({ ...day, totals: people(day.totals) })) ?? null, + pages: list(state.pages), + referrers: list(state.referrers), + countries: list(state.countries), + devices: list(state.devices), + browsers: list(state.browsers), + systems: list(state.systems), + crawlers: list(state.crawlers), + }; +} + const sum = (values: AnalyticsValue[] | null, by: keyof Pick) => (values ?? []).reduce((total, v) => total + v[by], 0); diff --git a/src/Frontend/src/locales/de/editor/analytics.tsx b/src/Frontend/src/locales/de/editor/analytics.tsx index f6f2c7c..ba03d53 100644 --- a/src/Frontend/src/locales/de/editor/analytics.tsx +++ b/src/Frontend/src/locales/de/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Verschiedene Personen, die eine Seite geöffnet haben, jede einmal pro Tag gezählt. Suchmaschinen und andere Programme sind keine Besucher.', views: 'Seitenaufrufe', viewsHint: 'Jedes Mal, wenn jemand eine Seite geöffnet hat.', + bots: 'Bots einbeziehen', + botsHint: 'Zählt auch die Seiten, die Suchmaschinen, KI-Crawler und andere Programme geöffnet haben. Besucher sind in jedem Fall Menschen.', + withBots: 'inkl. Bots', hits: 'Requests', hitsHint: 'Alles, was bei Ihrer App angefragt wurde: ihre Seiten sowie die Bilder und Skripte, die sie laden.', versus: (days) => `gegenüber den ${days} Tagen davor`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Seiten', pagesHint: 'Die Seiten, die geöffnet wurden – die am häufigsten geöffneten zuerst.', + pagesHintBots: 'Die Seiten, die Menschen und Bots geöffnet haben – die am häufigsten geöffneten zuerst.', requested: 'Am häufigsten angefragt', requestedHint: 'Alles, was bei Ihrer App angefragt wurde, nach Adresse – sie hat keine Seiten, die Personen öffnen.', sources: 'Woher die Besucher kommen', diff --git a/src/Frontend/src/locales/de/guide.tsx b/src/Frontend/src/locales/de/guide.tsx index 4709d81..b8b00b9 100644 --- a/src/Frontend/src/locales/de/guide.tsx +++ b/src/Frontend/src/locales/de/guide.tsx @@ -511,7 +511,9 @@ export const guide: Messages['guide'] = { Öffnen Sie im Kontrollzentrum {k.b('Analyse')} und schalten Sie sie ein, um zu sehen, wer Ihre App besucht: wie viele Personen jeden Tag kommen, welche Seiten sie öffnen, welche Websites sie schicken – eine Suchmaschine, ein Beitrag, ein Newsletter –, aus welchen Ländern, auf welchen Geräten und in welchen Browsern, und welche Suchmaschinen und - KI-Assistenten ihre Seiten lesen. Wählen Sie oben eine Zahl, um sie Tag für Tag zu sehen. + KI-Assistenten ihre Seiten lesen. Wählen Sie oben eine Zahl, um sie Tag für Tag zu sehen. Seitenaufrufe sind die Seiten, + die Personen geöffnet haben; schalten Sie {k.b('Bots einbeziehen')} ein, um auch die von Suchmaschinen, KI-Crawlern und + anderen Programmen zu zählen. )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/en/editor/analytics.tsx b/src/Frontend/src/locales/en/editor/analytics.tsx index 9e812bd..f4feec8 100644 --- a/src/Frontend/src/locales/en/editor/analytics.tsx +++ b/src/Frontend/src/locales/en/editor/analytics.tsx @@ -33,6 +33,11 @@ export const analytics = { visitorsHint: 'Different people who opened a page, each counted once a day. Search engines and other programs are not visitors.', views: 'Page views', viewsHint: 'Every time somebody opened a page.', + /** The switch beside the time ranges: whether the page views count the pages bots opened as well. Off, they count people only. */ + bots: 'Include bots', + botsHint: 'Count the pages that search engines, AI crawlers and other programs opened as well. Visitors are people either way.', + /** Beside "Page views" at the top while bots are included. */ + withBots: 'incl. bots', hits: 'Requests', hitsHint: 'Everything your app was asked for: its pages, and the pictures and scripts they load.', /** Under a figure: how it changed against the days before the ones shown. */ @@ -62,6 +67,8 @@ export const analytics = { pages: 'Pages', pagesHint: 'The pages people opened, the most opened first.', + /** The same, while the bots are included. */ + pagesHintBots: 'The pages people and bots opened, the most opened first.', requested: 'Most requested', requestedHint: 'Everything your app was asked for, by address - it has no pages people open.', sources: 'Where visitors come from', diff --git a/src/Frontend/src/locales/en/guide.tsx b/src/Frontend/src/locales/en/guide.tsx index 8719dd9..32e3041 100644 --- a/src/Frontend/src/locales/en/guide.tsx +++ b/src/Frontend/src/locales/en/guide.tsx @@ -507,7 +507,8 @@ export const guide = { Open {k.b('Analytics')} in the control center and switch it on to see who visits your app: how many people come each day, which pages they open, which sites send them - a search engine, a post, a newsletter - from which countries, on which devices and browsers, and which search engines and AI assistants read its pages. - Pick a figure at the top to see it day by day. + Pick a figure at the top to see it day by day. Page views are the pages people opened; switch on {k.b('Include bots')} + to count those of search engines, AI crawlers and other programs as well. )) as Text, visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/es/editor/analytics.tsx b/src/Frontend/src/locales/es/editor/analytics.tsx index 847195e..cc9ab9f 100644 --- a/src/Frontend/src/locales/es/editor/analytics.tsx +++ b/src/Frontend/src/locales/es/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Personas distintas que abrieron una página, cada una contada una vez al día. Los buscadores y otros programas no son visitantes.', views: 'Visualizaciones de página', viewsHint: 'Cada vez que alguien abrió una página.', + bots: 'Incluir bots', + botsHint: 'Cuenta también las páginas que abrieron buscadores, rastreadores de IA y otros programas. Los visitantes son siempre personas.', + withBots: 'con bots', hits: 'Peticiones', hitsHint: 'Todo lo que se pidió a tu app: sus páginas y también las imágenes y los scripts que cargan.', versus: (days) => `frente a los ${days} días anteriores`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Páginas', pagesHint: 'Las páginas que abrió la gente, las más abiertas primero.', + pagesHintBots: 'Las páginas que abrieron la gente y los bots, las más abiertas primero.', requested: 'Lo más pedido', requestedHint: 'Todo lo que se pidió a tu app, por dirección: no tiene páginas que la gente abra.', sources: 'De dónde vienen los visitantes', diff --git a/src/Frontend/src/locales/es/guide.tsx b/src/Frontend/src/locales/es/guide.tsx index 6833123..be74028 100644 --- a/src/Frontend/src/locales/es/guide.tsx +++ b/src/Frontend/src/locales/es/guide.tsx @@ -501,7 +501,8 @@ export const guide: Messages['guide'] = { Abre {k.b('Analítica')} en el centro de control y actívala para ver quién visita tu app: cuánta gente llega cada día, qué páginas abre, qué sitios la envían (un buscador, una publicación, un boletín), desde qué países, en qué dispositivos y navegadores, y qué buscadores y asistentes de IA leen sus páginas. Elige una cifra arriba para - verla día a día. + verla día a día. Las visitas a páginas son las páginas que la gente abrió; activa {k.b('Incluir bots')} para + contar también las de buscadores, rastreadores de IA y otros programas. ), visitors2: (k) => ( diff --git a/src/Frontend/src/locales/fr/editor/analytics.tsx b/src/Frontend/src/locales/fr/editor/analytics.tsx index ef7120a..debe4bc 100644 --- a/src/Frontend/src/locales/fr/editor/analytics.tsx +++ b/src/Frontend/src/locales/fr/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Personnes différentes ayant ouvert une page, chacune comptée une fois par jour. Les moteurs de recherche et les autres programmes ne sont pas des visiteurs.', views: 'Pages vues', viewsHint: 'Chaque fois que quelqu’un a ouvert une page.', + bots: 'Inclure les robots', + botsHint: 'Compter aussi les pages ouvertes par les moteurs de recherche, les robots d’IA et d’autres programmes. Les visiteurs sont toujours des personnes.', + withBots: 'robots inclus', hits: 'Requêtes', hitsHint: 'Tout ce qui a été demandé à votre application : ses pages, et les images et scripts qu’elles chargent.', versus: (days) => `par rapport aux ${days} jours précédents`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Pages', pagesHint: 'Les pages ouvertes, les plus ouvertes en premier.', + pagesHintBots: 'Les pages ouvertes par des personnes et par des robots, les plus ouvertes en premier.', requested: 'Les plus demandées', requestedHint: 'Tout ce qui a été demandé à votre application, par adresse - elle n’a pas de pages que l’on ouvre.', sources: 'D’où viennent les visiteurs', diff --git a/src/Frontend/src/locales/fr/guide.tsx b/src/Frontend/src/locales/fr/guide.tsx index 2f86b5b..810baae 100644 --- a/src/Frontend/src/locales/fr/guide.tsx +++ b/src/Frontend/src/locales/fr/guide.tsx @@ -514,6 +514,8 @@ export const guide: Messages['guide'] = { de personnes viennent chaque jour, quelles pages elles ouvrent, quels sites les envoient - un moteur de recherche, une publication, une newsletter - depuis quels pays, sur quels appareils et navigateurs, et quels moteurs de recherche et assistants IA lisent ses pages. Choisissez un chiffre en haut pour le voir jour par jour. + Les pages vues sont les pages que les visiteurs ont ouvertes ; activez {k.b('Inclure les robots')} pour compter + aussi celles des moteurs de recherche, des robots d’exploration IA et d’autres programmes. )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/id/editor/analytics.tsx b/src/Frontend/src/locales/id/editor/analytics.tsx index e45d438..af315c8 100644 --- a/src/Frontend/src/locales/id/editor/analytics.tsx +++ b/src/Frontend/src/locales/id/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Orang yang berbeda yang membuka sebuah halaman, masing-masing dihitung sekali sehari. Mesin pencari dan program lain bukan pengunjung.', views: 'Tampilan halaman', viewsHint: 'Setiap kali seseorang membuka sebuah halaman.', + bots: 'Sertakan bot', + botsHint: 'Hitung juga halaman yang dibuka oleh mesin pencari, crawler AI, dan program lain. Pengunjung tetap hanya manusia.', + withBots: 'termasuk bot', hits: 'Request', hitsHint: 'Semua yang diminta dari aplikasi Anda: halamannya, serta gambar dan skrip yang dimuat halaman itu.', versus: (days) => `dibandingkan ${days} hari sebelumnya`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Halaman', pagesHint: 'Halaman yang dibuka orang, yang paling sering dibuka di atas.', + pagesHintBots: 'Halaman yang dibuka orang dan bot, yang paling sering dibuka di atas.', requested: 'Paling sering diminta', requestedHint: 'Semua yang diminta dari aplikasi Anda, menurut alamat - aplikasi ini tidak punya halaman yang dibuka orang.', sources: 'Asal pengunjung', diff --git a/src/Frontend/src/locales/id/guide.tsx b/src/Frontend/src/locales/id/guide.tsx index 87b94e6..b01ae60 100644 --- a/src/Frontend/src/locales/id/guide.tsx +++ b/src/Frontend/src/locales/id/guide.tsx @@ -511,7 +511,8 @@ export const guide: Messages['guide'] = { banyak orang yang datang setiap hari, halaman apa yang mereka buka, situs mana yang mengarahkan mereka - mesin pencari, sebuah postingan, sebuah newsletter - dari negara mana, dengan perangkat dan browser apa, dan mesin pencari serta asisten AI mana yang membaca halamannya. Pilih salah satu angka di bagian atas untuk melihatnya - hari demi hari. + hari demi hari. Tayangan halaman adalah halaman yang dibuka orang; aktifkan {k.b('Sertakan bot')} untuk ikut + menghitung tayangan dari mesin pencari, crawler AI, dan program lain. )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/it/editor/analytics.tsx b/src/Frontend/src/locales/it/editor/analytics.tsx index c0a83f8..d45c593 100644 --- a/src/Frontend/src/locales/it/editor/analytics.tsx +++ b/src/Frontend/src/locales/it/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Persone diverse che hanno aperto una pagina, ognuna contata una volta al giorno. I motori di ricerca e gli altri programmi non sono visitatori.', views: 'Visualizzazioni di pagina', viewsHint: 'Ogni volta che qualcuno ha aperto una pagina.', + bots: 'Includi i bot', + botsHint: 'Conta anche le pagine aperte da motori di ricerca, crawler di IA e altri programmi. I visitatori sono sempre persone.', + withBots: 'bot inclusi', hits: 'Richieste', hitsHint: 'Tutto ciò che è stato chiesto alla tua app: le sue pagine, e le immagini e gli script che caricano.', versus: (days) => `rispetto ai ${days} giorni precedenti`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Pagine', pagesHint: 'Le pagine aperte dalle persone, le più aperte per prime.', + pagesHintBots: 'Le pagine aperte dalle persone e dai bot, le più aperte per prime.', requested: 'Più richieste', requestedHint: 'Tutto ciò che è stato chiesto alla tua app, per indirizzo: non ha pagine che le persone aprono.', sources: 'Da dove arrivano i visitatori', diff --git a/src/Frontend/src/locales/it/guide.tsx b/src/Frontend/src/locales/it/guide.tsx index d6e5ec8..70bb577 100644 --- a/src/Frontend/src/locales/it/guide.tsx +++ b/src/Frontend/src/locales/it/guide.tsx @@ -497,14 +497,15 @@ export const guide: Messages['guide'] = { ), openAside: 'Tutto quello che c’è nel codice diventa pubblico, comprese le versioni precedenti. Una chiave o una password va tra le chiavi e password in Dati, mai nel codice, che sia pubblicato o no.', - visitors: ((k) => ( + visitors: (k) => ( <> Apri {k.b('Analytics')} nel pannello di controllo e attivalo per vedere chi visita la tua app: quante persone arrivano ogni giorno, quali pagine aprono, quali siti le mandano (un motore di ricerca, un post, una newsletter), da quali paesi, con quali dispositivi e browser, e quali motori di ricerca e assistenti IA leggono le sue pagine. - Scegli una cifra in alto per vederla giorno per giorno. + Scegli una cifra in alto per vederla giorno per giorno. Le visualizzazioni di pagina sono le pagine aperte dalle + persone; attiva {k.b('Includi i bot')} per contare anche quelle di motori di ricerca, crawler IA e altri programmi. - )), + ), visitors2: ((k) => ( <> Lo conta la piattaforma, davanti alla tua app, quindi alla tua app non serve nulla: niente cookie, niente script, diff --git a/src/Frontend/src/locales/ja/editor/analytics.tsx b/src/Frontend/src/locales/ja/editor/analytics.tsx index 6375a1e..5180ae8 100644 --- a/src/Frontend/src/locales/ja/editor/analytics.tsx +++ b/src/Frontend/src/locales/ja/editor/analytics.tsx @@ -31,6 +31,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'ページを開いたユニークな訪問者数です。同じ人は1日に1回だけ数えます。検索エンジンやその他のプログラムは訪問者に含めません。', views: 'ページビュー', viewsHint: 'ページが開かれた回数です。', + bots: 'ボットを含める', + botsHint: '検索エンジンやAIクローラー、その他のプログラムが開いたページも数えます。訪問者は、どちらの場合も人だけです。', + withBots: 'ボットを含む', hits: 'リクエスト', hitsHint: 'アプリに寄せられたすべてのリクエストです。ページに加え、ページが読み込む画像やスクリプトも含みます。', /** Under a figure: how it changed against the days before the ones shown. */ @@ -60,6 +63,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'ページ', pagesHint: '開かれたページを、多い順に表示します。', + pagesHintBots: '人とボットが開いたページを、多い順に表示します。', requested: 'リクエストの多いもの', requestedHint: 'アプリに寄せられたすべてのリクエストを、パスごとに表示します。人が開くページはありません。', sources: '訪問元', diff --git a/src/Frontend/src/locales/ja/guide.tsx b/src/Frontend/src/locales/ja/guide.tsx index 67e2e7f..b1d8cf5 100644 --- a/src/Frontend/src/locales/ja/guide.tsx +++ b/src/Frontend/src/locales/ja/guide.tsx @@ -466,7 +466,7 @@ export const guide: Messages['guide'] = { 'コードに書かれているものは、以前のバージョンも含めてすべて公開されます。キーやパスワードは、公開するかどうかにかかわらずコードには書かず、「データ」のキーとパスワードに入れてください。', visitors: ((k) => ( <> - 管理画面で{k.b('アクセス解析')}を開いてオンにすると、アプリを訪れた人がわかります。毎日どれくらいの人が来るか、どのページを開くか、どのサイト(検索エンジン、投稿、ニュースレターなど)から来るか、どの国から、どのデバイスやブラウザで来るか、そしてどの検索エンジンやAIアシスタントがページを読んだかを確認できます。上部の数値を選ぶと、日ごとに見られます。 + 管理画面で{k.b('アクセス解析')}を開いてオンにすると、アプリを訪れた人がわかります。毎日どれくらいの人が来るか、どのページを開くか、どのサイト(検索エンジン、投稿、ニュースレターなど)から来るか、どの国から、どのデバイスやブラウザで来るか、そしてどの検索エンジンやAIアシスタントがページを読んだかを確認できます。上部の数値を選ぶと、日ごとに見られます。ページビューは人が開いたページの数です。{k.b('ボットを含める')}をオンにすると、検索エンジンやAIクローラー、その他のプログラムによるものも数えます。 )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/ko/editor/analytics.tsx b/src/Frontend/src/locales/ko/editor/analytics.tsx index 8be5f54..b559171 100644 --- a/src/Frontend/src/locales/ko/editor/analytics.tsx +++ b/src/Frontend/src/locales/ko/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: '페이지를 연 서로 다른 사람을 하루에 한 번씩 세어요. 검색 엔진과 그 밖의 프로그램은 방문자가 아니에요.', views: '페이지 조회수', viewsHint: '누군가 페이지를 열 때마다 세어요.', + bots: '봇 포함', + botsHint: '검색 엔진, AI 크롤러 등 다른 프로그램이 연 페이지도 함께 세어요. 방문자는 어느 쪽이든 사람만 해당해요.', + withBots: '봇 포함', hits: '요청', hitsHint: '앱이 받은 모든 요청이에요. 페이지와, 페이지가 불러오는 그림과 스크립트를 포함해요.', versus: (days) => `직전 ${days}일 대비`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: '페이지', pagesHint: '사람들이 연 페이지예요. 가장 많이 열린 것부터 보여 줘요.', + pagesHintBots: '사람들과 봇이 연 페이지예요. 가장 많이 열린 것부터 보여 줘요.', requested: '가장 많이 요청된 것', requestedHint: '앱이 받은 모든 요청을 주소별로 보여 줘요. 이 앱에는 사람들이 여는 페이지가 없어요.', sources: '방문 경로', diff --git a/src/Frontend/src/locales/ko/guide.tsx b/src/Frontend/src/locales/ko/guide.tsx index e5308e4..acf6fe7 100644 --- a/src/Frontend/src/locales/ko/guide.tsx +++ b/src/Frontend/src/locales/ko/guide.tsx @@ -487,7 +487,8 @@ export const guide: Messages['guide'] = { 관리 화면에서 {k.b('분석')}을 열고 켜면 앱에 누가 방문하는지 볼 수 있어요. 하루에 몇 명이 오는지, 어떤 페이지를 여는지, 검색 엔진이나 게시글, 뉴스레터처럼 어떤 사이트에서 오는지, 어느 나라에서 어떤 기기와 브라우저로 오는지, 그리고 어떤 검색 엔진과 AI 어시스턴트가 페이지를 읽어 가는지 알려 줘요. - 맨 위에서 수치를 고르면 날짜별로 볼 수 있어요. + 맨 위에서 수치를 고르면 날짜별로 볼 수 있어요. 페이지 조회수는 사람들이 연 페이지 수예요.{' '} + {k.b('봇 포함')}을 켜면 검색 엔진, AI 크롤러 같은 다른 프로그램의 조회수도 함께 세요. )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/nl/editor/analytics.tsx b/src/Frontend/src/locales/nl/editor/analytics.tsx index e768393..aa0e8ed 100644 --- a/src/Frontend/src/locales/nl/editor/analytics.tsx +++ b/src/Frontend/src/locales/nl/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Verschillende mensen die een pagina hebben geopend, elk één keer per dag geteld. Zoekmachines en andere programma’s zijn geen bezoekers.', views: 'Paginaweergaven', viewsHint: 'Elke keer dat iemand een pagina opende.', + bots: 'Bots meetellen', + botsHint: 'Tel ook de pagina’s mee die zoekmachines, AI-crawlers en andere programma’s hebben geopend. Bezoekers zijn in beide gevallen mensen.', + withBots: 'incl. bots', hits: 'Requests', hitsHint: 'Alles wat er van je app gevraagd werd: de pagina’s, en de plaatjes en scripts die ze laden.', versus: (days) => `ten opzichte van de ${days} dagen ervoor`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Pagina’s', pagesHint: 'De pagina’s die mensen openden, de meest geopende eerst.', + pagesHintBots: 'De pagina’s die mensen en bots openden, de meest geopende eerst.', requested: 'Meest opgevraagd', requestedHint: 'Alles wat er van je app gevraagd werd, per adres – er zijn geen pagina’s die mensen openen.', sources: 'Waar bezoekers vandaan komen', diff --git a/src/Frontend/src/locales/nl/guide.tsx b/src/Frontend/src/locales/nl/guide.tsx index ba8d5cd..09a2b56 100644 --- a/src/Frontend/src/locales/nl/guide.tsx +++ b/src/Frontend/src/locales/nl/guide.tsx @@ -510,7 +510,8 @@ export const guide: Messages['guide'] = { Open {k.b('Analytics')} in het dashboard en zet het aan om te zien wie je app bezoekt: hoeveel mensen er per dag komen, welke pagina’s ze openen, welke sites hen sturen – een zoekmachine, een bericht, een nieuwsbrief – uit welke landen, op welke apparaten en browsers, en welke zoekmachines en AI-assistenten de pagina’s lezen. Kies bovenaan - een cijfer om het dag voor dag te zien. + een cijfer om het dag voor dag te zien. Paginaweergaven zijn de pagina’s die mensen hebben geopend; zet {k.b('Bots meetellen')} aan + om ook die van zoekmachines, AI-crawlers en andere programma’s mee te tellen. )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/pl/editor/analytics.tsx b/src/Frontend/src/locales/pl/editor/analytics.tsx index 9e93478..9c17f4a 100644 --- a/src/Frontend/src/locales/pl/editor/analytics.tsx +++ b/src/Frontend/src/locales/pl/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Różne osoby, które otworzyły stronę, każda liczona raz dziennie. Wyszukiwarki i inne programy nie są odwiedzającymi.', views: 'Odsłony stron', viewsHint: 'Za każdym razem, gdy ktoś otworzył stronę.', + bots: 'Uwzględnij boty', + botsHint: 'Licz także strony otwarte przez wyszukiwarki, roboty AI i inne programy. Odwiedzający to w obu przypadkach ludzie.', + withBots: 'z botami', hits: 'Żądania', hitsHint: 'Wszystko, o co poproszono twoją aplikację: jej strony oraz obrazy i skrypty, które ładują.', versus: (days) => `względem poprzednich ${days} dni`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Strony', pagesHint: 'Strony otwierane przez ludzi, od najczęściej otwieranych.', + pagesHintBots: 'Strony otwierane przez ludzi i boty, od najczęściej otwieranych.', requested: 'Najczęściej żądane', requestedHint: 'Wszystko, o co poproszono twoją aplikację, według adresu – nie ma w niej stron otwieranych przez ludzi.', sources: 'Skąd przychodzą odwiedzający', diff --git a/src/Frontend/src/locales/pl/guide.tsx b/src/Frontend/src/locales/pl/guide.tsx index b1703c6..7f60d70 100644 --- a/src/Frontend/src/locales/pl/guide.tsx +++ b/src/Frontend/src/locales/pl/guide.tsx @@ -496,7 +496,8 @@ export const guide: Messages['guide'] = { Otwórz {k.b('Analityka')} w centrum sterowania i włącz ją, aby zobaczyć, kto odwiedza twoją aplikację: ilu ludzi przychodzi każdego dnia, jakie strony otwierają, które strony ich kierują – wyszukiwarka, post, newsletter – z jakich krajów, na jakich urządzeniach i w jakich przeglądarkach oraz które wyszukiwarki i asystenci AI czytają - jej strony. Wybierz liczbę u góry, aby zobaczyć ją dzień po dniu. + jej strony. Wybierz liczbę u góry, aby zobaczyć ją dzień po dniu. Wyświetlenia stron to strony, które ludzie + otworzyli; włącz {k.b('Uwzględnij boty')}, aby liczyć także te wyszukiwarek, robotów AI i innych programów. ), visitors2: (k) => ( diff --git a/src/Frontend/src/locales/pt-pt/editor/analytics.tsx b/src/Frontend/src/locales/pt-pt/editor/analytics.tsx index c08cead..80ac65e 100644 --- a/src/Frontend/src/locales/pt-pt/editor/analytics.tsx +++ b/src/Frontend/src/locales/pt-pt/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Pessoas diferentes que abriram uma página, cada uma contada uma vez por dia. Motores de pesquisa e outros programas não são visitantes.', views: 'Visualizações de páginas', viewsHint: 'Cada vez que alguém abriu uma página.', + bots: 'Incluir bots', + botsHint: 'Conta também as páginas que motores de pesquisa, crawlers de IA e outros programas abriram. Os visitantes são sempre pessoas.', + withBots: 'incl. bots', hits: 'Pedidos', hitsHint: 'Tudo o que foi pedido à tua app: as suas páginas e as imagens e scripts que elas carregam.', versus: (days) => `em relação aos ${days} dias anteriores`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Páginas', pagesHint: 'As páginas que as pessoas abriram, as mais abertas primeiro.', + pagesHintBots: 'As páginas que as pessoas e os bots abriram, as mais abertas primeiro.', requested: 'Mais pedidos', requestedHint: 'Tudo o que foi pedido à tua app, por endereço - ela não tem páginas que as pessoas abram.', sources: 'De onde vêm os visitantes', diff --git a/src/Frontend/src/locales/pt-pt/guide.tsx b/src/Frontend/src/locales/pt-pt/guide.tsx index 1665eaf..f3882a4 100644 --- a/src/Frontend/src/locales/pt-pt/guide.tsx +++ b/src/Frontend/src/locales/pt-pt/guide.tsx @@ -500,7 +500,7 @@ export const guide: Messages['guide'] = { Abre {k.b('Análise de visitas')} no painel de controlo e liga-a para ver quem visita a tua app: quantas pessoas chegam por dia, que páginas abrem, que sites as enviam - um motor de pesquisa, uma publicação, uma newsletter - e de que países, em que dispositivos e browsers, e que motores de pesquisa e assistentes de IA leem as suas páginas. - Escolhe um valor no topo para o ver dia a dia. + Escolhe um valor no topo para o ver dia a dia. As visualizações de página são as páginas que as pessoas abriram; liga {k.b('Incluir bots')} para contar também as de motores de pesquisa, crawlers de IA e outros programas. )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/pt/editor/analytics.tsx b/src/Frontend/src/locales/pt/editor/analytics.tsx index f581f29..c0a44ac 100644 --- a/src/Frontend/src/locales/pt/editor/analytics.tsx +++ b/src/Frontend/src/locales/pt/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Pessoas diferentes que abriram uma página, cada uma contada uma vez por dia. Mecanismos de busca e outros programas não são visitantes.', views: 'Visualizações de página', viewsHint: 'Cada vez que alguém abriu uma página.', + bots: 'Incluir bots', + botsHint: 'Conta também as páginas abertas por mecanismos de busca, crawlers de IA e outros programas. Os visitantes são sempre pessoas.', + withBots: 'incl. bots', hits: 'Requisições', hitsHint: 'Tudo o que o seu app recebeu: as páginas e as imagens e scripts que elas carregam.', versus: (days) => `em relação aos ${days} dias anteriores`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Páginas', pagesHint: 'As páginas que as pessoas abriram, das mais abertas para as menos abertas.', + pagesHintBots: 'As páginas que as pessoas e os bots abriram, das mais abertas para as menos abertas.', requested: 'Mais requisitados', requestedHint: 'Tudo o que o seu app recebeu, por endereço - ele não tem páginas que as pessoas abrem.', sources: 'De onde vêm os visitantes', diff --git a/src/Frontend/src/locales/pt/guide.tsx b/src/Frontend/src/locales/pt/guide.tsx index 78ea392..6253b63 100644 --- a/src/Frontend/src/locales/pt/guide.tsx +++ b/src/Frontend/src/locales/pt/guide.tsx @@ -502,7 +502,9 @@ export const guide: Messages['guide'] = { Abra {k.b('Analytics')} no painel de controle e ative para ver quem visita o seu app: quantas pessoas chegam por dia, quais páginas abrem, quais sites as enviam - um mecanismo de busca, uma postagem, uma newsletter -, de quais países, em quais dispositivos e navegadores, e quais mecanismos de busca e assistentes de IA leem as - páginas dele. Escolha um número no alto para vê-lo dia a dia. + páginas dele. Escolha um número no alto para vê-lo dia a dia. As visualizações de página são as páginas que as + pessoas abriram; ative {k.b('Incluir bots')} para contar também as de mecanismos de busca, crawlers de IA e + outros programas. )), visitors2: ((k) => ( diff --git a/src/Frontend/src/locales/tr/editor/analytics.tsx b/src/Frontend/src/locales/tr/editor/analytics.tsx index f17f1bf..2ddd7bd 100644 --- a/src/Frontend/src/locales/tr/editor/analytics.tsx +++ b/src/Frontend/src/locales/tr/editor/analytics.tsx @@ -27,6 +27,9 @@ export const analytics: EditorMessages['analytics'] = { visitorsHint: 'Bir sayfa açan farklı kişiler, her biri günde bir kez sayılır. Arama motorları ve diğer programlar ziyaretçi sayılmaz.', views: 'Sayfa görüntülemeleri', viewsHint: 'Birinin bir sayfayı her açışı.', + bots: 'Botları dahil et', + botsHint: 'Arama motorlarının, yapay zekâ tarayıcılarının ve diğer programların açtığı sayfaları da say. Ziyaretçiler her durumda yalnızca insanlardır.', + withBots: 'botlar dahil', hits: 'İstekler', hitsHint: 'Uygulamanızdan istenen her şey: sayfaları ve onların yüklediği resimler ile scriptler.', versus: (days) => `önceki ${days} güne göre`, @@ -53,6 +56,7 @@ export const analytics: EditorMessages['analytics'] = { pages: 'Sayfalar', pagesHint: 'İnsanların açtığı sayfalar, en çok açılan başta.', + pagesHintBots: 'İnsanların ve botların açtığı sayfalar, en çok açılan başta.', requested: 'En çok istenenler', requestedHint: 'Uygulamanızdan istenen her şey, adrese göre - insanların açtığı sayfaları yok.', sources: 'Ziyaretçiler nereden geliyor', diff --git a/src/Frontend/src/locales/tr/guide.tsx b/src/Frontend/src/locales/tr/guide.tsx index 7753784..d3f24ee 100644 --- a/src/Frontend/src/locales/tr/guide.tsx +++ b/src/Frontend/src/locales/tr/guide.tsx @@ -504,10 +504,12 @@ export const guide: Messages['guide'] = { visitors: ((k) => ( <> Uygulamanızı kimlerin ziyaret ettiğini görmek için kontrol panelinde {k.b('Analitik')} bölümünü açın ve - açın: her gün kaç kişinin geldiğini, hangi sayfaları açtığını, onları hangi sitelerin - bir arama motoru, bir - paylaşım, bir bülten - yönlendirdiğini, hangi ülkelerden, hangi cihaz ve tarayıcılarla geldiğini ve hangi arama - motorlarının ve yapay zekâ asistanlarının sayfalarını okuduğunu görürsünüz. Gün gün görmek için üstten bir - rakam seçin. + etkinleştirin: her gün kaç kişinin geldiğini, hangi sayfaları açtığını, onları hangi sitelerin - bir arama + motoru, bir paylaşım, bir bülten - yönlendirdiğini, hangi ülkelerden, hangi cihaz ve tarayıcılarla geldiğini ve + hangi arama motorlarının ve yapay zekâ asistanlarının sayfalarını okuduğunu görürsünüz. Gün gün görmek için + üstten bir rakam seçin. Sayfa görüntülemeleri, kişilerin açtığı sayfalardır; arama motorlarının, yapay zekâ + tarayıcılarının ve diğer programların görüntülemelerini de saymak için {k.b('Botları dahil et')} seçeneğini + açın. )), visitors2: ((k) => ( diff --git a/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs b/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs index 22e52de..9a5eac0 100644 --- a/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs +++ b/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs @@ -2052,7 +2052,7 @@ private JsonObject Analytics(JsonObject arguments) analytics = AnalyticsResponse.Of(found), note = found.Shown < found.Kept && found.Earliest is { } earliest && earliest < found.From ? $"Counted by day in UTC, written down every {minutes} minutes. More is kept than this tier shows: the owner sees all of the year with premium." - : $"Counted by day in UTC, written down every {minutes} minutes - the visits of the last minutes are not in it yet. Views are pages opened, hits every request; visitors are people who opened a page, counted once a day." + : $"Counted by day in UTC, written down every {minutes} minutes - the visits of the last minutes are not in it yet. Views are pages opened, by people and programs alike - botViews are those of programs, so people opened views - botViews; hits are every request; visitors are people who opened a page, counted once a day." }); } @@ -2500,7 +2500,7 @@ private JsonObject Guide(string origin) => McpProtocol.Say(new { what = "The owner can have a lambda's visits counted: visitors, pages opened and requests per day, and the pages, referring sites, countries, devices, browsers, operating systems and crawlers behind them. They read it in the editor, and the analytics tool reads the same.", when = "Off until the owner switches it on, in the editor or by asking you (analytics with enable: true). Switching off deletes what was counted, and is theirs to do. A feature's preview is never counted.", - how = "Counted by the platform, in front of the lambda, by day in UTC and written down every few minutes; a year is kept, of which a free lambda shows the last 7 days and a premium one all. Only the host of a referring site is kept, never an address, and a visitor - a person who opened a page, never a program - is told apart for a day by a hash that is never stored. Crawlers are counted by their kind from a maintained list; Google, Bing, OpenAI, Apple and Perplexity by name only where the visit came from an address they publish. The lambda needs no code for it - do not build a counter of your own for what this counts." + how = "Counted by the platform, in front of the lambda, by day in UTC and written down every few minutes; a year is kept, of which a free lambda shows the last 7 days and a premium one all. Only the host of a referring site is kept, never an address, and a visitor - a person who opened a page, never a program - is told apart for a day by a hash that is never stored. Views count people and programs alike, with those of programs as botViews, so people opened views - botViews; the owner sees the people's and may include the bots. Crawlers are counted by their kind from a maintained list; Google, Bing, OpenAI, Apple and Perplexity by name only where the visit came from an address they publish. The lambda needs no code for it - do not build a counter of your own for what this counts." }, afterDeploying = new { diff --git a/src/GenHTTP.Lambda/Api/Model/Analytics.cs b/src/GenHTTP.Lambda/Api/Model/Analytics.cs index 7c9bc78..5c2585d 100644 --- a/src/GenHTTP.Lambda/Api/Model/Analytics.cs +++ b/src/GenHTTP.Lambda/Api/Model/Analytics.cs @@ -62,7 +62,7 @@ public static AnalyticsResponse Of(LambdaAnalytics analytics) var report = analytics.Report; IReadOnlyList? Of(AnalyticsDimension dimension) - => report?.Breakdowns[dimension].Select(v => new AnalyticsValueResponse(v.Value, v.Hits, v.Views)).ToList(); + => report?.Breakdowns[dimension].Select(v => new AnalyticsValueResponse(v.Value, v.Hits, v.Views, v.BotViews)).ToList(); return new AnalyticsResponse( analytics.Since != null, @@ -96,17 +96,30 @@ public static AnalyticsResponse Of(LambdaAnalytics analytics) /// What was counted over some days, summed. /// /// Every request answered - pages, the files they load, calls of an API -/// The requests that were somebody opening a page +/// The requests that were somebody opening a page - people and programs alike +/// +/// Of the views, those of programs rather than people: crawlers, and whatever names no browser. The pages people +/// opened are the views less these. +/// /// Different visitors, each counted once a day - summed over days, one who came back is counted again /// Answered with a server error /// Answered with a client error, not found included /// What the responses carried, where that was known /// The mean time to answer a request -public sealed record AnalyticsTotalsResponse(long Hits, long Views, long Visitors, long Failed, long Rejected, long Bytes, double AverageMillis) +public sealed record AnalyticsTotalsResponse( + long Hits, + long Views, + long BotViews, + long Visitors, + long Failed, + long Rejected, + long Bytes, + double AverageMillis +) { public static AnalyticsTotalsResponse Of(AnalyticsTotals totals) - => new(totals.Hits, totals.Views, totals.Visitors, totals.Failed, totals.Rejected, totals.Bytes, + => new(totals.Hits, totals.Views, totals.BotViews, totals.Visitors, totals.Failed, totals.Rejected, totals.Bytes, totals.Hits > 0 ? Math.Round(totals.Millis / totals.Hits, 2) : 0); } @@ -122,4 +135,5 @@ public sealed record AnalyticsDayResponse(DateOnly Date, AnalyticsTotalsResponse /// The path, the host, the country code or the name; "(other)" gathers what did not fit in a day /// Requests it was part of /// Pages opened it was part of -public sealed record AnalyticsValueResponse(string Value, long Hits, long Views); +/// Of those, the pages programs opened rather than people +public sealed record AnalyticsValueResponse(string Value, long Hits, long Views, long BotViews); diff --git a/src/GenHTTP.Lambda/Services/Analytics/AnalyticsModel.cs b/src/GenHTTP.Lambda/Services/Analytics/AnalyticsModel.cs index 0743cd4..15a176d 100644 --- a/src/GenHTTP.Lambda/Services/Analytics/AnalyticsModel.cs +++ b/src/GenHTTP.Lambda/Services/Analytics/AnalyticsModel.cs @@ -94,6 +94,7 @@ IReadOnlyDictionary> Breakdown /// /// Every request answered - pages, the files they load, calls of an API /// The requests that were somebody opening a page +/// Of the views, those of programs - crawlers, and whatever names no browser - rather than people /// /// Different visitors, each counted once a day: summed over several days, a visitor who came back on another day is /// counted again @@ -102,14 +103,14 @@ IReadOnlyDictionary> Breakdown /// Answered with a client error, not found included /// What the responses carried, where that was known /// The time it took to answer them, all together -public sealed record AnalyticsTotals(long Hits, long Views, long Visitors, long Failed, long Rejected, long Bytes, double Millis) +public sealed record AnalyticsTotals(long Hits, long Views, long BotViews, long Visitors, long Failed, long Rejected, long Bytes, double Millis) { - public static AnalyticsTotals None { get; } = new(0, 0, 0, 0, 0, 0, 0); + public static AnalyticsTotals None { get; } = new(0, 0, 0, 0, 0, 0, 0, 0); public AnalyticsTotals Add(AnalyticsTotals other) - => new(Hits + other.Hits, Views + other.Views, Visitors + other.Visitors, Failed + other.Failed, Rejected + other.Rejected, - Bytes + other.Bytes, Millis + other.Millis); + => new(Hits + other.Hits, Views + other.Views, BotViews + other.BotViews, Visitors + other.Visitors, Failed + other.Failed, + Rejected + other.Rejected, Bytes + other.Bytes, Millis + other.Millis); } @@ -124,7 +125,8 @@ public sealed record AnalyticsDay(DateOnly Date, AnalyticsTotals Totals); /// The path, the host, the country code or the name /// Requests it was part of /// Pages opened it was part of -public sealed record AnalyticsValue(string Value, long Hits, long Views); +/// Of those, the pages programs opened rather than people +public sealed record AnalyticsValue(string Value, long Hits, long Views, long BotViews); /// /// Whether a lambda's visits are counted, and what its owner may see of them. diff --git a/src/GenHTTP.Lambda/Services/Analytics/AnalyticsStore.cs b/src/GenHTTP.Lambda/Services/Analytics/AnalyticsStore.cs index cf54fa6..2073521 100644 --- a/src/GenHTTP.Lambda/Services/Analytics/AnalyticsStore.cs +++ b/src/GenHTTP.Lambda/Services/Analytics/AnalyticsStore.cs @@ -92,19 +92,20 @@ public void Write(VisitTally tally, DateTime now) } using (var days = Command(connection, """ - INSERT INTO days (lambda, year, month, day, hits, views, visitors, failed, rejected, bytes, millis) - VALUES ($lambda, $year, $month, $day, $hits, $views, $visitors, $failed, $rejected, $bytes, $millis) + INSERT INTO days (lambda, year, month, day, hits, views, bot_views, visitors, failed, rejected, bytes, millis) + VALUES ($lambda, $year, $month, $day, $hits, $views, $botViews, $visitors, $failed, $rejected, $bytes, $millis) ON CONFLICT (lambda, year, month, day) DO UPDATE SET - hits = hits + excluded.hits, views = views + excluded.views, visitors = visitors + excluded.visitors, - failed = failed + excluded.failed, rejected = rejected + excluded.rejected, + hits = hits + excluded.hits, views = views + excluded.views, bot_views = bot_views + excluded.bot_views, + visitors = visitors + excluded.visitors, failed = failed + excluded.failed, rejected = rejected + excluded.rejected, bytes = bytes + excluded.bytes, millis = millis + excluded.millis - """, [.. DayKey, "$hits", "$views", "$visitors", "$failed", "$rejected", "$bytes", "$millis"])) + """, [.. DayKey, "$hits", "$views", "$botViews", "$visitors", "$failed", "$rejected", "$bytes", "$millis"])) { foreach (var (lambda, day, totals) in tally.Days) { Day(days, lambda, day); days.Parameters["$hits"].Value = totals.Hits; days.Parameters["$views"].Value = totals.Views; + days.Parameters["$botViews"].Value = totals.BotViews; days.Parameters["$visitors"].Value = totals.Visitors; days.Parameters["$failed"].Value = totals.Failed; days.Parameters["$rejected"].Value = totals.Rejected; @@ -121,11 +122,11 @@ ON CONFLICT (lambda, year, month, day) DO UPDATE SET var most = AnalyticsDimensions.MostOf(group.Key); using var upsert = Command(connection, $""" - INSERT INTO {table} (lambda, year, month, day, value, hits, views) - VALUES ($lambda, $year, $month, $day, $value, $hits, $views) + INSERT INTO {table} (lambda, year, month, day, value, hits, views, bot_views) + VALUES ($lambda, $year, $month, $day, $value, $hits, $views, $botViews) ON CONFLICT (lambda, year, month, day, value) DO UPDATE SET - hits = hits + excluded.hits, views = views + excluded.views - """, [.. DayKey, "$value", "$hits", "$views"]); + hits = hits + excluded.hits, views = views + excluded.views, bot_views = bot_views + excluded.bot_views + """, [.. DayKey, "$value", "$hits", "$views", "$botViews"]); using var known = Command(connection, $"SELECT value FROM {table} WHERE lambda = $lambda AND year = $year AND month = $month AND day = $day", DayKey); @@ -141,7 +142,7 @@ ON CONFLICT (lambda, year, month, day, value) DO UPDATE SET values = Strings(known); } - foreach (var (cell, hits, views) in day.OrderByDescending(c => c.Views).ThenByDescending(c => c.Hits)) + foreach (var (cell, hits, views, botViews) in day.OrderByDescending(c => c.Views).ThenByDescending(c => c.Hits)) { var value = cell.Value; @@ -161,6 +162,7 @@ ON CONFLICT (lambda, year, month, day, value) DO UPDATE SET upsert.Parameters["$value"].Value = value; upsert.Parameters["$hits"].Value = hits; upsert.Parameters["$views"].Value = views; + upsert.Parameters["$botViews"].Value = botViews; upsert.ExecuteNonQuery(); } } @@ -253,7 +255,7 @@ public AnalyticsReport Read(long lambda, DateOnly from, DateOnly to, int most) var counted = new Dictionary(); using (var days = Ranged(connection, lambda, from, to, """ - SELECT year, month, day, hits, views, visitors, failed, rejected, bytes, millis FROM days + SELECT year, month, day, hits, views, bot_views, visitors, failed, rejected, bytes, millis FROM days WHERE lambda = $lambda AND (year, month, day) >= ($fromYear, $fromMonth, $fromDay) AND (year, month, day) <= ($toYear, $toMonth, $toDay) """)) using (var reader = days.ExecuteReader()) @@ -282,7 +284,7 @@ public AnalyticsReport Read(long lambda, DateOnly from, DateOnly to, int most) foreach (var dimension in AnalyticsDimensions.All) { using var values = Ranged(connection, lambda, from, to, $""" - SELECT value, SUM(hits) AS hits, SUM(views) AS views FROM {AnalyticsDimensions.TableOf(dimension)} + SELECT value, SUM(hits) AS hits, SUM(views) AS views, SUM(bot_views) AS bot_views FROM {AnalyticsDimensions.TableOf(dimension)} WHERE lambda = $lambda AND (year, month, day) >= ($fromYear, $fromMonth, $fromDay) AND (year, month, day) <= ($toYear, $toMonth, $toDay) GROUP BY value ORDER BY views DESC, hits DESC, value LIMIT $most """); @@ -295,7 +297,7 @@ public AnalyticsReport Read(long lambda, DateOnly from, DateOnly to, int most) while (reader.Read()) { - listed.Add(new AnalyticsValue(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2))); + listed.Add(new AnalyticsValue(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2), reader.GetInt64(3))); } breakdowns[dimension] = listed; @@ -312,7 +314,7 @@ public AnalyticsTotals Sum(long lambda, DateOnly from, DateOnly to) using var connection = Open(); using var sum = Ranged(connection, lambda, from, to, """ - SELECT IFNULL(SUM(hits), 0), IFNULL(SUM(views), 0), IFNULL(SUM(visitors), 0), IFNULL(SUM(failed), 0), + SELECT IFNULL(SUM(hits), 0), IFNULL(SUM(views), 0), IFNULL(SUM(bot_views), 0), IFNULL(SUM(visitors), 0), IFNULL(SUM(failed), 0), IFNULL(SUM(rejected), 0), IFNULL(SUM(bytes), 0), IFNULL(SUM(millis), 0) FROM days WHERE lambda = $lambda AND (year, month, day) >= ($fromYear, $fromMonth, $fromDay) AND (year, month, day) <= ($toYear, $toMonth, $toDay) """); @@ -424,7 +426,7 @@ private static HashSet Strings(SqliteCommand command) private static AnalyticsTotals Totals(SqliteDataReader reader, int at) => new(reader.GetInt64(at), reader.GetInt64(at + 1), reader.GetInt64(at + 2), reader.GetInt64(at + 3), reader.GetInt64(at + 4), - reader.GetInt64(at + 5), reader.GetDouble(at + 6)); + reader.GetInt64(at + 5), reader.GetInt64(at + 6), reader.GetDouble(at + 7)); #endregion diff --git a/src/GenHTTP.Lambda/Services/Analytics/Migrations/V2__Bot_views.sql b/src/GenHTTP.Lambda/Services/Analytics/Migrations/V2__Bot_views.sql new file mode 100644 index 0000000..c42d954 --- /dev/null +++ b/src/GenHTTP.Lambda/Services/Analytics/Migrations/V2__Bot_views.sql @@ -0,0 +1,30 @@ +-- The pages opened by programs rather than people, counted apart from the +-- views they are part of, so the owner can leave them out. +-- +-- bot_views are the views by anything the classifier did not take for a +-- person: crawlers, and programs that name no browser. What people opened is +-- views - bot_views. +-- +-- What was counted before is filled in where it can be told: a day's totals +-- from its devices, whose bots and others are programs to a visit, and the +-- devices and crawlers themselves. Browsers and systems only ever counted +-- people. The pages, referrers and countries of those days cannot be told +-- apart any more and keep their bots among their people. + +ALTER TABLE days ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pages ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE referrers ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE countries ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE devices ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE browsers ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE systems ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE crawlers ADD COLUMN bot_views INTEGER NOT NULL DEFAULT 0; + +UPDATE devices SET bot_views = views WHERE value IN ('bot', 'other'); + +UPDATE crawlers SET bot_views = views; + +UPDATE days SET bot_views = ( + SELECT IFNULL(SUM(d.views), 0) FROM devices d + WHERE d.lambda = days.lambda AND d.year = days.year AND d.month = days.month AND d.day = days.day AND d.value IN ('bot', 'other') +); diff --git a/src/GenHTTP.Lambda/Services/Analytics/VisitTally.cs b/src/GenHTTP.Lambda/Services/Analytics/VisitTally.cs index 94cb605..f4a9d81 100644 --- a/src/GenHTTP.Lambda/Services/Analytics/VisitTally.cs +++ b/src/GenHTTP.Lambda/Services/Analytics/VisitTally.cs @@ -26,13 +26,14 @@ public sealed class VisitTally(DailyVisitors visitors, VisitorClassifier classif /// public IEnumerable<(long Lambda, DateOnly Day, AnalyticsTotals Totals)> Days => _days.Select(d => (d.Key.Lambda, d.Key.Day, - new AnalyticsTotals(d.Value.Hits, d.Value.Views, d.Value.Visitors, d.Value.Failed, d.Value.Rejected, + new AnalyticsTotals(d.Value.Hits, d.Value.Views, d.Value.BotViews, d.Value.Visitors, d.Value.Failed, d.Value.Rejected, d.Value.Bytes, d.Value.Millis))); /// /// What each value of a dimension came to, per lambda and day. /// - public IEnumerable<(Cell Cell, long Hits, long Views)> Cells => _cells.Select(c => (c.Key, c.Value.Hits, c.Value.Views)); + public IEnumerable<(Cell Cell, long Hits, long Views, long BotViews)> Cells + => _cells.Select(c => (c.Key, c.Value.Hits, c.Value.Views, c.Value.BotViews)); /// /// The lambdas anything was counted for. @@ -59,10 +60,17 @@ public void Add(LambdaVisit visit) _days[(lambda, day)] = totals = new Totals(); } + var agent = classifier.Classify(visit.Agent, visit.Hints, visit.Client); + var view = visit.Page ? 1 : 0; + // a page a program opened - a crawler, or whatever names no browser - + // is a view the owner may leave out + var botView = agent.Browser == null ? view : 0; + totals.Hits++; totals.Views += view; + totals.BotViews += botView; totals.Bytes += visit.Bytes; // an upgraded connection returned at the handshake; what follows is a @@ -82,8 +90,6 @@ public void Add(LambdaVisit visit) break; } - var agent = classifier.Classify(visit.Agent, visit.Hints, visit.Client); - // a visitor is a person who opened a page, counted with the first page // they opened that day - a crawler opens pages, but is nobody, and a // scanner that names a browser guesses paths, but opens none; so there @@ -97,31 +103,31 @@ public void Add(LambdaVisit visit) // are in the totals, and the paths stay the lambda's own if (visit.Status < 400) { - Count(lambda, day, AnalyticsDimension.Pages, visit.Path, view); + Count(lambda, day, AnalyticsDimension.Pages, visit.Path, view, botView); } - Count(lambda, day, AnalyticsDimension.Countries, visit.Country ?? string.Empty, view); + Count(lambda, day, AnalyticsDimension.Countries, visit.Country ?? string.Empty, view, botView); if (ReferrerOf(visit.Referrer, visit.Domain) is { } referrer) { - Count(lambda, day, AnalyticsDimension.Referrers, referrer, view); + Count(lambda, day, AnalyticsDimension.Referrers, referrer, view, botView); } - Count(lambda, day, AnalyticsDimension.Devices, agent.Device, view); + Count(lambda, day, AnalyticsDimension.Devices, agent.Device, view, botView); if (agent.Browser != null) { - Count(lambda, day, AnalyticsDimension.Browsers, agent.Browser, view); + Count(lambda, day, AnalyticsDimension.Browsers, agent.Browser, view, botView); } if (agent.System != null) { - Count(lambda, day, AnalyticsDimension.Systems, agent.System, view); + Count(lambda, day, AnalyticsDimension.Systems, agent.System, view, botView); } if (agent.Crawler != null) { - Count(lambda, day, AnalyticsDimension.Crawlers, agent.Crawler, view); + Count(lambda, day, AnalyticsDimension.Crawlers, agent.Crawler, view, botView); } } @@ -161,7 +167,7 @@ private static string Bare(string host) return host.StartsWith("www.", StringComparison.Ordinal) ? host[4..] : host; } - private void Count(long lambda, DateOnly day, AnalyticsDimension dimension, string value, int view) + private void Count(long lambda, DateOnly day, AnalyticsDimension dimension, string value, int view, int botView) { var cell = new Cell(lambda, day, dimension, value); @@ -172,6 +178,7 @@ private void Count(long lambda, DateOnly day, AnalyticsDimension dimension, stri counts.Hits++; counts.Views += view; + counts.BotViews += botView; } #endregion @@ -187,6 +194,7 @@ private sealed class Totals { internal long Hits; internal long Views; + internal long BotViews; internal long Visitors; internal long Failed; internal long Rejected; @@ -198,6 +206,7 @@ private sealed class Counts { internal long Hits; internal long Views; + internal long BotViews; } #endregion diff --git a/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs b/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs index bc8ef53..b0fb7a0 100644 --- a/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs +++ b/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs @@ -243,6 +243,45 @@ public async Task AVisitorIsSomebodyWhoOpenedAPage() Assert.AreEqual(1, shown.Totals.Visitors, "only the one who opened a page"); } + [TestMethod] + public async Task PagesProgramsOpenedAreCountedApartFromPeoples() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + Place(fixture, "CH"); + + var lambda = await OnlineAsync(fixture, "crawled"); + + await EnableAsync(fixture, lambda.PrivateKey); + + await VisitAsync(fixture, "http://crawled.localhost/", Chrome, destination: "document"); + await VisitAsync(fixture, "http://crawled.localhost/menu", IPhone, destination: "document"); + + // a crawler, and a program that names no browser, both fetching HTML + await VisitAsync(fixture, "http://crawled.localhost/menu", Googlebot); + await VisitAsync(fixture, "http://crawled.localhost/menu", "curl/8.9.1"); + + Flush(fixture); + + var shown = await ReadAsync(fixture, lambda.PrivateKey); + + Assert.AreEqual(4, shown.Totals!.Views, "every page opened"); + Assert.AreEqual(2, shown.Totals.BotViews, "of which programs opened two"); + Assert.AreEqual(2, shown.Daily!.Last().Totals.BotViews); + + var menu = shown.Pages!.Single(p => p.Value == "/menu"); + + Assert.AreEqual(3, menu.Views); + Assert.AreEqual(2, menu.BotViews, "people opened the menu once"); + + Assert.AreEqual(2, shown.Countries!.Single(c => c.Value == "CH").BotViews); + + Assert.AreEqual(2, shown.Devices!.Where(d => d.Value is "bot" or "other").Sum(d => d.BotViews), "what is no person's device is a program's"); + Assert.AreEqual(0, shown.Devices!.Single(d => d.Value == "desktop").BotViews); + + Assert.IsTrue(shown.Browsers!.All(b => b.BotViews == 0), "browsers are people's only"); + } + [TestMethod] public async Task AScannerCannotFillADayWithPaths() {