lthn.io/app/Core/Seo/Analytics/SeoScoreTrend.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

595 lines
17 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Seo\Analytics;
use Core\Seo\Models\SeoScoreHistory;
use Core\Seo\SeoMetadata;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
/**
* SEO Score Trend Tracking Service.
*
* Tracks SEO scores over time to show improvement or regression trends.
* Provides methods for recording scores, querying trends, and generating
* analytics reports.
*
* Features:
* - Automatic score recording from SeoMetadata models
* - Daily and weekly aggregation for trend analysis
* - Score improvement/regression detection
* - Site-wide score statistics
* - Configurable retention policy
*
* Configuration in config/seo.php:
* 'trends' => [
* 'enabled' => true,
* 'retention_days' => 90,
* 'record_on_save' => true,
* 'min_interval_hours' => 24,
* ]
*
* Usage:
* $trend = new SeoScoreTrend();
* $trend->recordScore($seoMetadata);
* $history = $trend->getHistory($seoMetadata);
* $stats = $trend->getSiteStats();
*/
class SeoScoreTrend
{
/**
* Table name for score history.
*/
protected const TABLE = 'seo_score_history';
/**
* Whether trend tracking is enabled.
*/
protected bool $enabled;
/**
* Minimum interval between recordings (hours).
*/
protected int $minIntervalHours;
/**
* Whether the history table exists (cached).
*/
protected ?bool $tableExists = null;
public function __construct()
{
$this->enabled = config('seo.trends.enabled', true);
$this->minIntervalHours = config('seo.trends.min_interval_hours', 24);
}
/**
* Check if trend tracking is enabled and table exists.
*/
public function isEnabled(): bool
{
if (! $this->enabled) {
return false;
}
return $this->tableExists();
}
/**
* Record the current score for an SEO metadata record.
*
* @param SeoMetadata $metadata The SEO metadata to record
* @param bool $force Force recording even if within min interval
* @return SeoScoreHistory|null The created record or null if skipped
*/
public function recordScore(SeoMetadata $metadata, bool $force = false): ?SeoScoreHistory
{
if (! $this->isEnabled()) {
return null;
}
// Skip if score is null
if ($metadata->seo_score === null) {
return null;
}
// Check if we should skip due to minimum interval
if (! $force && ! $this->shouldRecord($metadata)) {
return null;
}
try {
return SeoScoreHistory::create([
'seoable_type' => $metadata->seoable_type,
'seoable_id' => $metadata->seoable_id,
'seo_metadata_id' => $metadata->id,
'score' => $metadata->seo_score,
'issues' => $metadata->seo_issues,
'suggestions' => $metadata->seo_suggestions,
'snapshot' => $this->createSnapshot($metadata),
'recorded_at' => now(),
]);
} catch (\Exception $e) {
Log::warning('Failed to record SEO score history', [
'metadata_id' => $metadata->id,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Record scores for multiple metadata records.
*
* @param Collection<int, SeoMetadata> $metadataRecords
* @return int Number of records created
*/
public function recordScores(Collection $metadataRecords): int
{
$count = 0;
foreach ($metadataRecords as $metadata) {
if ($this->recordScore($metadata) !== null) {
$count++;
}
}
return $count;
}
/**
* Record scores for all metadata with scores.
*
* Useful for periodic batch recording (e.g., daily cron job).
*
* @return int Number of records created
*/
public function recordAllScores(): int
{
if (! $this->isEnabled()) {
return 0;
}
$metadata = SeoMetadata::whereNotNull('seo_score')->get();
return $this->recordScores($metadata);
}
/**
* Check if we should record based on minimum interval.
*/
protected function shouldRecord(SeoMetadata $metadata): bool
{
if ($this->minIntervalHours <= 0) {
return true;
}
$latest = SeoScoreHistory::latestForModel(
$metadata->seoable_type,
$metadata->seoable_id
);
if ($latest === null) {
return true;
}
return $latest->recorded_at->addHours($this->minIntervalHours)->isPast();
}
/**
* Create a snapshot of the metadata for historical reference.
*
* @return array<string, mixed>
*/
protected function createSnapshot(SeoMetadata $metadata): array
{
return [
'title' => $metadata->title,
'description' => $metadata->description,
'canonical_url' => $metadata->canonical_url,
'focus_keyword' => $metadata->focus_keyword,
'robots' => $metadata->robots,
'has_og_image' => ! empty($metadata->og_data['image'] ?? null),
'has_schema' => $metadata->hasSchemaMarkup(),
];
}
/**
* Get score history for a model.
*
* @param SeoMetadata|object $model The model or metadata
* @param int $limit Maximum records to return
* @return Collection<int, SeoScoreHistory>
*/
public function getHistory(object $model, int $limit = 100): Collection
{
if (! $this->isEnabled()) {
return collect();
}
if ($model instanceof SeoMetadata) {
return SeoScoreHistory::forModel(
$model->seoable_type,
$model->seoable_id,
$limit
);
}
// Assume it's a seoable model
return SeoScoreHistory::forModel(
get_class($model),
$model->getKey(),
$limit
);
}
/**
* Get daily score trend for a model.
*
* @param SeoMetadata|object $model The model or metadata
* @param int $days Days to look back
* @return Collection<int, object>
*/
public function getDailyTrend(object $model, int $days = 30): Collection
{
if (! $this->isEnabled()) {
return collect();
}
if ($model instanceof SeoMetadata) {
return SeoScoreHistory::dailyAggregateForModel(
$model->seoable_type,
$model->seoable_id,
$days
);
}
return SeoScoreHistory::dailyAggregateForModel(
get_class($model),
$model->getKey(),
$days
);
}
/**
* Get weekly score trend for a model.
*
* @param SeoMetadata|object $model The model or metadata
* @param int $weeks Weeks to look back
* @return Collection<int, object>
*/
public function getWeeklyTrend(object $model, int $weeks = 12): Collection
{
if (! $this->isEnabled()) {
return collect();
}
if ($model instanceof SeoMetadata) {
return SeoScoreHistory::weeklyAggregateForModel(
$model->seoable_type,
$model->seoable_id,
$weeks
);
}
return SeoScoreHistory::weeklyAggregateForModel(
get_class($model),
$model->getKey(),
$weeks
);
}
/**
* Get site-wide SEO statistics.
*
* @param int $days Days to look back
* @return array{
* current_avg: float,
* previous_avg: float,
* change: float,
* change_percent: float,
* total_pages: int,
* improving: int,
* declining: int,
* stable: int
* }
*/
public function getSiteStats(int $days = 7): array
{
if (! $this->isEnabled()) {
return $this->emptyStats();
}
$cacheKey = 'seo_trend:site_stats:'.$days;
return Cache::remember($cacheKey, 300, function () use ($days) {
$recentPeriod = now()->subDays($days);
$previousPeriod = now()->subDays($days * 2);
// Current average
$currentAvg = (float) DB::table(self::TABLE)
->where('recorded_at', '>=', $recentPeriod)
->avg('score') ?? 0;
// Previous average
$previousAvg = (float) DB::table(self::TABLE)
->whereBetween('recorded_at', [$previousPeriod, $recentPeriod])
->avg('score') ?? 0;
$change = $currentAvg - $previousAvg;
$changePercent = $previousAvg > 0 ? ($change / $previousAvg) * 100 : 0;
// Get improving/declining/stable counts
$improving = SeoScoreHistory::getImprovingModels($days);
$declining = SeoScoreHistory::getDecliningModels($days);
// Total unique pages tracked
$totalPages = DB::table(self::TABLE)
->where('recorded_at', '>=', $recentPeriod)
->distinct()
->count(DB::raw('CONCAT(seoable_type, seoable_id)'));
$stableCount = max(0, $totalPages - $improving->count() - $declining->count());
return [
'current_avg' => round($currentAvg, 1),
'previous_avg' => round($previousAvg, 1),
'change' => round($change, 1),
'change_percent' => round($changePercent, 1),
'total_pages' => $totalPages,
'improving' => $improving->count(),
'declining' => $declining->count(),
'stable' => $stableCount,
];
});
}
/**
* Get score distribution statistics.
*
* @param int $days Days to look back
* @return array{
* excellent: int,
* good: int,
* fair: int,
* poor: int,
* distribution: array<string, int>
* }
*/
public function getScoreDistribution(int $days = 7): array
{
if (! $this->isEnabled()) {
return [
'excellent' => 0,
'good' => 0,
'fair' => 0,
'poor' => 0,
'distribution' => [],
];
}
$cacheKey = 'seo_trend:distribution:'.$days;
return Cache::remember($cacheKey, 300, function () use ($days) {
// Get the latest score for each unique model
$latestScores = DB::table(self::TABLE.' as h1')
->select('h1.seoable_type', 'h1.seoable_id', 'h1.score')
->where('h1.recorded_at', '>=', now()->subDays($days))
->whereRaw('h1.recorded_at = (SELECT MAX(h2.recorded_at) FROM '.self::TABLE.' as h2 WHERE h2.seoable_type = h1.seoable_type AND h2.seoable_id = h1.seoable_id)')
->get();
$excellent = $latestScores->filter(fn ($r) => $r->score >= 80)->count();
$good = $latestScores->filter(fn ($r) => $r->score >= 60 && $r->score < 80)->count();
$fair = $latestScores->filter(fn ($r) => $r->score >= 40 && $r->score < 60)->count();
$poor = $latestScores->filter(fn ($r) => $r->score < 40)->count();
// 10-point bucket distribution
$distribution = [];
for ($i = 0; $i <= 90; $i += 10) {
$label = $i.'-'.($i + 9);
$distribution[$label] = $latestScores->filter(
fn ($r) => $r->score >= $i && $r->score < $i + 10
)->count();
}
$distribution['100'] = $latestScores->filter(fn ($r) => $r->score === 100)->count();
return [
'excellent' => $excellent,
'good' => $good,
'fair' => $fair,
'poor' => $poor,
'distribution' => $distribution,
];
});
}
/**
* Get top improving pages.
*
* @param int $limit Maximum pages to return
* @param int $days Days in comparison period
* @return Collection<int, object>
*/
public function getTopImproving(int $limit = 10, int $days = 7): Collection
{
if (! $this->isEnabled()) {
return collect();
}
return SeoScoreHistory::getImprovingModels($days)->take($limit);
}
/**
* Get top declining pages.
*
* @param int $limit Maximum pages to return
* @param int $days Days in comparison period
* @return Collection<int, object>
*/
public function getTopDeclining(int $limit = 10, int $days = 7): Collection
{
if (! $this->isEnabled()) {
return collect();
}
return SeoScoreHistory::getDecliningModels($days)->take($limit);
}
/**
* Get pages that need attention (low or declining scores).
*
* @param int $limit Maximum pages to return
* @param int $scoreThreshold Score threshold (below = needs attention)
* @return Collection<int, SeoScoreHistory>
*/
public function getNeedsAttention(int $limit = 20, int $scoreThreshold = 50): Collection
{
if (! $this->isEnabled()) {
return collect();
}
return SeoScoreHistory::belowThreshold($scoreThreshold)->take($limit);
}
/**
* Get daily site-wide trend.
*
* @param int $days Days to look back
* @return Collection<int, object>
*/
public function getSiteDailyTrend(int $days = 30): Collection
{
if (! $this->isEnabled()) {
return collect();
}
return DB::table(self::TABLE)
->selectRaw('DATE(recorded_at) as date, AVG(score) as avg_score, MIN(score) as min_score, MAX(score) as max_score, COUNT(DISTINCT CONCAT(seoable_type, seoable_id)) as pages_tracked')
->where('recorded_at', '>=', now()->subDays($days))
->groupByRaw('DATE(recorded_at)')
->orderBy('date')
->get();
}
/**
* Get common issues across the site.
*
* @param int $days Days to look back
* @param int $limit Maximum issues to return
* @return Collection<int, object>
*/
public function getCommonIssues(int $days = 7, int $limit = 10): Collection
{
if (! $this->isEnabled()) {
return collect();
}
// Get all issues from recent records
$records = SeoScoreHistory::where('recorded_at', '>=', now()->subDays($days))
->whereNotNull('issues')
->get();
// Count issue occurrences
$issueCounts = collect();
foreach ($records as $record) {
foreach ($record->issues ?? [] as $issue) {
$key = is_string($issue) ? $issue : ($issue['message'] ?? json_encode($issue));
$issueCounts[$key] = ($issueCounts[$key] ?? 0) + 1;
}
}
return $issueCounts
->map(fn ($count, $issue) => (object) ['issue' => $issue, 'count' => $count])
->sortByDesc('count')
->take($limit)
->values();
}
/**
* Prune old history records.
*
* @param int|null $days Days to retain (null uses config)
* @return int Number of records deleted
*/
public function prune(?int $days = null): int
{
if (! $this->tableExists()) {
return 0;
}
$days = $days ?? config('seo.trends.retention_days', 90);
return SeoScoreHistory::prune($days);
}
/**
* Clear cached statistics.
*/
public function clearCache(): void
{
Cache::forget('seo_trend:site_stats:7');
Cache::forget('seo_trend:site_stats:30');
Cache::forget('seo_trend:distribution:7');
Cache::forget('seo_trend:distribution:30');
}
/**
* Check if the history table exists (cached).
*/
protected function tableExists(): bool
{
if ($this->tableExists !== null) {
return $this->tableExists;
}
$this->tableExists = Cache::remember(
'seo_score_history:table_exists',
300,
fn () => Schema::hasTable(self::TABLE)
);
return $this->tableExists;
}
/**
* Return empty statistics array.
*
* @return array{
* current_avg: float,
* previous_avg: float,
* change: float,
* change_percent: float,
* total_pages: int,
* improving: int,
* declining: int,
* stable: int
* }
*/
protected function emptyStats(): array
{
return [
'current_avg' => 0.0,
'previous_avg' => 0.0,
'change' => 0.0,
'change_percent' => 0.0,
'total_pages' => 0,
'improving' => 0,
'declining' => 0,
'stable' => 0,
];
}
}