feat(api): add seo analysis endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
71eebc53aa
commit
5eaaa8a01e
8 changed files with 539 additions and 15 deletions
56
src/php/src/Api/Controllers/Api/SeoReportController.php
Normal file
56
src/php/src/Api/Controllers/Api/SeoReportController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
372
src/php/src/Api/Services/SeoReportService.php
Normal file
372
src/php/src/Api/Services/SeoReportService.php
Normal 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).')';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
70
src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php
Normal file
70
src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php
Normal 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', []);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue