feat: Integrate DataForSEO for indexed pages tracking
- Updated CRON documentation to include DataForSEO metrics synchronization. - Enhanced SettingsController to manage DataForSEO API credentials and settings. - Modified SiteController to handle DataForSEO domain input. - Updated Site model to accommodate DataForSEO data handling. - Added methods in SiteSeoMetric model for DataForSEO data retrieval and validation. - Implemented SiteSeoSyncService to synchronize SEO metrics from both SEMSTORM and DataForSEO. - Enhanced dashboard templates to display indexed pages data. - Updated settings and site creation/edit templates to include DataForSEO fields. - Created migration for adding DataForSEO related columns in the database. - Developed DataForSeoService to fetch indexed pages count from DataForSEO API.
This commit is contained in:
118
src/Services/DataForSeoService.php
Normal file
118
src/Services/DataForSeoService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Config;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
|
||||
class DataForSeoService
|
||||
{
|
||||
private Client $http;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$timeout = (float) Config::getDbSetting(
|
||||
'dataforseo_timeout_seconds',
|
||||
Config::get('DATAFORSEO_TIMEOUT_SECONDS', '30')
|
||||
);
|
||||
|
||||
$this->http = new Client([
|
||||
'timeout' => max(5, $timeout),
|
||||
'verify' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function fetchIndexedPagesCount(string $domain): array
|
||||
{
|
||||
$baseUrl = rtrim((string) Config::getDbSetting(
|
||||
'dataforseo_api_base',
|
||||
Config::get('DATAFORSEO_API_BASE', 'https://api.dataforseo.com')
|
||||
), '/');
|
||||
$login = trim((string) Config::getDbSetting('dataforseo_login', Config::get('DATAFORSEO_LOGIN', '')));
|
||||
$password = trim((string) Config::getDbSetting('dataforseo_password', Config::get('DATAFORSEO_PASSWORD', '')));
|
||||
$locationCode = (int) Config::getDbSetting('dataforseo_location_code', Config::get('DATAFORSEO_LOCATION_CODE', '2616'));
|
||||
$languageCode = strtolower(trim((string) Config::getDbSetting(
|
||||
'dataforseo_language_code',
|
||||
Config::get('DATAFORSEO_LANGUAGE_CODE', 'pl')
|
||||
)));
|
||||
|
||||
if ($login === '' || $password === '') {
|
||||
throw new \RuntimeException('Brak danych logowania DataForSEO (login/haslo).');
|
||||
}
|
||||
|
||||
if ($locationCode <= 0) {
|
||||
$locationCode = 2616;
|
||||
}
|
||||
|
||||
if ($languageCode === '') {
|
||||
$languageCode = 'pl';
|
||||
}
|
||||
|
||||
$payload = [[
|
||||
'keyword' => 'site:' . $domain,
|
||||
'location_code' => $locationCode,
|
||||
'language_code' => $languageCode,
|
||||
'device' => 'desktop',
|
||||
'os' => 'windows',
|
||||
'depth' => 10,
|
||||
]];
|
||||
|
||||
try {
|
||||
$response = $this->http->post($baseUrl . '/v3/serp/google/organic/live/regular', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'BackPRO/1.0',
|
||||
],
|
||||
'auth' => [$login, $password],
|
||||
'json' => $payload,
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new \RuntimeException('Blad pobierania statystyk DataForSEO: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$raw = (string) $response->getBody();
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new \RuntimeException('DataForSEO zwrocil niepoprawny JSON.');
|
||||
}
|
||||
|
||||
return [
|
||||
'indexed_pages' => $this->extractIndexedPages($decoded),
|
||||
'payload' => $raw,
|
||||
];
|
||||
}
|
||||
|
||||
private function extractIndexedPages(array $payload): int
|
||||
{
|
||||
$tasks = $payload['tasks'] ?? null;
|
||||
if (!is_array($tasks) || empty($tasks) || !is_array($tasks[0] ?? null)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$task = $tasks[0];
|
||||
$result = null;
|
||||
if (is_array($task['result'] ?? null) && !empty($task['result'][0]) && is_array($task['result'][0])) {
|
||||
$result = $task['result'][0];
|
||||
}
|
||||
|
||||
if (!is_array($result)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Prefer dedicated total counters from SERP response.
|
||||
if (isset($result['se_results_count'])) {
|
||||
return max(0, (int) $result['se_results_count']);
|
||||
}
|
||||
|
||||
if (isset($result['items_count'])) {
|
||||
return max(0, (int) $result['items_count']);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ use App\Models\SiteSeoMetric;
|
||||
class SiteSeoSyncService
|
||||
{
|
||||
private SemstormService $semstorm;
|
||||
private DataForSeoService $dataforseo;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->semstorm = new SemstormService();
|
||||
$this->dataforseo = new DataForSeoService();
|
||||
}
|
||||
|
||||
public function syncSite(array $site, ?\DateTimeImmutable $month = null, bool $force = false): array
|
||||
@@ -24,53 +26,96 @@ class SiteSeoSyncService
|
||||
}
|
||||
|
||||
$metricMonth = ($month ?? new \DateTimeImmutable('first day of this month'))->format('Y-m-01');
|
||||
$existing = SiteSeoMetric::findForMonth($siteId, $metricMonth);
|
||||
$hasMonth = $existing !== null;
|
||||
$hasDataforseo = SiteSeoMetric::hasDataforseoForMonth($siteId, $metricMonth);
|
||||
|
||||
if (!$force && SiteSeoMetric::existsForMonth($siteId, $metricMonth)) {
|
||||
if (!$force && $hasMonth && $hasDataforseo) {
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => 'skipped',
|
||||
'message' => 'Dane SEO dla tego miesiaca juz istnieja.',
|
||||
'message' => 'Dane SEO dla tego miesiaca juz istnieja (SEMSTORM + DataForSEO).',
|
||||
'metric_month' => $metricMonth,
|
||||
];
|
||||
}
|
||||
|
||||
$domain = $this->resolveDomain($site);
|
||||
if ($domain === '') {
|
||||
return ['success' => false, 'status' => 'error', 'message' => 'Brak domeny SEMSTORM dla strony.'];
|
||||
}
|
||||
$metricsToSave = [
|
||||
'top3' => (int) ($existing['top3'] ?? 0),
|
||||
'top10' => (int) ($existing['top10'] ?? 0),
|
||||
'top20' => (int) ($existing['top20'] ?? 0),
|
||||
'top50' => (int) ($existing['top50'] ?? 0),
|
||||
'traffic' => (int) ($existing['traffic'] ?? 0),
|
||||
'indexed_pages' => (int) ($existing['indexed_pages'] ?? 0),
|
||||
];
|
||||
|
||||
$payload = $this->extractPayloadParts($existing['source_payload'] ?? null);
|
||||
$semstormDomain = $this->resolveDomain($site);
|
||||
$dataforseoDomain = $this->resolveDataforseoDomain($site, $semstormDomain);
|
||||
$errors = [];
|
||||
$didSync = false;
|
||||
|
||||
try {
|
||||
$metrics = $this->semstorm->fetchDomainMetrics($domain, new \DateTimeImmutable($metricMonth));
|
||||
SiteSeoMetric::upsertMonthly($siteId, $metricMonth, $metrics, $metrics['payload'] ?? null);
|
||||
if ($force || !$hasMonth) {
|
||||
if ($semstormDomain !== '') {
|
||||
$semstormMetrics = $this->semstorm->fetchDomainMetrics($semstormDomain, new \DateTimeImmutable($metricMonth));
|
||||
$metricsToSave['top3'] = (int) ($semstormMetrics['top3'] ?? 0);
|
||||
$metricsToSave['top10'] = (int) ($semstormMetrics['top10'] ?? 0);
|
||||
$metricsToSave['top20'] = (int) ($semstormMetrics['top20'] ?? 0);
|
||||
$metricsToSave['top50'] = (int) ($semstormMetrics['top50'] ?? 0);
|
||||
$metricsToSave['traffic'] = (int) ($semstormMetrics['traffic'] ?? 0);
|
||||
$payload['semstorm'] = is_string($semstormMetrics['payload'] ?? null) ? $semstormMetrics['payload'] : null;
|
||||
$didSync = true;
|
||||
} else {
|
||||
$errors[] = 'Brak domeny SEMSTORM.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($force || !$hasDataforseo) {
|
||||
if ($dataforseoDomain !== '') {
|
||||
$dataforseo = $this->dataforseo->fetchIndexedPagesCount($dataforseoDomain);
|
||||
$metricsToSave['indexed_pages'] = max(0, (int) ($dataforseo['indexed_pages'] ?? 0));
|
||||
$payload['dataforseo'] = is_string($dataforseo['payload'] ?? null) ? $dataforseo['payload'] : null;
|
||||
$didSync = true;
|
||||
} else {
|
||||
$errors[] = 'Brak domeny DataForSEO.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$didSync) {
|
||||
throw new \RuntimeException(!empty($errors) ? implode(' ', $errors) : 'Brak danych do synchronizacji.');
|
||||
}
|
||||
|
||||
$payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
SiteSeoMetric::upsertMonthly($siteId, $metricMonth, $metricsToSave, is_string($payloadJson) ? $payloadJson : null);
|
||||
|
||||
Logger::info(
|
||||
"SEMSTORM sync OK: site_id={$siteId}, domain={$domain}, month={$metricMonth}",
|
||||
"SEO sync OK: site_id={$siteId}, semstorm_domain={$semstormDomain}, dataforseo_domain={$dataforseoDomain}, month={$metricMonth}",
|
||||
'semstorm'
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => 'saved',
|
||||
'message' => 'Zapisano dane SEO z SEMSTORM.',
|
||||
'message' => 'Zapisano/uzupelniono dane SEO (SEMSTORM + DataForSEO).',
|
||||
'metric_month' => $metricMonth,
|
||||
'metrics' => [
|
||||
'top3' => (int) ($metrics['top3'] ?? 0),
|
||||
'top10' => (int) ($metrics['top10'] ?? 0),
|
||||
'top20' => (int) ($metrics['top20'] ?? 0),
|
||||
'top50' => (int) ($metrics['top50'] ?? 0),
|
||||
'traffic' => (int) ($metrics['traffic'] ?? 0),
|
||||
],
|
||||
'metrics' => $metricsToSave,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error(
|
||||
"SEMSTORM sync FAIL: site_id={$siteId}, domain={$domain}, month={$metricMonth}, error={$e->getMessage()}",
|
||||
if (str_contains($e->getMessage(), 'DataForSEO')) {
|
||||
Logger::warning(
|
||||
"DataForSEO sync WARN: site_id={$siteId}, semstorm_domain={$semstormDomain}, dataforseo_domain={$dataforseoDomain}, month={$metricMonth}, error={$e->getMessage()}",
|
||||
'semstorm'
|
||||
);
|
||||
}
|
||||
Logger::info(
|
||||
"SEO sync FAIL: site_id={$siteId}, semstorm_domain={$semstormDomain}, dataforseo_domain={$dataforseoDomain}, month={$metricMonth}, error={$e->getMessage()}",
|
||||
'semstorm'
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'error',
|
||||
'message' => 'Blad pobierania SEMSTORM: ' . $e->getMessage(),
|
||||
'message' => 'Blad synchronizacji SEO: ' . $e->getMessage(),
|
||||
'metric_month' => $metricMonth,
|
||||
];
|
||||
}
|
||||
@@ -95,4 +140,36 @@ class SiteSeoSyncService
|
||||
|
||||
return strtolower($host);
|
||||
}
|
||||
|
||||
private function resolveDataforseoDomain(array $site, string $fallbackDomain): string
|
||||
{
|
||||
$manual = trim((string) ($site['dataforseo_domain'] ?? ''));
|
||||
if ($manual !== '') {
|
||||
return strtolower($manual);
|
||||
}
|
||||
|
||||
return $fallbackDomain;
|
||||
}
|
||||
|
||||
private function extractPayloadParts($sourcePayload): array
|
||||
{
|
||||
$default = ['semstorm' => null, 'dataforseo' => null];
|
||||
$raw = trim((string) $sourcePayload);
|
||||
if ($raw === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [
|
||||
'semstorm' => $raw,
|
||||
'dataforseo' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'semstorm' => is_string($decoded['semstorm'] ?? null) ? $decoded['semstorm'] : null,
|
||||
'dataforseo' => is_string($decoded['dataforseo'] ?? null) ? $decoded['dataforseo'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user