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,358 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user