feat(api): add seo analysis endpoint

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 07:18:01 +00:00
parent 71eebc53aa
commit 5eaaa8a01e
8 changed files with 539 additions and 15 deletions

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Documentation\Attributes\ApiTag;
use Core\Api\Services\SeoReportService;
use Core\Front\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
/**
* SEO report and analysis controller.
*/
#[ApiTag('SEO', 'SEO report and analysis endpoints')]
class SeoReportController extends Controller
{
use HasApiResponses;
public function __construct(
protected SeoReportService $seoReportService
) {
}
/**
* Analyse a URL and return a technical SEO report.
*
* GET /api/seo/report?url=https://example.com
*/
public function show(Request $request): JsonResponse
{
$validated = $request->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,
]);
}
}

View file

@ -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)
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -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.

View file

@ -0,0 +1,372 @@
<?php
declare(strict_types=1);
namespace Core\Api\Services;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
/**
* SEO report service.
*
* Fetches a page and extracts the most useful technical SEO signals from it.
*/
class SeoReportService
{
/**
* Analyse a URL and return a technical SEO report.
*/
public function analyse(string $url): array
{
try {
$response = Http::withHeaders([
'User-Agent' => 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<string, int>
*/
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<int, array<string, string>>
*/
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 <title> 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).')';
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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</title>
<meta name="description" content="A concise example description for the landing page.">
<link rel="canonical" href="https://example.com/landing-page">
<meta property="og:title" content="Example Product Landing Page">
<meta property="og:description" content="A concise example description for the landing page.">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Example">
<meta name="twitter:card" content="summary_large_image">
</head>
<body>
<h1>Example Product Landing Page</h1>
<h2>Key Features</h2>
</body>
</html>
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', []);
});

View file

@ -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