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
@@ -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;
}
}