Skip to content

Commit 5b65b8f

Browse files
MTSistemiclaude
andcommitted
analytics: add visitor country (server-side GeoIP) and full referrer URL
pulse.php now records GEOIP_COUNTRY_CODE/NAME (OVH mod_geoip, IP stays on the host) and the full external referrer URL. stats.php gains a Countries panel (with flag), a full-referrer-links panel, and country + clickable source link in recent visits. Schema migrates existing DBs via ALTER TABLE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8ec82e8 commit 5b65b8f

3 files changed

Lines changed: 88 additions & 10 deletions

File tree

website/public/_sfstats.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ function sfstats_db() {
3939
ts INTEGER NOT NULL, day TEXT NOT NULL,
4040
path TEXT NOT NULL, ref TEXT NOT NULL DEFAULT "",
4141
vis TEXT NOT NULL, bot INTEGER NOT NULL DEFAULT 0,
42-
browser TEXT NOT NULL DEFAULT "", os TEXT NOT NULL DEFAULT "")');
42+
browser TEXT NOT NULL DEFAULT "", os TEXT NOT NULL DEFAULT "",
43+
country TEXT NOT NULL DEFAULT "", cname TEXT NOT NULL DEFAULT "",
44+
ref_full TEXT NOT NULL DEFAULT "")');
45+
// migrate older databases that predate the new columns
46+
foreach (array('country','cname','ref_full') as $col) {
47+
try { $db->exec('ALTER TABLE hits ADD COLUMN ' . $col . ' TEXT NOT NULL DEFAULT ""'); }
48+
catch (Throwable $e) { /* column already exists */ }
49+
}
4350
$db->exec('CREATE INDEX IF NOT EXISTS idx_day ON hits(day)');
4451
$db->exec('CREATE INDEX IF NOT EXISTS idx_vis ON hits(vis)');
4552
return $db;
@@ -85,6 +92,28 @@ function sfstats_browser($ua) {
8592
return 'Altro';
8693
}
8794

95+
// Country from OVH/Apache mod_geoip (server-side, IP never leaves the host).
96+
function sfstats_country() {
97+
$code = strtoupper(substr((string)($_SERVER['GEOIP_COUNTRY_CODE'] ?? ''), 0, 2));
98+
$name = (string)($_SERVER['GEOIP_COUNTRY_NAME'] ?? '');
99+
if ($code && !$name) $name = $code;
100+
return array($code, substr($name, 0, 60));
101+
}
102+
103+
// Two-letter country code -> flag emoji (regional indicators), with fallback.
104+
function sfstats_flag($code) {
105+
$code = strtoupper((string)$code);
106+
if (strlen($code) !== 2 || !ctype_alpha($code)) return '🏳️';
107+
$a = function_exists('mb_chr') ? 'mb_chr' : null;
108+
$f = '';
109+
for ($i = 0; $i < 2; $i++) {
110+
$cp = 0x1F1E6 + (ord($code[$i]) - 65);
111+
$f .= $a ? mb_chr($cp, 'UTF-8')
112+
: html_entity_decode('&#' . $cp . ';', ENT_QUOTES, 'UTF-8');
113+
}
114+
return $f;
115+
}
116+
88117
function sfstats_os($ua) {
89118
if (preg_match('/Android/i', $ua)) return 'Android';
90119
if (preg_match('/iPhone|iPad|iOS/i', $ua)) return 'iOS';

website/public/pulse.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,25 @@
1717

1818
// Keep only the path part of whatever was sent; drop query strings.
1919
$path = parse_url($path, PHP_URL_PATH) ?: '/';
20-
// Referrer: store host only (privacy + tidy aggregation), and ignore self-refs.
20+
// Referrer: keep the host (for aggregation) and, for EXTERNAL referrers, the
21+
// full URL too. Self-referrals are ignored.
2122
$ref_host = '';
23+
$ref_full = '';
2224
if ($ref) {
2325
$h = parse_url($ref, PHP_URL_HOST) ?: '';
24-
if ($h && !preg_match('/skillfishos\.com$/i', $h)) $ref_host = strtolower($h);
26+
$scheme = strtolower((string)(parse_url($ref, PHP_URL_SCHEME) ?: ''));
27+
if ($h && !preg_match('/skillfishos\.com$/i', $h)) {
28+
$ref_host = strtolower($h);
29+
if ($scheme === 'http' || $scheme === 'https') $ref_full = $ref; // already capped to 300
30+
}
2531
}
2632

33+
list($country, $cname) = sfstats_country();
34+
2735
try {
2836
$db = sfstats_db();
29-
$st = $db->prepare('INSERT INTO hits(ts,day,path,ref,vis,bot,browser,os)
30-
VALUES(:ts,:day,:path,:ref,:vis,:bot,:br,:os)');
37+
$st = $db->prepare('INSERT INTO hits(ts,day,path,ref,vis,bot,browser,os,country,cname,ref_full)
38+
VALUES(:ts,:day,:path,:ref,:vis,:bot,:br,:os,:cc,:cn,:rf)');
3139
$st->execute(array(
3240
':ts' => time(),
3341
':day' => gmdate('Y-m-d'),
@@ -37,6 +45,9 @@
3745
':bot' => sfstats_is_bot($ua),
3846
':br' => sfstats_browser($ua),
3947
':os' => sfstats_os($ua),
48+
':cc' => $country,
49+
':cn' => $cname,
50+
':rf' => $ref_full,
4051
));
4152
} catch (Throwable $e) {
4253
// never break a page load because of analytics

website/public/stats.php

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,14 @@ function toprows($db, $col, $where, $limit = 8) {
164164
$top_refs = toprows($db, 'ref', "$botw AND day>='$d30' AND ref<>''");
165165
$browsers = toprows($db, 'browser', "$botw AND day>='$d30'", 6);
166166
$oses = toprows($db, 'os', "$botw AND day>='$d30'", 6);
167-
$recent = $db->query("SELECT ts,path,ref,browser,os,bot FROM hits ORDER BY id DESC LIMIT 15")->fetchAll(PDO::FETCH_ASSOC);
167+
$countries = $db->query("SELECT country, MAX(cname) cname, COUNT(*) c FROM hits
168+
WHERE 1=1 $botw AND day>='$d30' AND country<>''
169+
GROUP BY country ORDER BY c DESC LIMIT 15")->fetchAll(PDO::FETCH_ASSOC);
170+
$top_links = $db->query("SELECT ref_full k, COUNT(*) c FROM hits
171+
WHERE 1=1 $botw AND day>='$d30' AND ref_full<>''
172+
GROUP BY ref_full ORDER BY c DESC LIMIT 12")->fetchAll(PDO::FETCH_ASSOC);
173+
$recent = $db->query("SELECT ts,path,ref,ref_full,browser,os,bot,country,cname
174+
FROM hits ORDER BY id DESC LIMIT 20")->fetchAll(PDO::FETCH_ASSOC);
168175

169176
page_head('SkillFishOS · Statistiche');
170177
echo '<div class="wrap">';
@@ -209,21 +216,52 @@ function tbl($title, $rows, $klabel, $linkpath = false) {
209216
}
210217
echo '</table></div>';
211218
}
219+
// countries
220+
echo '<div class="panel"><h3>Paesi di origine (30g)</h3><table><tr><th>Paese</th><th class="r">Visite</th></tr>';
221+
if (!$countries) echo '<tr><td class="muted" colspan="2">Nessun dato ancora.</td></tr>';
222+
$cmax = 1; foreach ($countries as $r) $cmax = max($cmax, (int)$r['c']);
223+
foreach ($countries as $r) {
224+
$label = sfstats_flag($r['country']) . ' ' . h($r['cname'] ?: $r['country']);
225+
$pct = round(100 * $r['c'] / $cmax);
226+
echo '<tr><td>' . $label . '<div class="bar" style="width:' . $pct . '%;margin-top:4px"></div></td><td class="r">' . (int)$r['c'] . '</td></tr>';
227+
}
228+
echo '</table></div>';
229+
212230
echo '<div class="grid2"><div>';
213231
tbl('Pagine più viste (30g)', $top_pages, 'Pagina', true);
214-
tbl('Provenienza (referrer, 30g)', $top_refs, 'Sito');
232+
tbl('Provenienza (domini, 30g)', $top_refs, 'Sito');
215233
echo '</div><div>';
216234
tbl('Browser (30g)', $browsers, 'Browser');
217235
tbl('Sistema operativo (30g)', $oses, 'OS');
218236
echo '</div></div>';
219237

238+
// full referrer links
239+
echo '<div class="panel"><h3>Link di provenienza completi (30g)</h3><table><tr><th>URL referrer</th><th class="r">Visite</th></tr>';
240+
if (!$top_links) echo '<tr><td class="muted" colspan="2">Nessun link esterno registrato.</td></tr>';
241+
$lmax = 1; foreach ($top_links as $r) $lmax = max($lmax, (int)$r['c']);
242+
foreach ($top_links as $r) {
243+
$u = $r['k'];
244+
$pct = round(100 * $r['c'] / $lmax);
245+
echo '<tr><td><a href="' . h($u) . '" target="_blank" rel="noreferrer noopener nofollow">' . h($u) . '</a>'
246+
. '<div class="bar" style="width:' . $pct . '%;margin-top:4px"></div></td><td class="r">' . (int)$r['c'] . '</td></tr>';
247+
}
248+
echo '</table></div>';
249+
220250
// recent
221-
echo '<div class="panel"><h3>Ultime visite</h3><table><tr><th>Quando (UTC)</th><th>Pagina</th><th>Da</th><th>Client</th></tr>';
222-
if (!$recent) echo '<tr><td class="muted" colspan="4">Nessuna visita registrata.</td></tr>';
251+
echo '<div class="panel"><h3>Ultime visite</h3><table><tr><th>Quando (UTC)</th><th>Paese</th><th>Pagina</th><th>Da</th><th>Client</th></tr>';
252+
if (!$recent) echo '<tr><td class="muted" colspan="5">Nessuna visita registrata.</td></tr>';
223253
foreach ($recent as $r) {
254+
$cc = $r['country'] ?? '';
255+
$country = $cc ? sfstats_flag($cc) . ' ' . h($cc) : '<span class="muted">—</span>';
256+
if (!empty($r['ref_full'])) {
257+
$da = '<a href="' . h($r['ref_full']) . '" target="_blank" rel="noreferrer noopener nofollow">' . h($r['ref'] ?: $r['ref_full']) . '</a>';
258+
} else {
259+
$da = '<span class="muted">' . h($r['ref'] ?: '') . '</span>';
260+
}
224261
echo '<tr><td class="muted">' . h(gmdate('d/m H:i', (int)$r['ts'])) . ($r['bot'] ? ' 🤖' : '') . '</td>'
262+
. '<td>' . $country . '</td>'
225263
. '<td>' . h($r['path']) . '</td>'
226-
. '<td class="muted">' . h($r['ref'] ?: '') . '</td>'
264+
. '<td>' . $da . '</td>'
227265
. '<td class="muted">' . h($r['browser'] . ' / ' . $r['os']) . '</td></tr>';
228266
}
229267
echo '</table></div>';

0 commit comments

Comments
 (0)