From 5eaaa8a01e425d095b942d0ea81809d6155a1599 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:18:01 +0000 Subject: [PATCH] feat(api): add seo analysis endpoint Co-Authored-By: Virgil --- .../Controllers/Api/SeoReportController.php | 56 +++ src/php/src/Api/Routes/api.php | 16 +- src/php/src/Api/Services/ApiUsageService.php | 6 +- src/php/src/Api/Services/SeoReportService.php | 372 ++++++++++++++++++ .../src/Api/Tests/Feature/ApiUsageTest.php | 12 +- .../Feature/EntitlementsEndpointTest.php | 8 +- .../Tests/Feature/SeoReportEndpointTest.php | 70 ++++ src/php/src/Api/config.php | 14 + 8 files changed, 539 insertions(+), 15 deletions(-) create mode 100644 src/php/src/Api/Controllers/Api/SeoReportController.php create mode 100644 src/php/src/Api/Services/SeoReportService.php create mode 100644 src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php diff --git a/src/php/src/Api/Controllers/Api/SeoReportController.php b/src/php/src/Api/Controllers/Api/SeoReportController.php new file mode 100644 index 0000000..cb5fd7a --- /dev/null +++ b/src/php/src/Api/Controllers/Api/SeoReportController.php @@ -0,0 +1,56 @@ +validate([ + 'url' => ['required', 'url'], + ]); + + try { + $report = $this->seoReportService->analyse($validated['url']); + } catch (RuntimeException) { + return $this->errorResponse( + errorCode: 'seo_unavailable', + message: 'Unable to fetch the requested URL.', + meta: [ + 'provider' => 'seo', + ], + status: 502, + ); + } + + return response()->json([ + 'data' => $report, + ]); + } +} diff --git a/src/php/src/Api/Routes/api.php b/src/php/src/Api/Routes/api.php index f130142..39da4f5 100644 --- a/src/php/src/Api/Routes/api.php +++ b/src/php/src/Api/Routes/api.php @@ -4,6 +4,7 @@ declare(strict_types=1); use Core\Api\Controllers\Api\UnifiedPixelController; use Core\Api\Controllers\Api\EntitlementApiController; +use Core\Api\Controllers\Api\SeoReportController; use Core\Api\Controllers\McpApiController; use Core\Api\Middleware\PublicApiCors; use Core\Mcp\Middleware\McpApiKeyAuth; @@ -16,8 +17,7 @@ use Illuminate\Support\Facades\Route; | | Core API routes for cross-cutting concerns. | -| TODO: SeoReportController and EntitlementApiController are planned but not -| yet implemented. Re-add routes when controllers exist. +| SEO, pixel tracking, entitlements, and MCP bridge endpoints. | */ @@ -33,6 +33,18 @@ Route::middleware([PublicApiCors::class, 'api.rate']) ->name('track'); }); +// ───────────────────────────────────────────────────────────────────────────── +// SEO analysis (authenticated) +// ───────────────────────────────────────────────────────────────────────────── + +Route::middleware(['auth.api', 'api.scope.enforce']) + ->prefix('seo') + ->name('api.seo.') + ->group(function () { + Route::get('/report', [SeoReportController::class, 'show']) + ->name('report'); + }); + // ───────────────────────────────────────────────────────────────────────────── // Entitlements (authenticated) // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/php/src/Api/Services/ApiUsageService.php b/src/php/src/Api/Services/ApiUsageService.php index 204f444..e9d241d 100644 --- a/src/php/src/Api/Services/ApiUsageService.php +++ b/src/php/src/Api/Services/ApiUsageService.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Mod\Api\Services; +namespace Core\Api\Services; use Carbon\Carbon; -use Mod\Api\Models\ApiUsage; -use Mod\Api\Models\ApiUsageDaily; +use Core\Api\Models\ApiUsage; +use Core\Api\Models\ApiUsageDaily; /** * API Usage Service - tracks and reports API usage metrics. diff --git a/src/php/src/Api/Services/SeoReportService.php b/src/php/src/Api/Services/SeoReportService.php new file mode 100644 index 0000000..c1cc1d7 --- /dev/null +++ b/src/php/src/Api/Services/SeoReportService.php @@ -0,0 +1,372 @@ + config('app.name', 'Core API').' SEO Reporter/1.0', + 'Accept' => 'text/html,application/xhtml+xml', + ]) + ->timeout((int) config('api.seo.timeout', 10)) + ->get($url); + } catch (Throwable $exception) { + throw new RuntimeException('Unable to fetch the requested URL.', 0, $exception); + } + + $html = (string) $response->body(); + $xpath = $this->loadXPath($html); + + $title = $this->extractSingleText($xpath, '//title'); + $description = $this->extractMetaContent($xpath, 'description'); + $canonical = $this->extractLinkHref($xpath, 'canonical'); + $robots = $this->extractMetaContent($xpath, 'robots'); + $language = $this->extractHtmlAttribute($xpath, 'lang'); + $charset = $this->extractCharset($xpath); + + $openGraph = [ + 'title' => $this->extractMetaContent($xpath, 'og:title', 'property'), + 'description' => $this->extractMetaContent($xpath, 'og:description', 'property'), + 'image' => $this->extractMetaContent($xpath, 'og:image', 'property'), + 'type' => $this->extractMetaContent($xpath, 'og:type', 'property'), + 'site_name' => $this->extractMetaContent($xpath, 'og:site_name', 'property'), + ]; + + $twitterCard = [ + 'card' => $this->extractMetaContent($xpath, 'twitter:card', 'name'), + 'title' => $this->extractMetaContent($xpath, 'twitter:title', 'name'), + 'description' => $this->extractMetaContent($xpath, 'twitter:description', 'name'), + 'image' => $this->extractMetaContent($xpath, 'twitter:image', 'name'), + ]; + + $headings = $this->countHeadings($xpath); + $issues = $this->buildIssues($title, $description, $canonical, $robots, $openGraph, $headings); + + return [ + 'url' => $url, + 'status_code' => $response->status(), + 'content_type' => $response->header('Content-Type'), + 'score' => $this->calculateScore($issues), + 'summary' => [ + 'title' => $title, + 'description' => $description, + 'canonical' => $canonical, + 'robots' => $robots, + 'language' => $language, + 'charset' => $charset, + ], + 'open_graph' => $openGraph, + 'twitter' => $twitterCard, + 'headings' => $headings, + 'issues' => $issues, + 'recommendations' => $this->buildRecommendations($issues), + ]; + } + + /** + * Load an HTML document into an XPath query object. + */ + protected function loadXPath(string $html): DOMXPath + { + $previous = libxml_use_internal_errors(true); + + $document = new DOMDocument(); + $document->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING); + + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + return new DOMXPath($document); + } + + /** + * Extract the first text node matched by an XPath query. + */ + protected function extractSingleText(DOMXPath $xpath, string $query): ?string + { + $nodes = $xpath->query($query); + + if (! $nodes || $nodes->length === 0) { + return null; + } + + $node = $nodes->item(0); + + if (! $node) { + return null; + } + + $value = trim($node->textContent ?? ''); + + return $value !== '' ? $value : null; + } + + /** + * Extract a meta tag content value. + */ + protected function extractMetaContent(DOMXPath $xpath, string $name, string $attribute = 'name'): ?string + { + $query = sprintf('//meta[@%s=%s]/@content', $attribute, $this->quoteForXPath($name)); + $nodes = $xpath->query($query); + + if (! $nodes || $nodes->length === 0) { + return null; + } + + $node = $nodes->item(0); + + if (! $node) { + return null; + } + + $value = trim($node->textContent ?? ''); + + return $value !== '' ? $value : null; + } + + /** + * Extract a link href value. + */ + protected function extractLinkHref(DOMXPath $xpath, string $rel): ?string + { + $query = sprintf('//link[@rel=%s]/@href', $this->quoteForXPath($rel)); + $nodes = $xpath->query($query); + + if (! $nodes || $nodes->length === 0) { + return null; + } + + $node = $nodes->item(0); + + if (! $node) { + return null; + } + + $value = trim($node->textContent ?? ''); + + return $value !== '' ? $value : null; + } + + /** + * Extract the HTML lang attribute. + */ + protected function extractHtmlAttribute(DOMXPath $xpath, string $attribute): ?string + { + $nodes = $xpath->query(sprintf('//html/@%s', $attribute)); + + if (! $nodes || $nodes->length === 0) { + return null; + } + + $node = $nodes->item(0); + + if (! $node) { + return null; + } + + $value = trim($node->textContent ?? ''); + + return $value !== '' ? $value : null; + } + + /** + * Extract a charset declaration. + */ + protected function extractCharset(DOMXPath $xpath): ?string + { + $nodes = $xpath->query('//meta[@charset]/@charset'); + + if ($nodes && $nodes->length > 0) { + $node = $nodes->item(0); + + if ($node) { + $value = trim($node->textContent ?? ''); + + if ($value !== '') { + return $value; + } + } + } + + return $this->extractMetaContent($xpath, 'content-type', 'http-equiv'); + } + + /** + * Count headings by level. + * + * @return array + */ + protected function countHeadings(DOMXPath $xpath): array + { + $counts = []; + + for ($level = 1; $level <= 6; $level++) { + $nodes = $xpath->query('//h'.$level); + $counts['h'.$level] = $nodes ? $nodes->length : 0; + } + + return $counts; + } + + /** + * Build issue list from the extracted SEO data. + * + * @return array> + */ + protected function buildIssues( + ?string $title, + ?string $description, + ?string $canonical, + ?string $robots, + array $openGraph, + array $headings + ): array { + $issues = []; + + if ($title === null) { + $issues[] = $this->issue('missing_title', 'No tag was found.', 'high'); + } elseif (Str::length($title) < 10) { + $issues[] = $this->issue('title_too_short', 'The page title is shorter than 10 characters.', 'medium'); + } elseif (Str::length($title) > 60) { + $issues[] = $this->issue('title_too_long', 'The page title is longer than 60 characters.', 'medium'); + } + + if ($description === null) { + $issues[] = $this->issue('missing_description', 'No meta description was found.', 'high'); + } + + if ($canonical === null) { + $issues[] = $this->issue('missing_canonical', 'No canonical URL was found.', 'medium'); + } + + if (($headings['h1'] ?? 0) === 0) { + $issues[] = $this->issue('missing_h1', 'The page does not contain an H1 heading.', 'high'); + } elseif (($headings['h1'] ?? 0) > 1) { + $issues[] = $this->issue('multiple_h1', 'The page contains multiple H1 headings.', 'medium'); + } + + if (($openGraph['title'] ?? null) === null) { + $issues[] = $this->issue('missing_og_title', 'No Open Graph title was found.', 'low'); + } + + if (($openGraph['description'] ?? null) === null) { + $issues[] = $this->issue('missing_og_description', 'No Open Graph description was found.', 'low'); + } + + if ($robots !== null && Str::contains(Str::lower($robots), ['noindex', 'nofollow'])) { + $issues[] = $this->issue('robots_restricted', 'Robots directives block indexing or following links.', 'high'); + } + + return $issues; + } + + /** + * Convert a list of issues to a report score. + */ + protected function calculateScore(array $issues): int + { + $penalties = [ + 'missing_title' => 20, + 'title_too_short' => 5, + 'title_too_long' => 5, + 'missing_description' => 15, + 'missing_canonical' => 10, + 'missing_h1' => 15, + 'multiple_h1' => 5, + 'missing_og_title' => 5, + 'missing_og_description' => 5, + 'robots_restricted' => 20, + ]; + + $score = 100; + + foreach ($issues as $issue) { + $score -= $penalties[$issue['code']] ?? 0; + } + + return max(0, $score); + } + + /** + * Build recommendations from issues. + * + * @return array<int, string> + */ + protected function buildRecommendations(array $issues): array + { + $recommendations = []; + + foreach ($issues as $issue) { + $recommendations[] = match ($issue['code']) { + 'missing_title' => 'Add a concise page title that describes the page content.', + 'title_too_short' => 'Expand the page title so it is more descriptive.', + 'title_too_long' => 'Shorten the page title to keep it under 60 characters.', + 'missing_description' => 'Add a meta description to improve search snippets.', + 'missing_canonical' => 'Add a canonical URL to prevent duplicate content issues.', + 'missing_h1' => 'Add a single, descriptive H1 heading.', + 'multiple_h1' => 'Reduce the page to a single primary H1 heading.', + 'missing_og_title' => 'Add an Open Graph title for better social sharing.', + 'missing_og_description' => 'Add an Open Graph description for better social sharing.', + 'robots_restricted' => 'Remove noindex or nofollow directives if the page should be indexed.', + default => $issue['message'], + }; + } + + return array_values(array_unique($recommendations)); + } + + /** + * Build an issue record. + * + * @return array{code: string, message: string, severity: string} + */ + protected function issue(string $code, string $message, string $severity): array + { + return [ + 'code' => $code, + 'message' => $message, + 'severity' => $severity, + ]; + } + + /** + * Quote a literal for XPath queries. + */ + protected function quoteForXPath(string $value): string + { + if (! str_contains($value, "'")) { + return "'{$value}'"; + } + + if (! str_contains($value, '"')) { + return '"'.$value.'"'; + } + + $parts = array_map( + fn (string $part) => "'{$part}'", + explode("'", $value) + ); + + return 'concat('.implode(", \"'\", ", $parts).')'; + } +} diff --git a/src/php/src/Api/Tests/Feature/ApiUsageTest.php b/src/php/src/Api/Tests/Feature/ApiUsageTest.php index 20c3f0d..74ae92b 100644 --- a/src/php/src/Api/Tests/Feature/ApiUsageTest.php +++ b/src/php/src/Api/Tests/Feature/ApiUsageTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -use Mod\Api\Models\ApiKey; -use Mod\Api\Models\ApiUsage; -use Mod\Api\Models\ApiUsageDaily; -use Mod\Api\Services\ApiUsageService; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Models\ApiKey; +use Core\Api\Models\ApiUsage; +use Core\Api\Models\ApiUsageDaily; +use Core\Api\Services\ApiUsageService; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); diff --git a/src/php/src/Api/Tests/Feature/EntitlementsEndpointTest.php b/src/php/src/Api/Tests/Feature/EntitlementsEndpointTest.php index 2ce835d..ed8b87f 100644 --- a/src/php/src/Api/Tests/Feature/EntitlementsEndpointTest.php +++ b/src/php/src/Api/Tests/Feature/EntitlementsEndpointTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Mod\Api\Models\ApiKey; -use Mod\Api\Services\ApiUsageService; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Models\ApiKey; +use Core\Api\Services\ApiUsageService; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); diff --git a/src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php b/src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php new file mode 100644 index 0000000..4bcde61 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +use Core\Api\Models\ApiKey; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; +use Illuminate\Support\Facades\Http; + +uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); + +beforeEach(function () { + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'SEO Key', + [ApiKey::SCOPE_READ] + ); + + $this->plainKey = $result['plain_key']; +}); + +it('returns a technical SEO report for a URL', function () { + Http::fake([ + 'https://example.com*' => Http::response(<<<'HTML' +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>Example Product Landing Page + + + + + + + + + + +

Example Product Landing Page

+

Key Features

+ + +HTML, 200, [ + 'Content-Type' => 'text/html; charset=utf-8', + ]), + ]); + + $response = $this->getJson('/api/seo/report?url=https://example.com', [ + 'Authorization' => "Bearer {$this->plainKey}", + ]); + + $response->assertOk(); + $response->assertJsonPath('data.url', 'https://example.com'); + $response->assertJsonPath('data.status_code', 200); + $response->assertJsonPath('data.summary.title', 'Example Product Landing Page'); + $response->assertJsonPath('data.summary.description', 'A concise example description for the landing page.'); + $response->assertJsonPath('data.headings.h1', 1); + $response->assertJsonPath('data.open_graph.site_name', 'Example'); + $response->assertJsonPath('data.score', 100); + $response->assertJsonPath('data.issues', []); +}); diff --git a/src/php/src/Api/config.php b/src/php/src/Api/config.php index 701ee76..220058c 100644 --- a/src/php/src/Api/config.php +++ b/src/php/src/Api/config.php @@ -220,6 +220,20 @@ return [ ], ], + /* + |-------------------------------------------------------------------------- + | SEO Analysis + |-------------------------------------------------------------------------- + | + | Settings for the SEO report and analysis endpoint. + | + */ + + 'seo' => [ + // HTTP timeout when fetching a page for analysis + 'timeout' => env('API_SEO_TIMEOUT', 10), + ], + /* |-------------------------------------------------------------------------- | Pagination