Add a read-only Google AdSense analytics module for admins.
Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Models\AdsenseDailyStat;
|
||||
use App\Models\AdsenseEntity;
|
||||
use App\Models\Country;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class AdsenseAnalyticsQueryService
|
||||
{
|
||||
/**
|
||||
* @return array{from: Carbon, to: Carbon, previous_from: Carbon, previous_to: Carbon, period: string}
|
||||
*/
|
||||
public function resolveRange(?string $period, ?string $from, ?string $to): array
|
||||
{
|
||||
$period = $period ?: '30d';
|
||||
$today = now()->startOfDay();
|
||||
|
||||
[$start, $end] = match ($period) {
|
||||
'today' => [$today->copy(), $today->copy()],
|
||||
'yesterday' => [$today->copy()->subDay(), $today->copy()->subDay()],
|
||||
'7d' => [$today->copy()->subDays(6), $today->copy()],
|
||||
'custom' => [
|
||||
Carbon::parse($from ?: $today->copy()->subDays(29)->toDateString())->startOfDay(),
|
||||
Carbon::parse($to ?: $today->toDateString())->startOfDay(),
|
||||
],
|
||||
default => [$today->copy()->subDays(29), $today->copy()],
|
||||
};
|
||||
|
||||
if ($end->lt($start)) {
|
||||
[$start, $end] = [$end, $start];
|
||||
}
|
||||
|
||||
$days = $start->diffInDays($end) + 1;
|
||||
$previousEnd = $start->copy()->subDay();
|
||||
$previousStart = $previousEnd->copy()->subDays($days - 1);
|
||||
|
||||
return [
|
||||
'from' => $start,
|
||||
'to' => $end,
|
||||
'previous_from' => $previousStart,
|
||||
'previous_to' => $previousEnd,
|
||||
'period' => $period === 'custom' ? 'custom' : (in_array($period, ['today', 'yesterday', '7d', '30d'], true) ? $period : '30d'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function dashboard(AdsenseConnection $connection, Carbon $from, Carbon $to, Carbon $previousFrom, Carbon $previousTo): array
|
||||
{
|
||||
$account = (string) $connection->account_resource_name;
|
||||
$current = $this->totals($account, $from, $to);
|
||||
$previous = $this->totals($account, $previousFrom, $previousTo);
|
||||
$currency = $current['currency_code'] ?? $previous['currency_code'] ?? 'USD';
|
||||
|
||||
return [
|
||||
'kpis' => [
|
||||
$this->kpi('estimated_earnings', 'Estimated Earnings', $current['estimated_earnings'], $previous['estimated_earnings'], 'money', $currency),
|
||||
$this->kpi('page_views', 'Page Views', $current['page_views'], $previous['page_views'], 'integer'),
|
||||
$this->kpi('impressions', 'Ad Impressions', $current['impressions'], $previous['impressions'], 'integer'),
|
||||
$this->kpi('clicks', 'Clicks', $current['clicks'], $previous['clicks'], 'integer'),
|
||||
$this->kpi('page_views_ctr', 'Page CTR', $this->ratio($current['clicks'], $current['page_views']), $this->ratio($previous['clicks'], $previous['page_views']), 'percent'),
|
||||
$this->kpi('page_views_rpm', 'Page RPM', $this->rpm($current['estimated_earnings'], $current['page_views']), $this->rpm($previous['estimated_earnings'], $previous['page_views']), 'money', $currency),
|
||||
$this->kpi('ad_requests_coverage', 'Ad Request Coverage', $this->ratio($current['matched_ad_requests'], $current['ad_requests']), $this->ratio($previous['matched_ad_requests'], $previous['ad_requests']), 'percent'),
|
||||
$this->kpi('cost_per_click', 'CPC', $this->cpc($current['estimated_earnings'], $current['clicks']), $this->cpc($previous['estimated_earnings'], $previous['clicks']), 'money', $currency),
|
||||
],
|
||||
'chart' => $this->chart($account, $from, $to),
|
||||
'placements' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_AD_UNIT, $from, $to, $currency),
|
||||
'platforms' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_PLATFORM, $from, $to, $currency),
|
||||
'countries' => array_slice($this->dimensionTable($account, AdsenseDailyStat::DIMENSION_COUNTRY, $from, $to, $currency), 0, 20),
|
||||
'currency_code' => $currency,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function totals(string $account, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$row = AdsenseDailyStat::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL)
|
||||
->whereDate('date', '>=', $from->toDateString())
|
||||
->whereDate('date', '<=', $to->toDateString())
|
||||
->selectRaw('
|
||||
SUM(page_views) as page_views,
|
||||
SUM(ad_requests) as ad_requests,
|
||||
SUM(matched_ad_requests) as matched_ad_requests,
|
||||
SUM(impressions) as impressions,
|
||||
SUM(clicks) as clicks,
|
||||
SUM(estimated_earnings) as estimated_earnings,
|
||||
MAX(currency_code) as currency_code
|
||||
')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'page_views' => (int) ($row?->page_views ?? 0),
|
||||
'ad_requests' => (int) ($row?->ad_requests ?? 0),
|
||||
'matched_ad_requests' => (int) ($row?->matched_ad_requests ?? 0),
|
||||
'impressions' => (int) ($row?->impressions ?? 0),
|
||||
'clicks' => (int) ($row?->clicks ?? 0),
|
||||
'estimated_earnings' => (string) ($row?->estimated_earnings ?? '0'),
|
||||
'currency_code' => $row?->currency_code,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function chart(string $account, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$rows = AdsenseDailyStat::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL)
|
||||
->whereDate('date', '>=', $from->toDateString())
|
||||
->whereDate('date', '<=', $to->toDateString())
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
return $rows->map(function (AdsenseDailyStat $row): array {
|
||||
return [
|
||||
'date' => optional($row->date)?->toDateString(),
|
||||
'estimated_earnings' => (float) $row->estimated_earnings,
|
||||
'page_views' => (int) $row->page_views,
|
||||
'page_views_rpm' => (float) $row->page_views_rpm,
|
||||
'clicks' => (int) $row->clicks,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function dimensionTable(string $account, string $dimensionType, Carbon $from, Carbon $to, ?string $currency): array
|
||||
{
|
||||
$rows = AdsenseDailyStat::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('dimension_type', $dimensionType)
|
||||
->whereDate('date', '>=', $from->toDateString())
|
||||
->whereDate('date', '<=', $to->toDateString())
|
||||
->selectRaw('
|
||||
dimension_key,
|
||||
MAX(dimension_label) as dimension_label,
|
||||
SUM(page_views) as page_views,
|
||||
SUM(impressions) as impressions,
|
||||
SUM(clicks) as clicks,
|
||||
SUM(estimated_earnings) as estimated_earnings
|
||||
')
|
||||
->groupBy('dimension_key')
|
||||
->orderByDesc('estimated_earnings')
|
||||
->get();
|
||||
|
||||
$labels = $this->entityLabels($account, $dimensionType);
|
||||
$countryNames = $dimensionType === AdsenseDailyStat::DIMENSION_COUNTRY ? $this->countryNames() : collect();
|
||||
|
||||
return $rows->map(function ($row) use ($labels, $countryNames, $currency, $dimensionType): array {
|
||||
$key = (string) $row->dimension_key;
|
||||
$label = (string) ($labels[$key] ?? $countryNames[$key] ?? $row->dimension_label ?? $key);
|
||||
if ($dimensionType === AdsenseDailyStat::DIMENSION_PLATFORM) {
|
||||
$label = $this->platformLabel($key, $label);
|
||||
}
|
||||
|
||||
$pageViews = (int) $row->page_views;
|
||||
$impressions = (int) $row->impressions;
|
||||
$clicks = (int) $row->clicks;
|
||||
$earnings = (string) ($row->estimated_earnings ?? '0');
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'page_views' => $pageViews,
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'ctr' => $this->ratio($clicks, $pageViews),
|
||||
'rpm' => $this->rpm($earnings, $pageViews),
|
||||
'revenue' => $earnings,
|
||||
'currency_code' => $currency,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function entityLabels(string $account, string $dimensionType): array
|
||||
{
|
||||
if (! in_array($dimensionType, [AdsenseDailyStat::DIMENSION_AD_UNIT, AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL], true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AdsenseEntity::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('type', $dimensionType)
|
||||
->pluck('display_name', 'reporting_dimension_id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, string>
|
||||
*/
|
||||
private function countryNames(): Collection
|
||||
{
|
||||
if (! Schema::hasTable('countries')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Country::query()
|
||||
->get(['iso2', 'name', 'name_common'])
|
||||
->mapWithKeys(function (Country $country): array {
|
||||
$code = strtoupper((string) $country->iso2);
|
||||
$name = (string) ($country->name_common ?: $country->name ?: $code);
|
||||
|
||||
return $code !== '' ? [$code => $name] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private function platformLabel(string $key, string $fallback): string
|
||||
{
|
||||
return match (strtoupper($key)) {
|
||||
'DESKTOP' => 'Desktop',
|
||||
'HIGH_END_MOBILE', 'MOBILE' => 'Mobile',
|
||||
'TABLET' => 'Tablet',
|
||||
default => $fallback !== '' ? $fallback : $key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function kpi(string $key, string $label, mixed $current, mixed $previous, string $format, ?string $currency = null): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'value' => $current,
|
||||
'previous' => $previous,
|
||||
'change_percent' => $this->changePercent($current, $previous),
|
||||
'format' => $format,
|
||||
'currency_code' => $currency,
|
||||
];
|
||||
}
|
||||
|
||||
private function changePercent(mixed $current, mixed $previous): ?float
|
||||
{
|
||||
$current = (float) $current;
|
||||
$previous = (float) $previous;
|
||||
|
||||
if ($previous == 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (($current - $previous) / $previous) * 100;
|
||||
}
|
||||
|
||||
private function ratio(int $numerator, int $denominator): ?float
|
||||
{
|
||||
if ($denominator === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $numerator / $denominator;
|
||||
}
|
||||
|
||||
private function rpm(string|float|int|null $earnings, int $pageViews): ?float
|
||||
{
|
||||
if ($pageViews === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((float) $earnings / $pageViews) * 1000;
|
||||
}
|
||||
|
||||
private function cpc(string|float|int|null $earnings, int $clicks): ?float
|
||||
{
|
||||
if ($clicks === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $earnings / $clicks;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user