Proje dosyaları eklendi
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user