*/ private const CATEGORIES = [ 'search' => 'Search', 'user_fetch' => 'User fetch', 'training' => 'Training', 'content_use' => 'Content use', 'unknown' => 'Unknown', ]; /** @var array */ private const SELF_IDENTIFICATION = [ 'googlebot' => ['family' => 'Googlebot', 'category' => 'search'], 'bingbot' => ['family' => 'Bingbot', 'category' => 'search'], 'openai-searchbot' => ['family' => 'OpenAI SearchBot', 'category' => 'search'], 'gptbot' => ['family' => 'GPTBot', 'category' => 'training'], 'chatgpt-user' => ['family' => 'ChatGPT-User', 'category' => 'user_fetch'], ]; /** * Accepts only caller-supplied, already memory-resident candidate evidence. * Returned records omit raw request fields by construction. * * @param list|mixed> $candidates * @return array{records: list>, rejected: int, truncated: bool, storageBoundary: string, networkRequests: int, writesPerformed: int} */ public static function normalize(array $candidates, int $site_id): array { $site_id = max(1, $site_id); $records = []; $rejected = 0; foreach (array_slice($candidates, 0, self::MAX_INPUTS) as $candidate) { if (!is_array($candidate) || (int) ($candidate['siteId'] ?? 0) !== $site_id) { ++$rejected; continue; } $evidence_type = sanitize_key((string) ($candidate['evidenceType'] ?? 'unknown')); if (!in_array($evidence_type, ['policy', 'observed', 'manual', 'unknown'], true)) { ++$rejected; continue; } $path = self::normalize_path($candidate['path'] ?? ''); if ($path === null) { ++$rejected; continue; } $signature = sanitize_key((string) ($candidate['agentSignature'] ?? '')); $identified = self::SELF_IDENTIFICATION[$signature] ?? null; $category = sanitize_key((string) ($candidate['category'] ?? 'unknown')); $category = array_key_exists($category, self::CATEGORIES) ? $category : 'unknown'; if ($identified !== null) { $category = $identified['category']; } $provenance = self::provenance($evidence_type, $identified !== null); $record = [ 'siteId' => $site_id, 'path' => $path, 'evidenceType' => $evidence_type, 'category' => $category, 'categoryLabel' => self::CATEGORIES[$category], 'agentFamily' => $identified['family'] ?? 'Unknown agent', 'provenance' => $provenance, 'uncertainty' => self::uncertainty($evidence_type, $identified !== null), 'observations' => self::bounded_positive_int($candidate['observationCount'] ?? 0, 1000000), 'eventId' => self::event_id($candidate['eventId'] ?? ''), 'observedAt' => self::timestamp($candidate['observedAt'] ?? ''), ]; if ($evidence_type !== 'observed') { $record['observations'] = 0; $record['eventId'] = ''; } $records[] = $record; } usort($records, static function (array $left, array $right): int { return [self::type_order((string) $left['evidenceType']), (string) $left['path'], (string) $left['agentFamily'], (string) $left['eventId']] <=> [self::type_order((string) $right['evidenceType']), (string) $right['path'], (string) $right['agentFamily'], (string) $right['eventId']]; }); return [ 'records' => array_slice($records, 0, self::MAX_RECORDS), 'rejected' => $rejected, 'truncated' => count($candidates) > self::MAX_INPUTS || count($records) > self::MAX_RECORDS, 'storageBoundary' => self::STORAGE_BOUNDARY, 'networkRequests' => 0, 'writesPerformed' => 0, ]; } private static function normalize_path(mixed $value): ?string { if (!is_string($value) || $value === '' || strlen($value) > 512 || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { return null; } $path = (string) (wp_parse_url($value, PHP_URL_PATH) ?? ''); if ($path === '' && str_starts_with($value, '/')) { $path = explode('?', explode('#', $value, 2)[0], 2)[0]; } if ($path === '' || !str_starts_with($path, '/') || str_contains($path, '..') || preg_match('/[^\x21-\x7E]/', $path) === 1) { return null; } $path = preg_replace('#/+#', '/', $path) ?? ''; return $path !== '' && strlen($path) <= 256 ? $path : null; } private static function provenance(string $type, bool $identified): string { if ($type === 'policy') return 'local_policy'; if ($type === 'manual') return 'manual_evidence'; if ($type === 'observed' && $identified) return 'self_identified_candidate'; if ($type === 'observed') return 'observed_unknown_agent'; return 'unavailable_or_unknown'; } private static function uncertainty(string $type, bool $identified): string { if ($type === 'policy') return 'Permission is separate from any visit or outcome.'; if ($type === 'manual') return 'Manual evidence is not an observed request.'; if ($type === 'observed' && $identified) return 'Self-identification is unverified and proves no outcome.'; return 'No reliable agent identification or outcome evidence is available.'; } private static function event_id(mixed $value): string { return is_string($value) && preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $value) === 1 ? $value : ''; } private static function timestamp(mixed $value): string { return is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/', $value) === 1 ? substr($value, 0, 40) : ''; } private static function bounded_positive_int(mixed $value, int $limit): int { return is_int($value) || (is_string($value) && ctype_digit($value)) ? min($limit, max(0, (int) $value)) : 0; } private static function type_order(string $type): int { return ['policy' => 0, 'observed' => 1, 'manual' => 2, 'unknown' => 3][$type] ?? 4; } }