- 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
99 lines
3.4 KiB
PHP
99 lines
3.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Libraries;
|
|
|
|
/**
|
|
* "Related tools" recommendation engine.
|
|
*
|
|
* Scoring signals (highest first):
|
|
* 1. Curated related_slugs authored in the registry
|
|
* 2. Same primary input format (mp4 -> mp3 implies mp4 -> wav)
|
|
* 3. Same primary output format (jpg -> png implies webp -> png)
|
|
* 4. Same category, weighted by popularity
|
|
*/
|
|
final class RelatedTools
|
|
{
|
|
public const LIMIT = 8;
|
|
|
|
public function forTool(array $tool, int $limit = self::LIMIT): array
|
|
{
|
|
$finder = service('finder');
|
|
$result = [];
|
|
|
|
// 1) curated picks always lead
|
|
foreach (($tool['related_slugs'] ?? []) as $slug) {
|
|
if ($related = $finder->tool((string) $slug)) {
|
|
$result[$related['slug']] = $related + ['_score' => 1000];
|
|
}
|
|
}
|
|
|
|
foreach ($finder->all() as $candidate) {
|
|
if ($candidate['id'] === $tool['id'] || isset($result[$candidate['slug']])) {
|
|
continue;
|
|
}
|
|
|
|
$score = 0;
|
|
if ($tool['primary_input_format'] !== null && $candidate['primary_input_format'] === $tool['primary_input_format']) {
|
|
$score += 50;
|
|
}
|
|
if ($tool['primary_output_format'] !== null && $candidate['primary_output_format'] === $tool['primary_output_format']) {
|
|
$score += 40;
|
|
}
|
|
if ((int) $candidate['category_id'] === (int) $tool['category_id']) {
|
|
$score += 25;
|
|
}
|
|
// small popularity tiebreaker keeps ordering stable and useful
|
|
$score += min(10, (int) round(((int) $candidate['popularity']) / 500));
|
|
|
|
if ($score >= 25) {
|
|
$candidate['_score'] = $score;
|
|
$result[$candidate['slug']] = $candidate;
|
|
}
|
|
}
|
|
|
|
usort($result, static fn (array $a, array $b): int => $b['_score'] <=> $a['_score']);
|
|
|
|
return array_slice(array_map(static function (array $t): array {
|
|
unset($t['_score']);
|
|
|
|
return $t;
|
|
}, $result), 0, $limit);
|
|
}
|
|
|
|
/**
|
|
* Format-conversion cross links: for a converter like mp4-to-mp3,
|
|
* surface sibling conversions from the same input (mp4-to-wav,
|
|
* mp4-to-webm ...) and to the same output from other inputs.
|
|
*
|
|
* @return list<array>
|
|
*/
|
|
public function conversionsAround(array $tool, int $limit = 6): array
|
|
{
|
|
if ($tool['kind'] !== 'upload' || $tool['operation'] !== 'convert') {
|
|
return [];
|
|
}
|
|
|
|
$finder = service('finder');
|
|
$sameInput = [];
|
|
$sameOutput = [];
|
|
|
|
foreach ($finder->all() as $candidate) {
|
|
if ($candidate['id'] === $tool['id'] || $candidate['operation'] !== 'convert') {
|
|
continue;
|
|
}
|
|
if ($tool['primary_input_format'] !== null && $candidate['primary_input_format'] === $tool['primary_input_format']) {
|
|
$sameInput[] = $candidate;
|
|
} elseif ($tool['primary_output_format'] !== null && $candidate['primary_output_format'] === $tool['primary_output_format']) {
|
|
$sameOutput[] = $candidate;
|
|
}
|
|
}
|
|
|
|
usort($sameInput, static fn ($a, $b) => $b['popularity'] <=> $a['popularity']);
|
|
usort($sameOutput, static fn ($a, $b) => $b['popularity'] <=> $a['popularity']);
|
|
|
|
return array_slice([...$sameInput, ...$sameOutput], 0, $limit);
|
|
}
|
|
}
|