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 $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 $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> $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> $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); } }