Files
SkinbaseNova/app/Services/Adsense/AdsenseSyncService.php
T
klevze ce0f278bac 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.
2026-09-20 14:49:48 +02:00

359 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseConnection;
use App\Models\AdsenseDailyStat;
use App\Models\AdsenseEntity;
use App\Services\Adsense\Exceptions\AdsenseApiException;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
final class AdsenseSyncService
{
public function __construct(
private readonly AdsenseApiClient $api,
private readonly AdsenseReportParser $parser,
private readonly AdsenseOAuthService $oauth,
) {}
public function sync(
AdsenseConnection $connection,
Carbon $from,
Carbon $to,
bool $entitiesOnly = false,
): AdsenseSyncResult {
$result = new AdsenseSyncResult(
accountDisplayName: (string) ($connection->account_display_name ?: $connection->account_resource_name),
from: $from->toDateString(),
to: $to->toDateString(),
);
$connection->last_sync_attempt_at = now();
$connection->save();
try {
$this->syncEntities($connection, $result);
if (! $entitiesOnly) {
$this->syncReports($connection, $from, $to, $result);
}
if ($result->reconnectRequired) {
return $result;
}
$connection->last_successful_sync_at = now();
$connection->status = $result->failedReports === []
? AdsenseConnection::STATUS_CONNECTED
: AdsenseConnection::STATUS_ERROR;
$connection->last_error = $result->failedReports === []
? null
: 'Partial AdSense sync failure: '.implode(', ', $result->failedReports);
$connection->save();
} catch (AdsenseAuthorizationException $exception) {
$result->reconnectRequired = true;
$this->oauth->markReconnectRequired($connection, $exception->getMessage());
} catch (AdsenseOAuthException $exception) {
$result->failedReports[] = 'oauth';
$connection->status = AdsenseConnection::STATUS_ERROR;
$connection->last_error = $exception->getMessage();
$connection->save();
} catch (AdsenseApiException $exception) {
$result->failedReports[] = 'entities';
Log::warning('AdSense entity synchronization failed.', AdsenseLogSanitizer::context([
'status' => $exception->status,
'message' => $exception->getMessage(),
]));
$connection->status = AdsenseConnection::STATUS_ERROR;
$connection->last_error = $exception->getMessage();
$connection->save();
}
return $result;
}
public function syncEntities(AdsenseConnection $connection, ?AdsenseSyncResult $result = null): AdsenseSyncResult
{
$result ??= new AdsenseSyncResult(
accountDisplayName: (string) ($connection->account_display_name ?: $connection->account_resource_name),
);
$account = (string) $connection->account_resource_name;
if ($account === '') {
throw new AdsenseApiException('No AdSense account is selected.');
}
$adClients = $this->api->listAdClients($connection, $account);
$now = now();
$adUnits = 0;
$customChannels = 0;
foreach ($adClients as $client) {
$clientName = (string) ($client['name'] ?? '');
if ($clientName === '' || ! $this->supportsInventory($client)) {
continue;
}
try {
foreach ($this->api->listAdUnits($connection, $clientName) as $unit) {
$this->upsertEntity($account, $clientName, AdsenseEntity::TYPE_AD_UNIT, $unit, $now);
$adUnits++;
}
} catch (AdsenseAuthorizationException $exception) {
throw $exception;
} catch (AdsenseApiException $exception) {
$this->logSkippedClient($clientName, 'ad_units', $exception);
}
try {
foreach ($this->api->listCustomChannels($connection, $clientName) as $channel) {
$this->upsertEntity($account, $clientName, AdsenseEntity::TYPE_CUSTOM_CHANNEL, $channel, $now);
$customChannels++;
}
} catch (AdsenseAuthorizationException $exception) {
throw $exception;
} catch (AdsenseApiException $exception) {
$this->logSkippedClient($clientName, 'custom_channels', $exception);
}
}
$result->adUnits = $adUnits;
$result->customChannels = $customChannels;
$result->entitiesSynced = true;
return $result;
}
private function syncReports(
AdsenseConnection $connection,
Carbon $from,
Carbon $to,
AdsenseSyncResult $result,
): void {
$account = (string) $connection->account_resource_name;
$metrics = array_values((array) config('adsense.metrics', []));
$definitions = [
AdsenseDailyStat::DIMENSION_TOTAL => ['DATE'],
AdsenseDailyStat::DIMENSION_AD_UNIT => ['DATE', 'AD_UNIT_ID'],
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => ['DATE', 'CUSTOM_CHANNEL_ID'],
AdsenseDailyStat::DIMENSION_PLATFORM => ['DATE', 'PLATFORM_TYPE_CODE'],
AdsenseDailyStat::DIMENSION_COUNTRY => ['DATE', 'COUNTRY_CODE'],
];
foreach ($definitions as $dimensionType => $dimensions) {
try {
$report = $this->api->generateReport(
$connection,
$account,
$dimensions,
$metrics,
$from,
$to,
$dimensionType,
);
$rows = $this->parser->parse($report, $dimensionType, $account);
$this->applyEntityLabels($rows, $account, $dimensionType);
$saved = $this->upsertStats($rows);
match ($dimensionType) {
AdsenseDailyStat::DIMENSION_TOTAL => $result->totalRows = $saved,
AdsenseDailyStat::DIMENSION_AD_UNIT => $result->adUnitRows = $saved,
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $result->customChannelRows = $saved,
AdsenseDailyStat::DIMENSION_PLATFORM => $result->platformRows = $saved,
AdsenseDailyStat::DIMENSION_COUNTRY => $result->countryRows = $saved,
default => null,
};
} catch (AdsenseAuthorizationException $exception) {
$result->reconnectRequired = true;
throw $exception;
} catch (AdsenseApiException $exception) {
$result->failedReports[] = $dimensionType;
Log::warning('AdSense report synchronization failed.', AdsenseLogSanitizer::context([
'report' => $dimensionType,
'status' => $exception->status,
'message' => $exception->getMessage(),
]));
}
}
}
/**
* Website content ad clients (AFC / ca-pub-...) expose ad units.
* YouTube host clients (ca-yt-host-pub-...) are listed by Google but 404 on adunits.
*
* @param array<string, mixed> $client
*/
private function supportsInventory(array $client): bool
{
$name = strtolower((string) ($client['name'] ?? ''));
$reportingId = strtolower((string) ($client['reportingDimensionId'] ?? ''));
$haystack = $name.' '.$reportingId;
foreach ((array) config('adsense.inventory_excluded_client_prefixes', ['ca-yt-host-']) as $prefix) {
$prefix = strtolower(trim((string) $prefix));
if ($prefix !== '' && str_contains($haystack, $prefix)) {
return false;
}
}
$product = strtoupper(trim((string) ($client['productCode'] ?? '')));
$allowed = array_values(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
(array) config('adsense.inventory_product_codes', ['AFC']),
)));
if ($product !== '' && $allowed !== [] && ! in_array($product, $allowed, true)) {
return false;
}
return true;
}
private function logSkippedClient(string $clientName, string $kind, AdsenseApiException $exception): void
{
Log::warning('AdSense skipped an ad client inventory request.', AdsenseLogSanitizer::context([
'client' => $clientName,
'kind' => $kind,
'status' => $exception->status,
'message' => $exception->getMessage(),
]));
}
/**
* @param array<string, mixed> $entity
*/
private function upsertEntity(
string $account,
string $adClient,
string $type,
array $entity,
Carbon $seenAt,
): void {
$resourceName = (string) ($entity['name'] ?? '');
$reportingId = (string) ($entity['reportingDimensionId'] ?? '');
if ($reportingId === '' && $resourceName !== '') {
$reportingId = (string) str($resourceName)->afterLast('/');
}
if ($reportingId === '') {
return;
}
$state = strtoupper((string) ($entity['state'] ?? ''));
$active = $state === '' ? null : $state === 'ACTIVE' || $state === 'READY';
AdsenseEntity::query()->updateOrCreate(
[
'account_resource_name' => $account,
'type' => $type,
'reporting_dimension_id' => $reportingId,
],
[
'ad_client_resource_name' => $adClient,
'resource_name' => $resourceName !== '' ? $resourceName : null,
'display_name' => (string) ($entity['displayName'] ?? $reportingId),
'active' => $active,
'last_seen_at' => $seenAt,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function applyEntityLabels(array &$rows, string $account, string $dimensionType): void
{
if (! in_array($dimensionType, [AdsenseDailyStat::DIMENSION_AD_UNIT, AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL], true)) {
return;
}
$labels = AdsenseEntity::query()
->where('account_resource_name', $account)
->where('type', $dimensionType)
->pluck('display_name', 'reporting_dimension_id');
foreach ($rows as &$row) {
$key = (string) ($row['dimension_key'] ?? '');
if ($key !== '' && isset($labels[$key]) && is_string($labels[$key]) && $labels[$key] !== '') {
$row['dimension_label'] = $labels[$key];
}
}
unset($row);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function upsertStats(array $rows): int
{
if ($rows === []) {
return 0;
}
$now = now();
$payload = [];
foreach ($rows as $row) {
$payload[] = [
'date' => $row['date'],
'account_resource_name' => $row['account_resource_name'],
'dimension_type' => $row['dimension_type'],
'dimension_key' => $row['dimension_key'],
'dimension_label' => $row['dimension_label'] ?? null,
'currency_code' => $row['currency_code'] ?? null,
'page_views' => $row['page_views'] ?? null,
'ad_requests' => $row['ad_requests'] ?? null,
'matched_ad_requests' => $row['matched_ad_requests'] ?? null,
'impressions' => $row['impressions'] ?? null,
'clicks' => $row['clicks'] ?? null,
'estimated_earnings' => $row['estimated_earnings'] ?? null,
'page_views_ctr' => $row['page_views_ctr'] ?? null,
'page_views_rpm' => $row['page_views_rpm'] ?? null,
'ad_requests_coverage' => $row['ad_requests_coverage'] ?? null,
'ad_requests_ctr' => $row['ad_requests_ctr'] ?? null,
'ad_requests_rpm' => $row['ad_requests_rpm'] ?? null,
'impressions_ctr' => $row['impressions_ctr'] ?? null,
'impressions_rpm' => $row['impressions_rpm'] ?? null,
'cost_per_click' => $row['cost_per_click'] ?? null,
'last_synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
foreach (array_chunk($payload, 200) as $chunk) {
AdsenseDailyStat::query()->upsert(
$chunk,
['account_resource_name', 'date', 'dimension_type', 'dimension_key'],
[
'dimension_label',
'currency_code',
'page_views',
'ad_requests',
'matched_ad_requests',
'impressions',
'clicks',
'estimated_earnings',
'page_views_ctr',
'page_views_rpm',
'ad_requests_coverage',
'ad_requests_ctr',
'ad_requests_rpm',
'impressions_ctr',
'impressions_rpm',
'cost_per_click',
'last_synced_at',
'updated_at',
],
);
}
return count($payload);
}
}