Proje dosyaları eklendi

This commit is contained in:
2026-08-29 15:53:53 +03:00
commit d3313400ca
440 changed files with 24827 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Serp\Contracts;
use App\Serp\Data\SerpQuery;
use App\Serp\Data\SerpSubmission;
use App\Serp\Data\SerpTaskResult;
interface AsyncSerpProvider extends SerpProvider
{
public function submit(SerpQuery $query): SerpSubmission;
public function collect(string $taskId, int $limit): SerpTaskResult;
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Serp\Contracts;
use App\Serp\Data\SerpQuery;
use App\Serp\Data\SerpResponse;
interface SerpProvider
{
public function key(): string;
public function supports(SerpQuery $query): bool;
public function search(SerpQuery $query): SerpResponse;
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Serp\Data;
final readonly class ProviderHealth
{
public function __construct(public string $providerKey, public int $balanceMicros, public int $statusCode, public \DateTimeInterface $checkedAt) {}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Serp\Data;
final readonly class SerpQuery
{
public function __construct(public string $phrase, public string $searchEngine, public string $countryCode, public string $languageCode, public string $device, public int $limit) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Serp\Data;
use InvalidArgumentException;
final readonly class SerpResponse
{
public array $results;
public function __construct(public string $providerKey, array $results, public ?string $referenceId = null, int $limit = 100, public string|int|float|null $cost = null, public string $currency = 'USD', public ?int $statusCode = null)
{
if (count($results) > $limit) {
throw new InvalidArgumentException('Sonuç limiti aşıldı.');
}foreach ($results as $r) {
if (! $r instanceof SerpResultData) {
throw new InvalidArgumentException('Geçersiz sonuç verisi.');
}
}$positions = array_map(fn ($r) => $r->position, $results);
if (count($positions) !== count(array_unique($positions))) {
throw new InvalidArgumentException('Tekrarlanan sonuç pozisyonu.');
}$this->results = array_values($results);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Serp\Data;
use InvalidArgumentException;
final readonly class SerpResultData
{
public function __construct(public int $position, public string $url, public ?string $title = null, public ?string $displayUrl = null, public ?string $snippet = null, public string $resultType = 'organic')
{
if ($position < 1) {
throw new InvalidArgumentException('Position pozitif olmalıdır.');
}
if ($resultType !== 'organic') {
throw new InvalidArgumentException('Desteklenmeyen sonuç türü.');
}
if (strlen($url) > 2048 || ! filter_var($url, FILTER_VALIDATE_URL) || ! in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) {
throw new InvalidArgumentException('Geçersiz sonuç URL adresi.');
}
if (($title !== null && mb_strlen($title) > 500) || ($displayUrl !== null && mb_strlen($displayUrl) > 500) || ($snippet !== null && mb_strlen($snippet) > 5000)) {
throw new InvalidArgumentException('Sonuç alanı izin verilen uzunluğu aşıyor.');
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Serp\Data;
final readonly class SerpSubmission
{
public function __construct(public string $providerKey, public string $taskId, public ?float $cost = null, public string $currency = 'USD', public ?int $statusCode = null) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Serp\Data;
final readonly class SerpTaskResult
{
private function __construct(public bool $ready, public ?SerpResponse $response, public ?float $cost, public string $currency, public ?int $statusCode) {}
public static function waiting(?int $statusCode = null): self
{
return new self(false, null, null, 'USD', $statusCode);
}
public static function ready(SerpResponse $response, ?float $cost = null, string $currency = 'USD', ?int $statusCode = null): self
{
return new self(true, $response, $cost, $currency, $statusCode);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Serp\Exceptions;
class PermanentProviderException extends SerpProviderException
{
public function retryable(): bool
{
return false;
}
public function safeCode(): string
{
return 'provider_rejected';
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Serp\Exceptions;
class ProviderAuthenticationException extends PermanentProviderException
{
public function safeCode(): string
{
return 'provider_configuration_error';
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Serp\Exceptions;
class ProviderConfigurationException extends PermanentProviderException
{
public function safeCode(): string
{
return 'provider_not_configured';
}
public function safeMessage(): string
{
return 'SERP kontrol hizmeti henüz yapılandırılmamış.';
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Serp\Exceptions;
class ProviderRateLimitException extends TransientProviderException
{
public function safeCode(): string
{
return 'provider_rate_limited';
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Serp\Exceptions;
class ProviderTimeoutException extends TransientProviderException
{
public function safeCode(): string
{
return 'provider_timeout';
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Serp\Exceptions;
use RuntimeException;
abstract class SerpProviderException extends RuntimeException
{
abstract public function retryable(): bool;
abstract public function safeCode(): string;
public function safeMessage(): string
{
return 'SERP kontrolü şu anda tamamlanamadı.';
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Serp\Exceptions;
class TransientProviderException extends SerpProviderException
{
public function retryable(): bool
{
return true;
}
public function safeCode(): string
{
return 'provider_transient';
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Serp\Exceptions;
class UnexpectedProviderResponseException extends PermanentProviderException
{
public function safeCode(): string
{
return 'provider_invalid_response';
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationState;
use Throwable;
final class ActivationManager
{
public function state(): ActivationState
{
try {
$configured = ActivationState::tryFrom((string) config('serp.operation_mode'));
if (! $configured) {
return ActivationState::Disabled;
}$db = SerpOperationState::query()->find(1);
if ($db?->emergency_stopped_at) {
return ActivationState::EmergencyStopped;
}
return $configured;
} catch (Throwable) {
return ActivationState::Disabled;
}
}
public function allowsNormal(): bool
{
return config('serp.enabled') === true && config('serp.provider') === 'dataforseo' && $this->state() === ActivationState::Enabled;
}
public function allowsCanary(): bool
{
return config('serp.enabled') === true && config('serp.provider') === 'dataforseo' && $this->state() === ActivationState::Canary;
}
public function allowsPaid(bool $canary = false): bool
{
return $canary ? $this->allowsCanary() : $this->allowsNormal();
}
public function assertPaid(bool $canary = false): void
{
if (! $this->allowsPaid($canary)) {
throw new OperationBlockedException($this->state() === ActivationState::EmergencyStopped ? 'emergency_stopped' : 'activation_blocked');
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Serp\Operations;
enum ActivationState: string
{
case Disabled = 'disabled';
case ValidationOnly = 'validation_only';
case Canary = 'canary';
case Enabled = 'enabled';
case EmergencyStopped = 'emergency_stopped';
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpBudgetEntry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class BudgetManager
{
public function __construct(private KillSwitch $kill, private OperationAuditLogger $audit, private RampUpPolicy $ramp) {}
public function reserve(string $key, string $mode, ?int $checkId = null): SerpBudgetEntry
{
return Cache::lock('serp-budget-global', 10)->block(5, function () use ($key, $mode, $checkId) {
$result = DB::transaction(function () use ($key, $mode, $checkId) {
if ($e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first()) {
return $e;
}
$this->ramp->assertAllowsNewPaid();
$estimate = Money::micros((string) config('serp.estimated_costs.'.$mode));
$day = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
$month = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
if ($day + $estimate > Money::micros((string) config('serp.daily_budget_usd')) || $month + $estimate > Money::micros((string) config('serp.monthly_budget_usd'))) {
return false;
}
return SerpBudgetEntry::create(['reservation_key' => $key, 'serp_check_id' => $checkId, 'mode' => $mode, 'reserved_micros' => $estimate, 'currency' => 'USD', 'reserved_at' => now('UTC')]);
});
if ($result === false) {
$this->kill->activate('budget_exceeded');
$this->audit->record('budget_blocked', ['cost_micros' => Money::micros((string) config('serp.estimated_costs.'.$mode))]);
throw new OperationBlockedException('budget_blocked');
}
$this->audit->record('budget_reserved', ['mode' => $mode, 'cost_micros' => $result->reserved_micros]);
return $result;
});
}
public function reconcile(string $key, string|int|float|null $actual, ?string $taskId = null): void
{
DB::transaction(function () use ($key, $actual, $taskId) {
$e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first();
if (! $e || $e->reconciled_at) {
return;
}$e->forceFill(['provider_task_id' => $taskId, 'actual_micros' => Money::micros($actual), 'reconciled_at' => now('UTC')])->save();
$this->audit->record('budget_reconciled', ['cost_micros' => $e->actual_micros]);
});
}
public function usage(): array
{
$daily = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
$monthly = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
return ['daily_micros' => $daily, 'monthly_micros' => $monthly];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Serp\Operations;
use App\Serp\Exceptions\ProviderAuthenticationException;
use App\Serp\Exceptions\ProviderConfigurationException;
use App\Serp\Exceptions\SerpProviderException;
use Illuminate\Support\Facades\Cache;
final class CircuitBreaker
{
public function __construct(private KillSwitch $kill, private OperationAuditLogger $audit) {}
public function assertClosed(): void
{
try {
if (Cache::get('serp:circuit:open')) {
throw new OperationBlockedException('circuit_open');
}
} catch (OperationBlockedException $e) {
throw $e;
} catch (\Throwable) {
throw new OperationBlockedException('circuit_cache_unavailable');
}
}
public function success(): void
{
Cache::forget('serp:circuit:failures');
Cache::forget('serp:circuit:open');
}
public function failure(SerpProviderException $e): void
{
if ($e instanceof ProviderAuthenticationException || $e instanceof ProviderConfigurationException) {
Cache::put('serp:circuit:open', true, now()->addMinutes((int) config('serp.circuit_cooldown_minutes')));
$this->kill->activate('provider_auth_configuration');
$this->audit->record('circuit_opened', ['error_category' => $e->safeCode()]);
return;
}$n = (int) Cache::increment('serp:circuit:failures');
Cache::put('serp:circuit:failures', $n, now()->addMinutes((int) config('serp.circuit_window_minutes')));
if ($n >= (int) config('serp.circuit_failure_threshold')) {
Cache::put('serp:circuit:open', true, now()->addMinutes((int) config('serp.circuit_cooldown_minutes')));
$this->audit->record('circuit_opened', ['error_category' => $e->safeCode()]);
}
}
public function open(): bool
{
return (bool) Cache::get('serp:circuit:open');
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationState;
use App\Serp\Data\ProviderHealth;
use App\Serp\Exceptions\UnexpectedProviderResponseException;
use App\Serp\Providers\DataForSeo\DataForSeoClient;
use App\Serp\Providers\DataForSeo\DataForSeoErrorClassifier;
use Illuminate\Support\Facades\Cache;
final class DataForSeoHealthService
{
public function __construct(private DataForSeoClient $client, private DataForSeoErrorClassifier $errors, private KillSwitch $kill, private OperationAuditLogger $audit) {}
public function validateConfig(): array
{
$configured = (string) config('serp.dataforseo.login') !== '' && (string) config('serp.dataforseo.password') !== '';
$base = rtrim((string) config('serp.dataforseo.base_url'), '/');
$parts = parse_url($base);
if (! is_array($parts) || ($parts['scheme'] ?? null) !== 'https' || strtolower($parts['host'] ?? '') !== 'api.dataforseo.com') {
throw new \InvalidArgumentException('Invalid provider endpoint.');
}if ($configured) {
$this->client->validateConfiguration();
}
foreach (['daily_budget_usd', 'monthly_budget_usd', 'low_balance_usd', 'critical_balance_usd'] as $key) {
if (Money::micros((string) config('serp.'.$key)) <= 0) {
throw new \InvalidArgumentException('Invalid '.$key);
}
}
return ['provider' => 'dataforseo', 'host' => 'api.dataforseo.com', 'tls' => true, 'credentials_configured' => $configured, 'operation_mode' => app(ActivationManager::class)->state()->value];
}
public function remote(bool $fresh = false): ProviderHealth
{
return Cache::remember('serp:dataforseo:health', $fresh ? 0 : (int) config('serp.health_ttl_seconds'), function () {
$data = $this->client->userData();
$status = $data['status_code'] ?? null;
if (! is_int($status)) {
throw new UnexpectedProviderResponseException('Health status missing.');
}if ($status !== 20000) {
throw $this->errors->api($status);
}$task = $data['tasks'][0] ?? null;
if (! is_array($task) || ($task['status_code'] ?? null) !== 20000) {
throw new UnexpectedProviderResponseException('Health task malformed.');
}$balance = $task['result'][0]['money']['balance'] ?? null;
if (! is_int($balance) && ! is_float($balance) && ! is_string($balance)) {
throw new UnexpectedProviderResponseException('Balance missing.');
}$health = new ProviderHealth('dataforseo', Money::micros($balance), 20000, now('UTC'));
SerpOperationState::query()->updateOrCreate(['id' => 1], ['last_health_at' => $health->checkedAt, 'last_balance_micros' => $health->balanceMicros, 'last_provider_status_code' => 20000, 'last_provider_success_at' => $health->checkedAt, 'last_error_category' => null]);
if ($health->balanceMicros < Money::micros((string) config('serp.critical_balance_usd'))) {
$this->kill->activate('critical_low_balance');
} elseif ($health->balanceMicros < Money::micros((string) config('serp.low_balance_usd'))) {
$this->audit->record('low_balance', ['cost_micros' => $health->balanceMicros]);
}
return $health;
});
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationState;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
final class KillSwitch
{
public function __construct(private OperationAuditLogger $audit) {}
public function active(): bool
{
return SerpOperationState::query()->find(1)?->emergency_stopped_at !== null;
}
public function activate(string $reason, ?User $actor = null): void
{
DB::transaction(function () use ($reason, $actor) {
$s = SerpOperationState::query()->lockForUpdate()->find(1) ?: new SerpOperationState(['id' => 1]);
if (! $s->emergency_stopped_at) {
$s->forceFill(['emergency_reason_code' => $reason, 'emergency_stopped_at' => now(), 'emergency_actor_id' => $actor?->id])->save();
$this->audit->record('kill_switch_activated', ['reason' => $reason], $actor);
}
});
}
public function clear(User $actor, string $reason): void
{
if (! $actor->hasRole('super_admin') || ! $actor->hasPermission('serp_operations.emergency_stop')) {
throw new AuthorizationException;
}DB::transaction(function () use ($actor, $reason) {
$s = SerpOperationState::query()->lockForUpdate()->find(1);
if ($s?->emergency_stopped_at) {
$s->forceFill(['emergency_reason_code' => null, 'emergency_stopped_at' => null, 'emergency_actor_id' => null])->save();
$this->audit->record('kill_switch_cleared', ['reason' => $reason], $actor);
}
});
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Serp\Operations;
use InvalidArgumentException;
final class Money
{
public static function micros(string|int|float|null $value): int
{
if ($value === null) {
return 0;
}$s = is_float($value) ? number_format($value, 6, '.', '') : (string) $value;
if (! preg_match('/^-?\d+(?:\.\d{1,6})?$/', $s)) {
throw new InvalidArgumentException('Invalid money value.');
}$negative = str_starts_with($s, '-');
$s = ltrim($s, '-');
[$whole,$fraction] = array_pad(explode('.', $s, 2), 2, '');
$result = ((int) $whole * 1000000) + (int) str_pad($fraction, 6, '0');
return $negative ? -$result : $result;
}
public static function decimal(int $micros): string
{
$negative = $micros < 0;
$micros = abs($micros);
return ($negative ? '-' : '').intdiv($micros, 1000000).'.'.str_pad((string) ($micros % 1000000), 6, '0', STR_PAD_LEFT);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationAudit;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class OperationAuditLogger
{
public function record(string $action, array $context = [], ?User $actor = null, ?string $correlationId = null): void
{
$safe = array_intersect_key($context, array_flip(['check_ulid', 'provider', 'mode', 'task_reference', 'attempt', 'duration_ms', 'result_count', 'error_category', 'cost_micros', 'reason']));
$id = $correlationId ?: Str::uuid()->toString();
SerpOperationAudit::create(['actor_user_id' => $actor?->id, 'action' => $action, 'correlation_id' => $id, 'context' => $safe]);
Log::info('serp_operation.'.$action, ['correlation_id' => $id] + $safe);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Serp\Operations;
use App\Serp\Exceptions\PermanentProviderException;
class OperationBlockedException extends PermanentProviderException
{
public function __construct(private readonly string $reason)
{
parent::__construct($reason);
}
public function safeCode(): string
{
return $this->reason;
}
public function safeMessage(): string
{
return 'SERP kontrol hizmeti şu anda kullanılamıyor.';
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpBudgetEntry;
use App\Models\SerpOperationState;
final class RampUpPolicy
{
public function __construct(private ActivationManager $activation) {}
public function assertAllowsNewPaid(): void
{
if ($this->activation->state() !== ActivationState::Enabled) {
return;
}$state = SerpOperationState::query()->lockForUpdate()->find(1);
if (! $state) {
throw new OperationBlockedException('ramp_state_missing');
}
if (! $state->ramp_started_at) {
$state->forceFill(['ramp_started_at' => now('UTC')])->save();
}$age = $state->ramp_started_at->diffInMinutes(now('UTC'));
$limit = $age < 60 ? (int) config('serp.ramp.first_hour_limit') : ($age < 360 ? (int) config('serp.ramp.six_hour_limit') : (int) config('serp.ramp.first_day_limit'));
$count = SerpBudgetEntry::where('reserved_at', '>=', $state->ramp_started_at)->count();
if ($count >= $limit) {
throw new OperationBlockedException('ramp_up_limit');
}
}
public function status(): array
{
$state = SerpOperationState::find(1);
if (! $state?->ramp_started_at) {
return ['started_at' => null, 'age_minutes' => null, 'used' => 0, 'limit' => (int) config('serp.ramp.first_hour_limit')];
}$age = $state->ramp_started_at->diffInMinutes(now('UTC'));
$limit = $age < 60 ? (int) config('serp.ramp.first_hour_limit') : ($age < 360 ? (int) config('serp.ramp.six_hour_limit') : (int) config('serp.ramp.first_day_limit'));
return ['started_at' => $state->ramp_started_at->toAtomString(), 'age_minutes' => (int) $age, 'used' => SerpBudgetEntry::where('reserved_at', '>=', $state->ramp_started_at)->count(), 'limit' => $limit];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpCheck;
use App\Models\SerpOperationState;
use Illuminate\Support\Facades\DB;
final class SerpOperationsMetrics
{
public function __construct(private ActivationManager $activation, private BudgetManager $budget, private CircuitBreaker $circuit, private RampUpPolicy $ramp) {}
public function summary(): array
{
$since = now('UTC')->subDay();
$counts = SerpCheck::where('created_at', '>=', $since)->select('status', 'execution_mode', DB::raw('count(*) aggregate'))->groupBy('status', 'execution_mode')->get();
$total = $counts->sum('aggregate');
$completed = $counts->where('status', 'completed')->sum('aggregate');
$state = SerpOperationState::find(1);
return ['activation_mode' => $this->activation->state()->value, 'kill_switch' => (bool) $state?->emergency_stopped_at, 'kill_reason' => $state?->emergency_reason_code, 'circuit_open' => $this->circuit->open(), 'last_health_at' => $state?->last_health_at, 'last_balance_micros' => $state?->last_balance_micros, 'last_error_category' => $state?->last_error_category, 'checks_24h' => $total, 'completed_24h' => $completed, 'failed_24h' => $counts->where('status', 'failed')->sum('aggregate'), 'success_percent' => $total ? round($completed * 100 / $total, 1) : 0, 'average_duration_ms' => (int) SerpCheck::where('created_at', '>=', $since)->whereNotNull('duration_ms')->avg('duration_ms'), 'average_polls' => (float) SerpCheck::where('created_at', '>=', $since)->avg('provider_poll_attempts'), 'stuck' => SerpCheck::whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->count(), 'waiting_provider' => SerpCheck::where('status', 'running')->whereNotNull('provider_reference')->count(), 'budget' => $this->budget->usage(), 'ramp_up' => $this->ramp->status(), 'queue_backlog' => DB::table('jobs')->where('queue', config('serp.queue'))->count(), 'failed_jobs' => DB::table('failed_jobs')->count()];
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Serp\Providers\DataForSeo;
use App\Serp\Exceptions\ProviderConfigurationException;
use App\Serp\Exceptions\UnexpectedProviderResponseException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
final class DataForSeoClient
{
public function __construct(private DataForSeoErrorClassifier $errors) {}
public function validateConfiguration(): void
{
$this->credentialsAndBase();
}
public function live(array $payload): array
{
return $this->request('post', '/v3/serp/google/organic/live/advanced', [$payload]);
}
public function submit(array $payload): array
{
return $this->request('post', '/v3/serp/google/organic/task_post', [$payload]);
}
public function collect(string $taskId): array
{
if (! preg_match('/^[0-9a-f-]{36}$/i', $taskId)) {
throw new UnexpectedProviderResponseException('Invalid provider task id.');
}
return $this->request('get', '/v3/serp/google/organic/task_get/advanced/'.$taskId);
}
public function userData(): array
{
return $this->request('get', '/v3/appendix/user_data');
}
private function credentialsAndBase(): array
{
$login = (string) config('serp.dataforseo.login');
$password = (string) config('serp.dataforseo.password');
$base = rtrim((string) config('serp.dataforseo.base_url'), '/');
if ($login === '' || $password === '') {
throw new ProviderConfigurationException('DataForSEO credentials are missing.');
}$parts = parse_url($base);
if (! is_array($parts) || ($parts['scheme'] ?? null) !== 'https' || strtolower($parts['host'] ?? '') !== 'api.dataforseo.com' || isset($parts['user'],$parts['pass'],$parts['query'],$parts['fragment']) || (($parts['path'] ?? '') !== '' && ($parts['path'] ?? '') !== '/')) {
throw new ProviderConfigurationException('Invalid DataForSEO base URL.');
}
return [$login, $password, $base];
}
private function request(string $method, string $path, ?array $json = null): array
{
[$login,$password,$base] = $this->credentialsAndBase();
try {
$request = Http::acceptJson()->asJson()->withBasicAuth($login, $password)->connectTimeout((int) config('serp.dataforseo.connect_timeout'))->timeout((int) config('serp.dataforseo.timeout'))->withoutRedirecting();
$response = $method === 'post' ? $request->post($base.$path, $json) : $request->get($base.$path);
} catch (ConnectionException $e) {
throw $this->errors->connection($e);
}if (! $response->successful()) {
throw $this->errors->http($response->status());
}$data = $response->json();
if (! is_array($data)) {
throw new UnexpectedProviderResponseException('DataForSEO returned invalid JSON.');
}
return $data;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Serp\Providers\DataForSeo;
use App\Serp\Exceptions\PermanentProviderException;
use App\Serp\Exceptions\ProviderAuthenticationException;
use App\Serp\Exceptions\ProviderConfigurationException;
use App\Serp\Exceptions\ProviderRateLimitException;
use App\Serp\Exceptions\ProviderTimeoutException;
use App\Serp\Exceptions\SerpProviderException;
use App\Serp\Exceptions\TransientProviderException;
use App\Serp\Exceptions\UnexpectedProviderResponseException;
use Illuminate\Http\Client\ConnectionException;
final class DataForSeoErrorClassifier
{
public function connection(ConnectionException $e): SerpProviderException
{
return str_contains(strtolower($e->getMessage()), 'timed out') ? new ProviderTimeoutException('DataForSEO request timed out.', previous: $e) : new TransientProviderException('DataForSEO connection failed.', previous: $e);
}
public function http(int $s): SerpProviderException
{
return match (true) {
$s === 401 || $s === 403 => new ProviderAuthenticationException('DataForSEO authentication failed.'),$s === 429 => new ProviderRateLimitException('DataForSEO HTTP rate limit.'),$s >= 500 => new TransientProviderException('DataForSEO server error.'),default => new PermanentProviderException('DataForSEO HTTP request rejected.')
};
}
public function api(int $s): SerpProviderException
{
return match (true) {
$s === 40100 || $s === 40104 => new ProviderAuthenticationException('DataForSEO account is unavailable.'),$s === 40200 || $s === 40201 => new ProviderConfigurationException('DataForSEO billing is unavailable.'),in_array($s, [40101, 40103, 50301, 50303, 50304], true) => new TransientProviderException('DataForSEO temporary error.'),in_array($s, [50401, 50402], true) => new ProviderTimeoutException('DataForSEO processing timed out.'),$s === 42900 => new ProviderRateLimitException('DataForSEO rate limit.'),$s >= 40000 && $s < 50000 => new PermanentProviderException('DataForSEO rejected the task.'),$s >= 50000 => new TransientProviderException('DataForSEO service error.'),default => new UnexpectedProviderResponseException('Unexpected DataForSEO status code.')
};
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Serp\Providers\DataForSeo;
use App\Serp\Data\SerpQuery;
use App\Serp\Exceptions\ProviderConfigurationException;
final class DataForSeoPayloadMapper
{
private const LOCATIONS = ['TR' => 2792, 'DE' => 2276, 'US' => 2840, 'GB' => 2826];
private const LANGUAGES = ['tr' => 'tr', 'de' => 'de', 'en' => 'en'];
private const DEVICES = ['desktop' => 'windows', 'mobile' => 'android'];
public function supports(SerpQuery $q): bool
{
return $q->searchEngine === 'google' && isset(self::LOCATIONS[strtoupper($q->countryCode)],self::LANGUAGES[strtolower($q->languageCode)],self::DEVICES[$q->device]);
}
public function map(SerpQuery $q): array
{
if (! $this->supports($q)) {
throw new ProviderConfigurationException('Unsupported DataForSEO target.');
}
return ['keyword' => $q->phrase, 'location_code' => self::LOCATIONS[strtoupper($q->countryCode)], 'language_code' => self::LANGUAGES[strtolower($q->languageCode)], 'device' => $q->device, 'os' => self::DEVICES[$q->device], 'depth' => 10];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Serp\Providers\DataForSeo;
use App\Serp\Contracts\AsyncSerpProvider;
use App\Serp\Data\SerpQuery;
use App\Serp\Data\SerpResponse;
use App\Serp\Data\SerpSubmission;
use App\Serp\Data\SerpTaskResult;
final class DataForSeoProvider implements AsyncSerpProvider
{
public function __construct(private DataForSeoClient $client, private DataForSeoPayloadMapper $mapper, private DataForSeoResponseNormalizer $normalizer) {}
public function key(): string
{
return 'dataforseo';
}
public function supports(SerpQuery $q): bool
{
return $this->mapper->supports($q);
}
public function search(SerpQuery $q): SerpResponse
{
return $this->normalizer->live($this->client->live($this->mapper->map($q)), 10);
}
public function submit(SerpQuery $q): SerpSubmission
{
return $this->normalizer->submission($this->client->submit($this->mapper->map($q)));
}
public function collect(string $taskId, int $limit): SerpTaskResult
{
return $this->normalizer->collection($this->client->collect($taskId), $taskId, min(10, $limit));
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Serp\Providers\DataForSeo;
use App\Serp\Data\SerpResponse;
use App\Serp\Data\SerpResultData;
use App\Serp\Data\SerpSubmission;
use App\Serp\Data\SerpTaskResult;
use App\Serp\Exceptions\UnexpectedProviderResponseException;
final class DataForSeoResponseNormalizer
{
public function __construct(private DataForSeoErrorClassifier $errors) {}
public function live(array $data, int $limit): SerpResponse
{
$task = $this->task($data, [20000]);
return $this->response($task, $limit);
}
public function submission(array $data): SerpSubmission
{
$task = $this->task($data, [20000, 20100]);
$id = $task['id'] ?? null;
if (! is_string($id) || ! preg_match('/^[0-9a-f-]{36}$/i', $id)) {
throw new UnexpectedProviderResponseException('DataForSEO task id is missing.');
}
return new SerpSubmission('dataforseo', $id, $this->cost($task, $data), 'USD', (int) $task['status_code']);
}
public function collection(array $data, string $taskId, int $limit): SerpTaskResult
{
$this->envelope($data);
$tasks = $data['tasks'];
if ($tasks === [] || ! isset($tasks[0]) || ! is_array($tasks[0])) {
return SerpTaskResult::waiting();
}
$task = $tasks[0];
if (($task['id'] ?? null) !== $taskId) {
throw new UnexpectedProviderResponseException('DataForSEO task id mismatch.');
}
$status = $task['status_code'] ?? null;
if (! is_int($status)) {
throw new UnexpectedProviderResponseException('DataForSEO task status is missing.');
}
if (in_array($status, [20100, 40601, 40602], true) || ! array_key_exists('result', $task) || $task['result'] === null) {
return SerpTaskResult::waiting($status);
}
if ($status !== 20000) {
throw $this->errors->api($status);
}
$response = $this->response($task, $limit);
return SerpTaskResult::ready($response, $this->cost($task, $data), 'USD', $status);
}
private function envelope(array $data): void
{
$status = $data['status_code'] ?? null;
if (! is_int($status)) {
throw new UnexpectedProviderResponseException('DataForSEO envelope status is missing.');
}if ($status !== 20000) {
throw $this->errors->api($status);
}if (! isset($data['tasks']) || ! is_array($data['tasks'])) {
throw new UnexpectedProviderResponseException('DataForSEO tasks are missing.');
}
}
private function task(array $data, array $accepted): array
{
$this->envelope($data);
$task = $data['tasks'][0] ?? null;
if (! is_array($task) || ! is_int($task['status_code'] ?? null)) {
throw new UnexpectedProviderResponseException('DataForSEO task is malformed.');
}if (! in_array($task['status_code'], $accepted, true)) {
throw $this->errors->api($task['status_code']);
}
return $task;
}
private function response(array $task, int $limit): SerpResponse
{
$result = $task['result'] ?? null;
if (! is_array($result) || ! isset($result[0]) || ! is_array($result[0])) {
throw new UnexpectedProviderResponseException('DataForSEO result is missing.');
}
$items = $result[0]['items'] ?? null;
if (! is_array($items)) {
throw new UnexpectedProviderResponseException('DataForSEO items are missing.');
}
$rows = [];
$seen = [];
foreach ($items as $item) {
if (! is_array($item) || ($item['type'] ?? null) !== 'organic') {
continue;
}$position = $item['rank_group'] ?? null;
$url = $item['url'] ?? null;
if (! is_int($position) || $position < 1 || $position > $limit || ! is_string($url) || isset($seen[$position])) {
continue;
}try {
$rows[] = new SerpResultData($position, $url, $this->text($item['title'] ?? null, 500), $this->text($item['breadcrumb'] ?? null, 500), $this->text($item['description'] ?? null, 5000));
$seen[$position] = true;
} catch (\InvalidArgumentException) {
}
}
usort($rows, fn ($a, $b) => $a->position <=> $b->position);
return new SerpResponse('dataforseo', $rows, is_string($task['id'] ?? null) ? $task['id'] : null, $limit, $this->cost($task, []), 'USD', is_int($task['status_code'] ?? null) ? $task['status_code'] : null);
}
private function text(mixed $value, int $max): ?string
{
if ($value === null) {
return null;
}if (! is_string($value)) {
return null;
}
return mb_substr($value, 0, $max);
}
private function cost(array $task, array $data): ?float
{
$cost = $task['cost'] ?? $data['cost'] ?? null;
return is_int($cost) || is_float($cost) ? (float) $cost : null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Serp\Providers;
use App\Serp\Contracts\AsyncSerpProvider;
use App\Serp\Data\SerpQuery;
use App\Serp\Data\SerpResponse;
use App\Serp\Data\SerpResultData;
use App\Serp\Data\SerpSubmission;
use App\Serp\Data\SerpTaskResult;
use App\Serp\Exceptions\PermanentProviderException;
use App\Serp\Exceptions\ProviderConfigurationException;
use App\Serp\Exceptions\ProviderRateLimitException;
use App\Serp\Exceptions\ProviderTimeoutException;
final class FakeSerpProvider implements AsyncSerpProvider
{
public static string $mode = 'success';
public static array $results = [];
public function key(): string
{
return 'fake';
}
public function supports(SerpQuery $q): bool
{
return in_array($q->searchEngine, ['google', 'bing'], true);
}
public function search(SerpQuery $q): SerpResponse
{
$this->guard();
$this->errors();
$results = self::$results ?: [new SerpResultData(1, 'https://example.com/result', 'Example', 'example.com', 'Deterministic test result')];
return new SerpResponse($this->key(), $results, 'fake-reference', $q->limit);
}
public function submit(SerpQuery $q): SerpSubmission
{
$this->guard();
$this->errors();
return new SerpSubmission($this->key(), '00000000-0000-0000-0000-000000000001', 0.0, 'USD', 20100);
}
public function collect(string $taskId, int $limit): SerpTaskResult
{
$this->guard();
$this->errors();
if (self::$mode === 'waiting') {
return SerpTaskResult::waiting(40601);
}$response = $this->search(new SerpQuery('fake', 'google', 'TR', 'tr', 'desktop', $limit));
$response = new SerpResponse($this->key(), $response->results, $taskId, $limit);
return SerpTaskResult::ready($response, 0.0, 'USD', 20000);
}
private function guard(): void
{
if (! (app()->environment('testing') || (app()->environment('local') && config('serp.allow_fake_local')))) {
throw new ProviderConfigurationException('Fake provider forbidden.');
}
}
private function errors(): void
{
if (self::$mode === 'timeout') {
throw new ProviderTimeoutException('secret timeout');
}if (self::$mode === 'rate_limit') {
throw new ProviderRateLimitException('secret rate');
}if (self::$mode === 'permanent') {
throw new PermanentProviderException('secret permanent');
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Serp;
use App\Serp\Contracts\SerpProvider;
use App\Serp\Exceptions\ProviderConfigurationException;
final class SerpProviderRegistry
{
private array $providers = [];
public function register(SerpProvider $provider): void
{
$this->providers[$provider->key()] = $provider;
}
public function resolve(?string $key = null): SerpProvider
{
$key = $key ?: config('serp.provider');
if (! $key || ! isset($this->providers[$key])) {
throw new ProviderConfigurationException('Unknown provider.');
}
return $this->providers[$key];
}
}