exec(" CREATE TABLE IF NOT EXISTS `visit_sources` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `source` VARCHAR(60) NOT NULL, `medium` VARCHAR(60) NULL, `campaign` VARCHAR(80) NULL, `referrer_host` VARCHAR(120) NULL, `landing_path` VARCHAR(190) NULL, `ip_hash` CHAR(64) NULL, `user_agent` VARCHAR(255) NULL, `is_bot` TINYINT(1) NOT NULL DEFAULT 0, `device_type` VARCHAR(12) NULL, `os` VARCHAR(24) NULL, `browser` VARCHAR(24) NULL, `language` VARCHAR(5) NULL, `country` CHAR(2) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_vs_source` (`source`), KEY `idx_vs_bot` (`is_bot`, `created_at`), KEY `idx_vs_country` (`country`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "); // Conversões numa tabela só, com o nome do formulário como chave, em vez de // uma coluna `source` em cada tabela de destino. Um site com contactos, // aulas experimentais e registos precisaria de três migrações e três sítios // por onde a atribuição se pode perder; assim é uma linha por handler e o // relatório é o mesmo em toda a quinta. $pdo->exec(" CREATE TABLE IF NOT EXISTS `tracking_conversions` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `form` VARCHAR(40) NOT NULL, `source` VARCHAR(60) NOT NULL, `campaign` VARCHAR(80) NULL, `country` CHAR(2) NULL, `ip_hash` CHAR(64) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_tc_form` (`form`, `created_at`), KEY `idx_tc_source` (`source`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "); // Instalações que já tinham a tabela antes de haver país. try { $pdo->exec("ALTER TABLE `visit_sources` ADD COLUMN `country` CHAR(2) NULL AFTER `language`"); $pdo->exec("ALTER TABLE `visit_sources` ADD KEY `idx_vs_country` (`country`)"); } catch (PDOException) { /* já existe */ } } /** * Regista este pedido como visita, quando é o primeiro da sessão. * * Seguro em qualquer página pública: escreve no máximo uma linha por sessão e * nunca lança — a analítica não vale uma página em baixo. */ function platform_track_visit(PDO $pdo): void { try { if (!empty($_SESSION['_visit_tracked'])) { return; } $_SESSION['_visit_tracked'] = 1; platform_tracking_ensure_tables($pdo); $parsed = platform_tracking_parse_request(); $existing = platform_tracking_stored_source(); if ($parsed['explicit'] || $existing === null) { platform_tracking_store_source($pdo, $parsed['source'], $parsed['campaign']); } $ua = substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); $dev = platform_tracking_parse_user_agent($ua); $lang = platform_tracking_parse_language((string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '')); $pdo->prepare(" INSERT INTO visit_sources (source, medium, campaign, referrer_host, landing_path, ip_hash, user_agent, is_bot, device_type, os, browser, language, country) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ")->execute([ $parsed['source'], $parsed['medium'], $parsed['campaign'], $parsed['referrer_host'], $parsed['landing_path'], platform_tracking_ip_hash(), $ua, platform_tracking_is_bot($ua) ? 1 : 0, $dev['device_type'], $dev['os'], $dev['browser'], $lang, geo_ip_country(platform_client_ip()), ]); } catch (\Throwable $e) { error_log('[tracking] ' . $e->getMessage()); } } /** * Carimba uma conversão com a origem que a pessoa trouxe. * * `$form` é o nome do formulário ('contacto', 'trial', 'registo'…). Chamar * depois de a submissão ter sido aceite, nunca antes: uma tentativa recusada * pelo antispam não é uma conversão, e contá-la faria uma campanha parecer * melhor por estar a atrair bots. */ function platform_track_conversion(PDO $pdo, string $form): void { try { platform_tracking_ensure_tables($pdo); $attr = platform_tracking_current_source(); $pdo->prepare(" INSERT INTO tracking_conversions (form, source, campaign, country, ip_hash) VALUES (?, ?, ?, ?, ?) ")->execute([ substr($form, 0, 40), $attr['source'], $attr['campaign'], geo_ip_country(platform_client_ip()), platform_tracking_ip_hash(), ]); } catch (\Throwable $e) { error_log('[tracking] conversão: ' . $e->getMessage()); } } /** * De onde veio este pedido. * * `explicit` diz se o visitante trazia um parâmetro de campanha, que é o que * decide se deve sobrepor-se a uma origem já guardada. */ function platform_tracking_parse_request(): array { $get = fn(string $k): string => trim((string) ($_GET[$k] ?? '')); $source = $get('source') ?: $get('utm_source') ?: $get('ref'); $medium = $get('utm_medium') ?: ($get('source') ? 'campaign' : null); $campaign = $get('utm_campaign') ?: null; $explicit = $source !== ''; $refHost = null; if (!empty($_SERVER['HTTP_REFERER'])) { $host = parse_url((string) $_SERVER['HTTP_REFERER'], PHP_URL_HOST); if ($host) { $refHost = strtolower(preg_replace('/^www\./', '', $host)); // Um referrer do nosso próprio site não é uma origem de aquisição. $base = function_exists('env') ? (string) env('APP_BASE_URL', '') : ''; $self = strtolower((string) parse_url($base, PHP_URL_HOST)); if ($self !== '' && $refHost === preg_replace('/^www\./', '', $self)) { $refHost = null; } // O host actual também conta como próprio: o APP_BASE_URL da quinta // aponta para o domínio de serviço e não para o público, por isso // sozinho deixava passar navegação interna como se fosse referral. $here = strtolower(preg_replace('/^www\./', '', (string) ($_SERVER['HTTP_HOST'] ?? ''))); if ($here !== '' && $refHost === $here) { $refHost = null; } } } if ($source === '') { $source = $refHost ?: 'direct'; $medium = $medium ?: ($refHost ? 'referral' : 'direct'); } return [ 'source' => platform_tracking_normalise($source), 'medium' => $medium ? platform_tracking_normalise($medium, 60) : null, 'campaign' => $campaign ? platform_tracking_normalise($campaign, 80) : null, 'referrer_host' => $refHost ? substr($refHost, 0, 120) : null, 'landing_path' => substr((string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '/'), 0, 190), 'explicit' => $explicit, ]; } /** * Reduz um valor cru a uma chave estável, para "Montra", "montra " e "MONTRA" * não darem três linhas no relatório. */ function platform_tracking_normalise(string $v, int $max = 60): string { $v = trim($v); // Transliterar em vez de remover: tirar os acentos à bruta transformava // "café" em "caf" e "são-joão" em "so-joo", que é exactamente o género de // nome que uma campanha portuguesa usa. if (function_exists('iconv')) { $t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $v); if ($t !== false && $t !== '') $v = $t; } $v = strtolower($v); // O TRANSLIT do iconv pode emitir formas como "c'e" ou "~a", conforme o locale. $v = preg_replace("/['\"^~`]/", '', $v) ?? ''; $v = preg_replace('/[^a-z0-9._\- ]+/', '', $v) ?? ''; $v = preg_replace('/\s+/', '-', trim($v)) ?? ''; $v = trim(preg_replace('/-+/', '-', $v) ?? '', '-'); return substr($v, 0, $max) ?: 'direct'; } /** * Hash salgado e não reversível do visitante. O endereço nunca é guardado. * * Usa platform_client_ip() e não REMOTE_ADDR. Era essa a versão original, e * atrás do NPM o REMOTE_ADDR é sempre o proxy: todos os visitantes ficavam com * o mesmo hash, o que fazia o COUNT(DISTINCT ip_hash) do relatório -- a coluna * "visitantes" -- dar sempre 1. */ function platform_tracking_ip_hash(): ?string { $ip = platform_client_ip(); if ($ip === null || $ip === '') { return null; } $env = fn(string $k) => function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: ''); $salt = $env('ANTISPAM_SECRET') ?: $env('APP_SECRET_KEY') ?: $env('BACKUP_TOKEN') ?: 'am-tracking'; return hash('sha256', $salt . '|' . $ip); } /** * Categorias grosseiras de dispositivo e plataforma a partir do User-Agent. * * A ordem importa em todo o lado: praticamente todos os browsers mentem no * User-Agent por compatibilidade. O Edge e o Opera dizem-se Chrome, o Chrome * diz-se Safari, e qualquer tablet Android diz-se Android. Cada teste tem de * excluir os impostores antes de o mais geral correr. * * Grosseiro de propósito: a pergunta é "desktop ou Android?", não "que versão * de correcção?". Números de versão estreitariam o visitante sem acrescentar * nada à resposta. */ function platform_tracking_parse_user_agent(string $ua): array { $out = ['device_type' => 'other', 'os' => 'Desconhecido', 'browser' => 'Desconhecido']; if ($ua === '') return $out; // O iPadOS 13+ diz-se "Macintosh", por isso o teste do iPad precisa da // pista de toque antes de o do macOS poder ser acreditado. if (preg_match('/iPad/i', $ua) || (preg_match('/Macintosh/i', $ua) && preg_match('/Mobile\/|Touch/i', $ua))) { $out['os'] = 'iPadOS'; $out['device_type'] = 'tablet'; } elseif (preg_match('/iPhone|iPod/i', $ua)) { $out['os'] = 'iOS'; $out['device_type'] = 'mobile'; } elseif (preg_match('/Android/i', $ua)) { $out['os'] = 'Android'; // Os tablets Android omitem "Mobile"; os telemóveis incluem-no. $out['device_type'] = preg_match('/Mobile/i', $ua) ? 'mobile' : 'tablet'; } elseif (preg_match('/Windows Phone/i', $ua)) { $out['os'] = 'Windows Phone'; $out['device_type'] = 'mobile'; } elseif (preg_match('/Windows NT/i', $ua)) { $out['os'] = 'Windows'; $out['device_type'] = 'desktop'; } elseif (preg_match('/CrOS/i', $ua)) { $out['os'] = 'ChromeOS'; $out['device_type'] = 'desktop'; } elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) { $out['os'] = 'macOS'; $out['device_type'] = 'desktop'; } elseif (preg_match('/Linux/i', $ua)) { $out['os'] = 'Linux'; $out['device_type'] = 'desktop'; } // Edge antes do Chrome (Edg/ contém Chrome/), Opera antes do Chrome, Chrome // antes do Safari, e os browsers embutidos antes de todos — quem toca num // link dentro do Instagram está a navegar na webview do Instagram, e contar // isso como Safari esconderia quanto tráfego as redes realmente mandam. if (preg_match('/FBAN|FBAV|FB_IAB/i', $ua)) $out['browser'] = 'Facebook (in-app)'; elseif (preg_match('/Instagram/i', $ua)) $out['browser'] = 'Instagram (in-app)'; elseif (preg_match('/\bEdgA?\//i', $ua)) $out['browser'] = 'Edge'; elseif (preg_match('/OPR\/|Opera/i', $ua)) $out['browser'] = 'Opera'; elseif (preg_match('/SamsungBrowser/i', $ua)) $out['browser'] = 'Samsung Internet'; elseif (preg_match('/Firefox\/|FxiOS/i', $ua)) $out['browser'] = 'Firefox'; elseif (preg_match('/CriOS/i', $ua)) $out['browser'] = 'Chrome'; elseif (preg_match('/Chrome\//i', $ua)) $out['browser'] = 'Chrome'; elseif (preg_match('/Safari\//i', $ua)) $out['browser'] = 'Safari'; return $out; } /** * Idioma principal que o browser pede, em duas letras ('pt', 'en'…). Serve para * distinguir visitantes locais de estrangeiros; nada mais granular é guardado. */ function platform_tracking_parse_language(string $header): ?string { if ($header === '') return null; // "pt-PT,pt;q=0.9,en;q=0.8" → pt if (!preg_match('/^\s*([a-zA-Z]{2})/', $header, $m)) return null; return strtolower($m[1]); } /** Filtro de crawlers, rude mas eficaz — mantém o relatório sobre pessoas. */ function platform_tracking_is_bot(string $ua): bool { if ($ua === '') return true; return (bool) preg_match( '/bot|crawl|spider|slurp|bingpreview|facebookexternalhit|whatsapp|telegram|preview|monitor|curl|wget|python-requests|headless|lighthouse|pingdom|uptime/i', $ua ); } // ─── Guardar o primeiro toque ─────────────────────────────────────────────── function platform_tracking_store_source(PDO $pdo, string $source, ?string $campaign): void { $days = 30; if (function_exists('platform_settings_get')) { $days = max(1, (int) (platform_settings_get($pdo, 'tracking_attribution_days', '30') ?: 30)); } $payload = $source . '|' . ($campaign ?? ''); $_SESSION['_visit_source'] = $payload; // Cookie para quem volta dias depois continuar a ser creditado à campanha // que o trouxe da primeira vez. @setcookie(PLATFORM_TRACKING_COOKIE, $payload, [ 'expires' => time() + ($days * 86400), 'path' => '/', 'secure' => (($_SERVER['HTTPS'] ?? '') !== '' || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'), 'httponly' => true, 'samesite' => 'Lax', ]); } /** "source|campaign" guardado, ou null quando não há nada. */ function platform_tracking_stored_source(): ?string { $raw = $_SESSION['_visit_source'] ?? $_COOKIE[PLATFORM_TRACKING_COOKIE] ?? null; return is_string($raw) && $raw !== '' ? $raw : null; } /** * A origem a creditar a uma conversão, como ['source' => …, 'campaign' => …]. * Cai para o pedido actual, para um formulário submetido logo na primeira * página continuar a ser atribuído. */ function platform_tracking_current_source(): array { $raw = platform_tracking_stored_source(); if ($raw !== null) { [$source, $campaign] = array_pad(explode('|', $raw, 2), 2, ''); return [ 'source' => platform_tracking_normalise($source ?: 'direct'), 'campaign' => $campaign !== '' ? platform_tracking_normalise($campaign, 80) : null, ]; } $p = platform_tracking_parse_request(); return ['source' => $p['source'], 'campaign' => $p['campaign']]; } // ─── Relatórios ───────────────────────────────────────────────────────────── /** * Visitas e conversões por origem, nos últimos N dias. * * As duas metades são contadas numa passagem por tabela e juntas em PHP, e não * com um JOIN — uma visita e uma conversão não têm relação ao nível da linha, * só a chave de origem em comum. */ function platform_tracking_report(PDO $pdo, int $days = 30): array { $days = max(1, $days); $visits = $pdo->prepare(" SELECT source, COUNT(*) AS visits, COUNT(DISTINCT ip_hash) AS visitors, MAX(created_at) AS last_seen FROM visit_sources WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY GROUP BY source "); $visits->execute([$days]); $visits = $visits->fetchAll(PDO::FETCH_ASSOC); $conv = $pdo->prepare(" SELECT source, COUNT(*) AS conversions FROM tracking_conversions WHERE created_at >= NOW() - INTERVAL ? DAY GROUP BY source "); $conv->execute([$days]); $conv = $conv->fetchAll(PDO::FETCH_KEY_PAIR); $rows = []; foreach ($visits as $v) { $src = (string) $v['source']; $rows[$src] = [ 'source' => $src, 'visits' => (int) $v['visits'], 'visitors' => (int) $v['visitors'], 'conversions' => (int) ($conv[$src] ?? 0), 'last_seen' => $v['last_seen'], ]; unset($conv[$src]); } // Conversões cuja origem já não tem visitas na janela — uma pessoa que // chegou há dois meses e só agora submeteu. Continuam a contar. foreach ($conv as $src => $n) { $rows[$src] = ['source' => (string) $src, 'visits' => 0, 'visitors' => 0, 'conversions' => (int) $n, 'last_seen' => null]; } uasort($rows, fn($a, $b) => [$b['conversions'], $b['visits']] <=> [$a['conversions'], $a['visits']]); return array_values($rows); } /** Contagem por uma coluna qualquer de visit_sources (device_type, os, country…). */ function platform_tracking_breakdown(PDO $pdo, string $column, int $days = 30, int $limit = 12): array { $allowed = ['device_type', 'os', 'browser', 'language', 'country', 'referrer_host']; if (!in_array($column, $allowed, true)) { return []; // o nome entra numa query -- só os da lista } $st = $pdo->prepare(" SELECT COALESCE(NULLIF(`$column`, ''), '—') AS label, COUNT(*) AS visits, COUNT(DISTINCT ip_hash) AS visitors FROM visit_sources WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY GROUP BY label ORDER BY visits DESC LIMIT " . max(1, min(100, $limit)) ); $st->execute([$days]); return $st->fetchAll(PDO::FETCH_ASSOC); } /** Conversões por formulário, nos últimos N dias. */ function platform_tracking_conversions_by_form(PDO $pdo, int $days = 30): array { $st = $pdo->prepare(" SELECT form, COUNT(*) AS n, MAX(created_at) AS last_seen FROM tracking_conversions WHERE created_at >= NOW() - INTERVAL ? DAY GROUP BY form ORDER BY n DESC "); $st->execute([$days]); return $st->fetchAll(PDO::FETCH_ASSOC); }