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
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
<?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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user