Files
toolvana/app/Controllers/Admin/SeoAdmin.php
T
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

87 lines
4.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
/**
* SEO quality dashboard: automated audit of the whole indexable surface.
*
* Critical checks (block publishing in ToolsAdmin too):
* unique titles / descriptions, length ranges, H1 present, canonical
* slug hygiene, FAQ presence for content tools, duplicate detection.
*/
final class SeoAdmin extends AdminBase
{
public function index(): string
{
$tools = service('finder')->all();
$guides = model(\App\Models\GuideModel::class)->published(1000);
$issues = [];
$seenTitles = [];
$seenDescs = [];
foreach ($tools as $tool) {
$slug = $tool['slug'];
$title = (string) $tool['seo_title'];
$desc = (string) $tool['seo_description'];
if (trim($title) === '') {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing SEO title'];
} elseif (mb_strlen($title) > 65) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Title longer than 65 chars (' . mb_strlen($title) . ')'];
}
if (trim($desc) === '') {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing meta description'];
} elseif (mb_strlen($desc) < 70 || mb_strlen($desc) > 320) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Description outside 70320 chars (' . mb_strlen($desc) . ')'];
}
if (trim((string) $tool['h1']) === '') {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing H1'];
}
if ($tool['faqs'] === [] && ! in_array($tool['kind'], ['text'], true)) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'No FAQ content'];
}
if ($tool['related_slugs'] === [] && count(service('relatedTools')->forTool($tool, 4)) < 3) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Thin internal linking (<3 related tools)'];
}
if (! preg_match('/^[a-z0-9]+$/', (string) ($tool['primary_input_format'] ?? '')) && $tool['operation'] === 'convert') {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Converter without primary format pair'];
}
$tKey = mb_strtolower($title);
if ($tKey !== '' && isset($seenTitles[$tKey])) {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Duplicate title with /' . $seenTitles[$tKey]];
}
$seenTitles[$tKey] = $slug;
$dKey = mb_substr(mb_strtolower($desc), 0, 120);
if ($dKey !== '' && isset($seenDescs[$dKey])) {
// near-duplicate short descriptions are common across generated converters — flag only exact dupes
if (mb_strtolower((string) $tool['short_description']) === mb_strtolower((string) $desc)) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Description identical to /' . $seenDescs[$dKey]];
}
}
$seenDescs[$dKey] = $slug;
}
foreach ($guides as $g) {
if ((string) $g['excerpt'] === '' || (string) $g['body_md'] === '') {
$issues[] = ['severity' => 'critical', 'page' => '/blog/' . $g['slug'], 'check' => 'Guide missing excerpt/body'];
}
}
usort($issues, static fn ($a, $b) => [$b['severity'], $a['page']] <=> [$a['severity'], $b['page']]);
$criticalCount = count(array_filter($issues, static fn ($i) => $i['severity'] === 'critical'));
return $this->render('seo_index', [
'issues' => $issues,
'criticalCount' => $criticalCount,
'toolCount' => count($tools),
'guideCount' => count($guides),
'sitemapUrl' => base_url('/sitemap.xml'),
]);
}
}