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:
2026-09-20 14:49:48 +02:00
parent 04a60a981e
commit ce0f278bac
30 changed files with 5098 additions and 12 deletions
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseDailyStat;
final class AdsenseReportParser
{
/**
* @var array<string, string>
*/
private const METRIC_COLUMNS = [
'ESTIMATED_EARNINGS' => 'estimated_earnings',
'PAGE_VIEWS' => 'page_views',
'PAGE_VIEWS_CTR' => 'page_views_ctr',
'PAGE_VIEWS_RPM' => 'page_views_rpm',
'AD_REQUESTS' => 'ad_requests',
'AD_REQUESTS_COVERAGE' => 'ad_requests_coverage',
'AD_REQUESTS_CTR' => 'ad_requests_ctr',
'AD_REQUESTS_RPM' => 'ad_requests_rpm',
'MATCHED_AD_REQUESTS' => 'matched_ad_requests',
'IMPRESSIONS' => 'impressions',
'IMPRESSIONS_CTR' => 'impressions_ctr',
'IMPRESSIONS_RPM' => 'impressions_rpm',
'CLICKS' => 'clicks',
'COST_PER_CLICK' => 'cost_per_click',
];
/**
* @var list<string>
*/
private const INTEGER_COLUMNS = [
'page_views',
'ad_requests',
'matched_ad_requests',
'impressions',
'clicks',
];
/**
* @param array<string, mixed> $report
* @return list<array<string, mixed>>
*/
public function parse(array $report, string $dimensionType, string $accountResourceName): array
{
$indexByName = $this->headerIndex($report['headers'] ?? []);
$currencyCode = $this->currencyCode($report['headers'] ?? []);
$rows = [];
foreach ($report['rows'] ?? [] as $row) {
if (! is_array($row)) {
continue;
}
$cells = $row['cells'] ?? [];
if (! is_array($cells)) {
continue;
}
$date = $this->cell($cells, $indexByName, 'DATE');
if (! is_string($date) || $date === '') {
continue;
}
$dimensionKey = $this->dimensionKey($cells, $indexByName, $dimensionType);
if ($dimensionKey === null || $dimensionKey === '') {
continue;
}
$parsed = [
'date' => $date,
'account_resource_name' => $accountResourceName,
'dimension_type' => $dimensionType,
'dimension_key' => $dimensionKey,
'dimension_label' => $this->dimensionLabel($cells, $indexByName, $dimensionType, $dimensionKey),
'currency_code' => $currencyCode,
];
foreach (self::METRIC_COLUMNS as $header => $column) {
$parsed[$column] = $this->metricValue($cells, $indexByName, $header, $column);
}
$rows[] = $parsed;
}
return $rows;
}
public function currencyCode(array $headers): ?string
{
foreach ($headers as $header) {
if (! is_array($header)) {
continue;
}
$type = strtoupper((string) ($header['type'] ?? ''));
$code = trim((string) ($header['currencyCode'] ?? ''));
if ($type === 'METRIC_CURRENCY' && $code !== '') {
return $code;
}
}
return null;
}
/**
* @return array<string, int>
*/
private function headerIndex(mixed $headers): array
{
$index = [];
if (! is_array($headers)) {
return $index;
}
foreach ($headers as $position => $header) {
if (! is_array($header)) {
continue;
}
$name = strtoupper(trim((string) ($header['name'] ?? '')));
if ($name === '') {
continue;
}
$index[$name] = (int) $position;
}
return $index;
}
/**
* @param array<int, mixed> $cells
* @param array<string, int> $indexByName
*/
private function dimensionKey(array $cells, array $indexByName, string $dimensionType): ?string
{
return match ($dimensionType) {
AdsenseDailyStat::DIMENSION_TOTAL => AdsenseDailyStat::TOTAL_DIMENSION_KEY,
AdsenseDailyStat::DIMENSION_AD_UNIT => $this->cell($cells, $indexByName, 'AD_UNIT_ID')
?? $this->cell($cells, $indexByName, 'AD_UNIT_NAME'),
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_ID')
?? $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_NAME'),
AdsenseDailyStat::DIMENSION_PLATFORM => $this->cell($cells, $indexByName, 'PLATFORM_TYPE_CODE')
?? $this->cell($cells, $indexByName, 'PLATFORM_TYPE_NAME'),
AdsenseDailyStat::DIMENSION_COUNTRY => $this->cell($cells, $indexByName, 'COUNTRY_CODE')
?? $this->cell($cells, $indexByName, 'COUNTRY_NAME'),
default => null,
};
}
/**
* @param array<int, mixed> $cells
* @param array<string, int> $indexByName
*/
private function dimensionLabel(array $cells, array $indexByName, string $dimensionType, string $dimensionKey): string
{
$label = match ($dimensionType) {
AdsenseDailyStat::DIMENSION_TOTAL => 'Total',
AdsenseDailyStat::DIMENSION_AD_UNIT => $this->cell($cells, $indexByName, 'AD_UNIT_NAME'),
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_NAME'),
AdsenseDailyStat::DIMENSION_PLATFORM => $this->cell($cells, $indexByName, 'PLATFORM_TYPE_NAME')
?? $this->cell($cells, $indexByName, 'PLATFORM_TYPE_CODE'),
AdsenseDailyStat::DIMENSION_COUNTRY => $this->cell($cells, $indexByName, 'COUNTRY_NAME')
?? $this->cell($cells, $indexByName, 'COUNTRY_CODE'),
default => $dimensionKey,
};
return is_string($label) && $label !== '' ? $label : $dimensionKey;
}
/**
* @param array<int, mixed> $cells
* @param array<string, int> $indexByName
*/
private function cell(array $cells, array $indexByName, string $name): ?string
{
$name = strtoupper($name);
if (! array_key_exists($name, $indexByName)) {
return null;
}
$cell = $cells[$indexByName[$name]] ?? null;
if (is_array($cell)) {
$value = $cell['value'] ?? null;
return is_scalar($value) ? (string) $value : null;
}
return is_scalar($cell) ? (string) $cell : null;
}
/**
* @param array<int, mixed> $cells
* @param array<string, int> $indexByName
*/
private function metricValue(array $cells, array $indexByName, string $header, string $column): int|string|null
{
$raw = $this->cell($cells, $indexByName, $header);
if ($raw === null || $raw === '') {
return null;
}
if (in_array($column, self::INTEGER_COLUMNS, true)) {
return (int) $raw;
}
if (! is_numeric($raw)) {
return null;
}
return $raw;
}
}