'GPTBot (OpenAI)', 'oai-searchbot' => 'OAI-SearchBot (OpenAI)', 'chatgpt-user' => 'ChatGPT (na dotaz uživatele)', 'claudebot' => 'ClaudeBot (Anthropic)', 'claude-web' => 'Claude (na dotaz uživatele)', 'anthropic-ai' => 'Anthropic', 'perplexitybot' => 'PerplexityBot', 'perplexity-user' => 'Perplexity (na dotaz uživatele)', 'googleother' => 'GoogleOther', 'googlebot' => 'Googlebot (vyhledávání)', 'bingbot' => 'Bingbot (vyhledávání)', 'applebot-extended' => 'Applebot-Extended', 'applebot' => 'Applebot', 'bytespider' => 'Bytespider (ByteDance)', 'ccbot' => 'CCBot (Common Crawl)', 'meta-externalagent' => 'Meta AI', 'meta-external' => 'Meta', 'amazonbot' => 'Amazonbot', 'cohere' => 'Cohere', 'mistral' => 'Mistral', 'deepseek' => 'DeepSeek', 'diffbot' => 'Diffbot', 'ai2bot' => 'AI2Bot', 'seznambot' => 'SeznamBot (vyhledávání)', 'yandexbot' => 'YandexBot (vyhledávání)', 'ahrefsbot' => 'AhrefsBot (SEO nástroj)', 'semrushbot' => 'SemrushBot (SEO nástroj)', ); private static $jmena_zdroju = array( 'chatgpt.com' => 'ChatGPT', 'chat.openai.com' => 'ChatGPT', 'openai.com' => 'OpenAI', 'perplexity.ai' => 'Perplexity', 'claude.ai' => 'Claude', 'gemini.google.com' => 'Gemini', 'copilot.microsoft.com' => 'Copilot', 'you.com' => 'You.com', 'phind.com' => 'Phind', 'x.com' => 'Grok (X)', ); private static $start; private static $zachytit = false; public static function init() { if (GEO_RESEARCH_VYPNUTO || PHP_SAPI === 'cli') { return; } // Soubor otevřený v prohlížeči přímo = majitel webu si jde pro svoje // čísla. Nezachytává se, nic se neodesílá; jen stránka se statistikou // za heslem, které dostal při stažení. if (self::volano_primo()) { self::stranka_statistik(); return; } self::$start = microtime(true); try { // Ověření domény bez zápisu na disk: když web žene všechny cesty // přes PHP (obvyklé u aplikací, kam se tenhle soubor vkládá), // odpoví na ověřovací adresu rovnou tento include. Zápis souboru // do /.well-known/ zůstává druhou cestou pro weby, kde statické // cesty obsluhuje server sám. self::odpovez_na_vyzvu(); self::$zachytit = self::should_capture( self::server('HTTP_USER_AGENT'), self::server('HTTP_REFERER'), self::server('REQUEST_URI')); } catch (Throwable $e) { self::$zachytit = false; } register_shutdown_function(array(__CLASS__, 'shutdown')); } private static function odpovez_na_vyzvu() { $cesta = (string) parse_url(self::server('REQUEST_URI'), PHP_URL_PATH); if (strpos($cesta, '/.well-known/crawl-obs-challenge-') !== 0) { return; } $ocekavana = self::stav_get('vyzva_cesta'); if ($ocekavana === '' || $cesta !== $ocekavana) { return; } header('Content-Type: text/plain; charset=utf-8'); echo self::stav_get('vyzva_nonce'); exit; } /* ---------------- filtr (zrcadlo pluginu) ---------------- */ public static function ua_je_robot($ua) { if ($ua === '') { return false; } $ua_l = strtolower($ua); foreach (self::$ua_needles as $needle) { if (strpos($ua_l, $needle) !== false) { return true; } } return false; } public static function should_capture($ua, $referer, $uri) { if (self::ua_je_robot($ua)) { return true; } if ($referer !== '') { $ref_l = strtolower($referer); $host = (string) parse_url($ref_l, PHP_URL_HOST); if ($host !== '') { foreach (self::$referer_hosts as $h) { if ($host === $h || substr($host, -(strlen($h) + 1)) === '.' . $h) { return true; } } } foreach (self::$referer_substrings as $s) { if (strpos($ref_l, $s) !== false) { return true; } } } $uri_l = strtolower($uri); if (strpos($uri_l, 'utm_source=') !== false) { foreach (self::$referer_hosts as $h) { if (strpos($uri_l, 'utm_source=' . $h) !== false) { return true; } } foreach (array('chatgpt', 'openai', 'perplexity', 'claude', 'gemini', 'copilot') as $h) { if (strpos($uri_l, 'utm_source=' . $h) !== false) { return true; } } } $path = $uri_l; $q = strpos($path, '?'); if ($q !== false) { $path = substr($path, 0, $q); } if (in_array($path, self::$policy_exact, true) || strpos($path, '/.well-known/') === 0 || (strpos($path, '/sitemap') === 0 && substr($path, -4) === '.xml')) { return true; } return false; } /* ---------------- zachycení a fronta ---------------- */ public static function shutdown() { try { if (self::$zachytit) { self::uloz(self::sestav()); } // Odeslání se zkouší i na nezachycených požadavcích – jinak by // fronta na tichém webu ležela do nekonečna. Návštěvník nečeká: // odpověď je v tuhle chvíli odeslaná (fastcgi_finish_request). if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } self::posli_kdyz_je_cas(); } catch (Throwable $e) { // selhání sběru je přijatelné, selhání webu ne } } private static function sestav() { $uri = self::server('REQUEST_URI'); $q = (string) parse_url($uri, PHP_URL_QUERY); $referer = self::server('HTTP_REFERER'); $ua = self::server('HTTP_USER_AGENT'); $ev = array( 'ts_utc' => gmdate('c'), 'ts_local' => date('Y-m-d\TH:i:s'), 'tz_offset' => date('P'), 'request_uri' => (string) parse_url($uri, PHP_URL_PATH), 'query' => $q !== '' ? $q : null, 'http_method' => self::server('REQUEST_METHOD'), 'http_status' => function_exists('http_response_code') ? (int) http_response_code() : null, 'user_agent' => $ua, 'accept_header' => self::server('HTTP_ACCEPT'), 'accept_encoding' => self::server('HTTP_ACCEPT_ENCODING'), 'accept_language' => self::server('HTTP_ACCEPT_LANGUAGE'), 'referer' => $referer, 'referer_host' => $referer !== '' ? (string) parse_url($referer, PHP_URL_HOST) : '', 'is_policy_file' => in_array(strtolower((string) parse_url($uri, PHP_URL_PATH)), self::$policy_exact, true), 'generation_ms' => (int) round((microtime(true) - self::$start) * 1000), // IP: solený otisk vždy; kus adresy (rozsah) JEN u robotů, // u lidí web žádná část adresy neopustí – stejně jako v pluginu. 'ip_hash' => self::ip_hash(), 'ip_prefix' => self::ua_je_robot($ua) ? self::ip_prefix() : '', ); return array_filter($ev, function ($v) { return $v !== null && $v !== ''; }); } private static function uloz($ev) { $pdo = self::db(); if (!$pdo) { return; } $pdo->prepare('INSERT INTO fronta (ulozeno, telo) VALUES (?, ?)') ->execute(array(time(), json_encode($ev, JSON_UNESCAPED_UNICODE))); // Místní souhrn pro majitele webu. Drží se ZVLÁŠŤ od fronty, protože // ta se po odeslání maže — bez toho by statistika ukazovala jen to, // co zrovna čeká na odeslání, a po každém odeslání spadla na nuly. $robot = self::ua_je_robot(isset($ev['user_agent']) ? $ev['user_agent'] : ''); $typ = $robot ? 'robot' : 'prichod'; $agent = $robot ? self::jmeno_agenta(isset($ev['user_agent']) ? $ev['user_agent'] : '') : self::jmeno_zdroje(isset($ev['referer_host']) ? $ev['referer_host'] : ''); $pdo->prepare('INSERT INTO souhrn (den, typ, agent, pocet) VALUES (?,?,?,1)' . ' ON CONFLICT(den, typ, agent) DO UPDATE SET pocet = pocet + 1') ->execute(array(gmdate('Y-m-d'), $typ, $agent)); } private static function jmeno_agenta($ua) { $l = strtolower($ua); foreach (self::$jmena_agentu as $jehla => $jmeno) { if (strpos($l, $jehla) !== false) { return $jmeno; } } return 'jiný robot'; } private static function jmeno_zdroje($host) { $h = strtolower($host); foreach (self::$jmena_zdroju as $jehla => $jmeno) { if ($h === $jehla || substr($h, -(strlen($jehla) + 1)) === '.' . $jehla) { return $jmeno; } } return $h !== '' ? $h : 'jiné rozhraní'; } private static function db() { static $pdo = null; if ($pdo !== null) { return $pdo; } if (!class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true)) { return $pdo = false; // bez SQLite se sběr tiše vzdá } self::priprav_adresar(); $pdo = new PDO('sqlite:' . GEO_RESEARCH_DATA_DIR . '/fronta.sqlite'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec('CREATE TABLE IF NOT EXISTS fronta' . ' (id INTEGER PRIMARY KEY AUTOINCREMENT, ulozeno INTEGER, telo TEXT)'); $pdo->exec('CREATE TABLE IF NOT EXISTS stav (k TEXT PRIMARY KEY, v TEXT)'); // Souhrn NIKDY nemazat ani nezakládat znovu: jsou to čísla majitele // webu a po výměně souboru musí historie zůstat, ne vypadat jako // čerstvá instalace. $pdo->exec('CREATE TABLE IF NOT EXISTS souhrn (den TEXT, typ TEXT, agent TEXT,' . ' pocet INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (den, typ, agent))'); return $pdo; } private static function priprav_adresar() { $d = GEO_RESEARCH_DATA_DIR; if (!is_dir($d)) { mkdir($d, 0750, true); } // data nesmí být dostupná z webu, i kdyby adresář ležel v docroot if (!file_exists($d . '/.htaccess')) { file_put_contents($d . '/.htaccess', "Require all denied\nDeny from all\n"); } if (!file_exists($d . '/index.html')) { file_put_contents($d . '/index.html', ''); } } /* ---------------- stav (klíč, sůl, časy) ---------------- */ private static function stav_get($k) { $pdo = self::db(); if (!$pdo) { return ''; } $st = $pdo->prepare('SELECT v FROM stav WHERE k = ?'); $st->execute(array($k)); $v = $st->fetchColumn(); return $v === false ? '' : (string) $v; } private static function stav_set($k, $v) { $pdo = self::db(); if ($pdo) { $pdo->prepare('INSERT INTO stav (k, v) VALUES (?, ?)' . ' ON CONFLICT(k) DO UPDATE SET v = excluded.v') ->execute(array($k, $v)); } } private static function sul() { $s = self::stav_get('ip_sul'); if ($s === '') { $s = bin2hex(random_bytes(32)); self::stav_set('ip_sul', $s); } return $s; } private static function ip_hash() { $ip = self::client_ip(); return $ip === '' ? '' : hash_hmac('sha256', $ip, self::sul()); } private static function ip_prefix() { $ip = self::client_ip(); if ($ip === '') { return ''; } if (strpos($ip, ':') !== false) { $c = explode(':', $ip); return implode(':', array_slice($c, 0, 3)) . '::/48'; } $c = explode('.', $ip); return count($c) === 4 ? $c[0] . '.' . $c[1] . '.' . $c[2] . '.0/24' : ''; } private static function client_ip() { foreach (array('HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR') as $h) { $v = self::server($h); if ($v !== '') { $prvni = trim(explode(',', $v)[0]); if (filter_var($prvni, FILTER_VALIDATE_IP)) { return $prvni; } } } return ''; } /* ---------------- klíč: ruční, nebo ověřením domény ---------------- */ private static function klic() { if (GEO_RESEARCH_KEY !== '' && strpos(GEO_RESEARCH_KEY, '.') !== false) { return explode('.', GEO_RESEARCH_KEY, 2); } $id = self::stav_get('site_id'); $key = self::stav_get('site_key'); if ($id !== '' && $key !== '') { return array($id, $key); } return self::zapoj_overenim_domeny(); } /** * Samoobslužné zapojení: požádá o výzvu a nechá si ji ověřit. Výzvu * umí vydat dvěma kanály najednou – odpovědí přímo z PHP (viz * odpovez_na_vyzvu) a zápisem souboru do /.well-known/; server uspěje * s kterýmkoli. Zkouší se nejvýš jednou za hodinu, aby web, kde * neprojde ani jedno, nebušil do serveru. */ private static function zapoj_overenim_domeny() { if (time() - (int) self::stav_get('posledni_pokus') < 3600) { return null; } self::stav_set('posledni_pokus', (string) time()); $domena = strtolower(preg_replace('/^www\./', '', self::server('HTTP_HOST'))); if ($domena === '') { return null; } $vyzva = self::http_json(self::ENDPOINT . '/api/v1/enroll', array( 'domain' => $domena, // hlásí se dopředu, aby registr věděl, že jde o lite skript 'environment' => array('collector' => 'php_generic', 'lite_version' => GEO_RESEARCH_LITE_VERSION), )); if (!$vyzva || empty($vyzva['challenge_path']) || empty($vyzva['challenge_nonce'])) { return null; } // kanál 1: odpoví příští požadavek na ověřovací adresu (přes PHP) self::stav_set('vyzva_cesta', $vyzva['challenge_path']); self::stav_set('vyzva_nonce', $vyzva['challenge_nonce']); // kanál 2: statický soubor, když je docroot zapisovatelný $root = rtrim(self::server('DOCUMENT_ROOT'), '/'); $soubor = ''; if ($root !== '') { $soubor = $root . $vyzva['challenge_path']; $adresar = dirname($soubor); if (is_dir($adresar) || @mkdir($adresar, 0755, true)) { @file_put_contents($soubor, $vyzva['challenge_nonce']); } } try { $klic = self::http_json(self::ENDPOINT . '/api/v1/enroll/confirm', array('enroll_id' => $vyzva['enroll_id'])); } finally { if ($soubor !== '') { @unlink($soubor); // výzva je jednorázová, po sobě uklidit } self::stav_set('vyzva_cesta', ''); self::stav_set('vyzva_nonce', ''); } if (!$klic || empty($klic['site_id']) || empty($klic['site_key'])) { return null; } self::stav_set('site_id', $klic['site_id']); self::stav_set('site_key', $klic['site_key']); return array($klic['site_id'], $klic['site_key']); } /* ---------------- odesílání ---------------- */ private static function posli_kdyz_je_cas() { $pdo = self::db(); if (!$pdo) { return; } $n = (int) $pdo->query('SELECT COUNT(*) FROM fronta')->fetchColumn(); if ($n === 0) { return; } $nejstarsi = (int) $pdo->query('SELECT MIN(ulozeno) FROM fronta')->fetchColumn(); if ($n < self::DAVKA_MAX && time() - $nejstarsi < self::DAVKA_S) { return; } $klic = self::klic(); if (!$klic) { return; // klíč zatím není; fronta počká } list($site_id, $site_key) = $klic; $radky = $pdo->query('SELECT id, telo FROM fronta ORDER BY id LIMIT 200') ->fetchAll(PDO::FETCH_ASSOC); $events = array(); foreach ($radky as $r) { $ev = json_decode($r['telo'], true); if (is_array($ev)) { $events[] = $ev; } } $telo = array( 'batch_id' => bin2hex(random_bytes(16)), 'site_id' => $site_id, 'sent_at' => gmdate('c'), 'plugin_version' => 'lite-' . GEO_RESEARCH_LITE_VERSION, 'filter_version' => self::FILTER_VERSION, 'collector_type' => 'php_generic', 'events' => $events, 'aggregates' => array(), ); if (self::stav_get('env_den') !== gmdate('Y-m-d')) { // otisk prostředí: jednou denně, jen technická fakta o běhu $telo['environment'] = array( 'collector' => 'php_generic', 'lite_version' => GEO_RESEARCH_LITE_VERSION, 'php_version' => PHP_VERSION, 'server_software' => self::server('SERVER_SOFTWARE'), 'behind_cloudflare' => self::server('HTTP_CF_RAY') !== '' || self::server('HTTP_CF_CONNECTING_IP') !== '', ); } $body = json_encode($telo, JSON_UNESCAPED_UNICODE); $ts = (string) time(); $nonce = bin2hex(random_bytes(16)); $mac_key = hash('sha256', $site_key); $sig = hash_hmac('sha256', $ts . '.' . $nonce . '.' . hash('sha256', $body), $mac_key); $odpoved = self::http_post(self::ENDPOINT . '/api/v1/ingest', $body, array( 'Content-Type: application/json', 'X-CO-Site: ' . $site_id, 'X-CO-Timestamp: ' . $ts, 'X-CO-Nonce: ' . $nonce, 'X-CO-Signature: ' . $sig, )); if ($odpoved !== null) { // přijato (nebo trvale odmítnuto) -> ven z fronty $ids = array_map(function ($r) { return (int) $r['id']; }, $radky); $pdo->exec('DELETE FROM fronta WHERE id IN (' . implode(',', $ids) . ')'); if (isset($telo['environment'])) { self::stav_set('env_den', gmdate('Y-m-d')); } } } /* ---------------- HTTP bez závislostí ---------------- */ private static function http_json($url, $data) { $resp = self::http_post($url, json_encode($data), array('Content-Type: application/json')); return $resp === null ? null : json_decode($resp, true); } private static function http_post($url, $body, $headers) { $ctx = stream_context_create(array('http' => array( 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body, 'timeout' => 10, 'ignore_errors' => true, ))); $resp = @file_get_contents($url, false, $ctx); if ($resp === false) { return null; } $stavova = isset($http_response_header[0]) ? $http_response_header[0] : ''; // 2xx = přijato; 4xx = trvale odmítnuto (opakovat nemá smysl); // 5xx/săť = zkusit příště, fronta zůstává. if (preg_match('/ (2|4)\d\d /', ' ' . $stavova . ' ')) { return $resp; } return null; } private static function server($k) { return isset($_SERVER[$k]) ? (string) $_SERVER[$k] : ''; } /* ---------------- stránka se statistikou pro majitele webu ------------ */ private static function volano_primo() { $skript = self::server('SCRIPT_FILENAME'); if ($skript === '') { return false; } $a = @realpath($skript); $b = @realpath(__FILE__); return $a !== false && $b !== false && $a === $b; } private static function stranka_statistik() { if (GEO_RESEARCH_HESLO_HASH === '') { self::html('Statistiky nejsou zapnuté', '
Tenhle soubor byl stažen obecným odkazem, takže k němu nepatří ' . 'heslo. Sběr běží dál; pro přístup ke svým číslům si stáhněte soubor ' . 'znovu na geo.kubicek.ai/spoluprace ' . '— dostanete verzi s vlastním heslem.
'); return; } $heslo = isset($_POST['heslo']) ? (string) $_POST['heslo'] : ''; if ($heslo === '') { self::html('Statistiky sběru', self::formular_hesla('')); return; } // Hrubá síla přes web: po pěti chybách v hodině se přestane odpovídat. $okno = (int) self::stav_get('pokusy_okno'); $pokusu = (int) self::stav_get('pokusy_pocet'); if (time() - $okno > 3600) { $okno = time(); $pokusu = 0; } if ($pokusu >= 5) { self::html('Statistiky sběru', self::formular_hesla('Příliš mnoho pokusů. Zkuste to za hodinu.')); return; } if (!hash_equals(GEO_RESEARCH_HESLO_HASH, hash('sha256', $heslo))) { self::stav_set('pokusy_okno', (string) $okno); self::stav_set('pokusy_pocet', (string) ($pokusu + 1)); self::html('Statistiky sběru', self::formular_hesla('Heslo nesouhlasí.')); return; } self::stav_set('pokusy_pocet', '0'); self::html('Statistiky sběru', self::obsah_statistik($heslo)); } private static function formular_hesla($chyba) { return ($chyba !== '' ? '' . htmlspecialchars($chyba) . '
' : '') . 'Zadejte heslo, které jste dostali při stažení souboru.
' . ''; } private static function obsah_statistik($heslo) { $pdo = self::db(); if (!$pdo) { return 'Úložiště statistik se nepodařilo otevřít.
'; } $od = gmdate('Y-m-d', time() - 30 * 86400); $radky = $pdo->prepare('SELECT typ, agent, SUM(pocet) pocet FROM souhrn' . ' WHERE den >= ? GROUP BY typ, agent ORDER BY pocet DESC'); $radky->execute(array($od)); $roboti = array(); $prichody = array(); foreach ($radky->fetchAll(PDO::FETCH_ASSOC) as $r) { if ($r['typ'] === 'robot') { $roboti[] = $r; } else { $prichody[] = $r; } } $dny = $pdo->prepare('SELECT den, SUM(pocet) pocet FROM souhrn WHERE den >= ?' . ' GROUP BY den ORDER BY den DESC LIMIT 14'); $dny->execute(array($od)); $h = 'Posledních 30 dní. Návštěva robota znamená stažení obsahu — ' . 'jestli se stránka objevila v odpovědi modelu, se z ní poznat nedá.
'; $h .= '| Den | Záznamů |
|---|---|
| ' . htmlspecialchars($d['den']) . ' | ' . (int) $d['pocet'] . ' |
Stav: ' . ($zapojen ? 'web je zapojený' : 'čeká na zapojení') . ' · ve frontě k odeslání: ' . $ceka . ' · verze skriptu: ' . GEO_RESEARCH_LITE_VERSION . '
'; $h .= 'Účast můžete kdykoli ukončit: smažte řádek s require '
. 'a tento soubor. O výzkumu
Zatím nic.
'; } $h = '| ' . $zahlavi . ' | Návštěv |
|---|---|
| ' . htmlspecialchars($r['agent']) . ' | ' . (int) $r['pocet'] . ' |