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:
2026-02-21 11:41:17 +01:00
parent 10ddd2ac1c
commit b2aead1fbe
15 changed files with 541 additions and 219 deletions

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

View File

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