Files
deepseek beaf0e1f37 Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions,
  JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter)
- DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD),
  Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG)
- Security: SSRF guard, MIME validation, rate limits, API-key auth,
  bcrypt admin login, security headers
- Admin panel: dashboard, tools/categories/guides CRUD, SEO audit,
  analytics, job inspector with retry, system health, feature flags
- Docker deployment (nginx + web/api FPM pools + scalable workers),
  PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
2026-08-23 07:10:30 +00:00

94 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* Global tool search with alias support ("yt mp3", "compress mp4",
* "jpg png"). Deterministic scoring, zero dependencies, instant enough
* to run on every keystroke server-side.
*/
final class ToolSearch
{
/** @return list<array> best matches first */
public function find(string $query, int $limit = 10): array
{
$terms = $this->tokenize($query);
if ($terms === []) {
return [];
}
$scored = [];
foreach (service('finder')->all() as $tool) {
$score = $this->scoreTool($tool, $terms);
if ($score > 0) {
$tool['_score'] = $score;
$scored[] = $tool;
}
}
usort($scored, static fn (array $a, array $b): int => [$b['_score'], (int) $b['popularity']] <=> [$a['_score'], (int) $a['popularity']]);
return array_slice(array_map(static function (array $t): array {
unset($t['_score']);
return $t;
}, $scored), 0, $limit);
}
public function best(string $query): ?array
{
return $this->find($query, 1)[0] ?? null;
}
private function scoreTool(array $tool, array $terms): float
{
$name = mb_strtolower($tool['name'] . ' ' . str_replace('-', ' ', $tool['slug']));
$aliases = implode(' ', array_map('mb_strtolower', (array) ($tool['aliases'] ?? [])));
$haystacks = [
'aliases' => $aliases,
'name' => $name,
'formats' => mb_strtolower(implode(' ', [...($tool['input_formats'] ?? []), ...($tool['output_formats'] ?? [])])),
'desc' => mb_strtolower($tool['short_description'] ?? ''),
];
$weight = ['aliases' => 40, 'name' => 30, 'formats' => 20, 'desc' => 8];
$score = 0.0;
foreach ($terms as $term) {
$matched = false;
foreach ($haystacks as $key => $haystack) {
if ($haystack === '') {
continue;
}
if (str_starts_with($haystack . ' ', $term . ' ') || $haystack === $term) {
$score += $weight[$key] * 1.5; // exact word start
$matched = true;
} elseif (str_contains(' ' . $haystack, ' ' . $term)) {
$score += $weight[$key];
$matched = true;
} elseif ($key !== 'desc' && str_contains($term, ' ') === false && str_contains($haystack, $term)) {
$score += $weight[$key] * 0.5; // fuzzy substring
$matched = true;
}
}
if (! $matched) {
return 0; // every term must match somewhere — keeps results relevant
}
}
// popularity tiebreak folded into score for stable ordering
return $score + min(5, ((int) ($tool['popularity'] ?? 0)) / 2000);
}
/** @return list<string> */
private function tokenize(string $query): array
{
$normalized = mb_strtolower(preg_replace('/[^a-z0-9\s]+/iu', ' ', $query) ?? '');
$tokens = preg_split('/\s+/', trim($normalized)) ?: [];
return array_values(array_unique(array_filter($tokens, static fn ($t) => mb_strlen($t) >= 2)));
}
}