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

@@ -13,6 +13,12 @@ SEMSTORM_LOGIN=
SEMSTORM_PASSWORD=
SEMSTORM_API_BASE=https://api.semstorm.com
SEMSTORM_TIMEOUT_SECONDS=30
DATAFORSEO_LOGIN=
DATAFORSEO_PASSWORD=
DATAFORSEO_API_BASE=https://api.dataforseo.com
DATAFORSEO_TIMEOUT_SECONDS=30
DATAFORSEO_LOCATION_CODE=2616
DATAFORSEO_LANGUAGE_CODE=pl
SEO_TRIGGER_TOKEN=change-this-to-long-random-token
APP_URL=https://backpro.projectpro.pl

View File

@@ -5,127 +5,72 @@
"css": {
"app.css": {
"type": "-",
<<<<<<< HEAD
"size": 1982,
"lmtime": 1771375416079,
=======
"size": 1915,
"lmtime": 1771354445514,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
}
},
"js": {
"app.js": {
"type": "-",
<<<<<<< HEAD
"size": 12352,
"lmtime": 1771375416080,
=======
"size": 12059,
"lmtime": 1771354440500,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
}
},
"wp-theme-backpro-news": {
"archive.php": {
"type": "-",
<<<<<<< HEAD
"size": 908,
"lmtime": 1771375416081,
=======
"size": 880,
"lmtime": 1771352690031,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"footer.php": {
"type": "-",
<<<<<<< HEAD
"size": 324,
"lmtime": 1771375416082,
=======
"size": 308,
"lmtime": 1771352651733,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"front-page.php": {
"type": "-",
<<<<<<< HEAD
"size": 4350,
"lmtime": 1771375416082,
=======
"size": 4256,
"lmtime": 1771353361367,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"functions.php": {
"type": "-",
<<<<<<< HEAD
"size": 847,
"lmtime": 1771375416083,
=======
"size": 811,
"lmtime": 1771352633840,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"header.php": {
"type": "-",
<<<<<<< HEAD
"size": 966,
"lmtime": 1771375416084,
=======
"size": 937,
"lmtime": 1771352640810,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"index.php": {
"type": "-",
<<<<<<< HEAD
"size": 1029,
"lmtime": 1771375416085,
=======
"size": 995,
"lmtime": 1771352675013,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"search.php": {
"type": "-",
<<<<<<< HEAD
"size": 683,
"lmtime": 1771375416085,
=======
"size": 660,
"lmtime": 1771352696638,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"single.php": {
"type": "-",
<<<<<<< HEAD
"size": 6116,
"lmtime": 1771375416086,
=======
"size": 5988,
"lmtime": 1771353631148,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"style.css": {
"type": "-",
<<<<<<< HEAD
"size": 10723,
"lmtime": 1771375416087,
=======
"size": 10119,
"lmtime": 1771353528618,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
}
}
@@ -171,8 +116,8 @@
"config": {
"routes.php": {
"type": "-",
"size": 3489,
"lmtime": 1771375416088,
"size": 3968,
"lmtime": 1771627464051,
"modified": false
}
},
@@ -182,6 +127,12 @@
"size": 1078,
"lmtime": 1771149803282,
"modified": false
},
"semstorm.php": {
"type": "-",
"size": 1874,
"lmtime": 1771620180848,
"modified": false
}
},
"docs": {
@@ -193,14 +144,14 @@
},
"CRON.md": {
"type": "-",
"size": 4340,
"lmtime": 1771274893249,
"size": 4784,
"lmtime": 1771620216534,
"modified": false
},
"DATABASE.md": {
"type": "-",
"size": 7265,
"lmtime": 1771274893237,
"size": 8354,
"lmtime": 1771620224191,
"modified": false
},
"PLAN.md": {
@@ -212,14 +163,14 @@
},
".env": {
"type": "-",
"size": 330,
"lmtime": 1771446981282,
"size": 389,
"lmtime": 1771626264475,
"modified": false
},
".env.example": {
"type": "-",
"size": 285,
"lmtime": 1771275211060,
"size": 442,
"lmtime": 1771626490188,
"modified": false
},
".htaccess": {
@@ -282,6 +233,18 @@
"size": 333,
"lmtime": 1771375416090,
"modified": false
},
"008_cron_logs.sql": {
"type": "-",
"size": 480,
"lmtime": 1771618047955,
"modified": false
},
"009_site_seo_metrics.sql": {
"type": "-",
"size": 829,
"lmtime": 1771620035555,
"modified": false
}
},
"src": {
@@ -306,8 +269,8 @@
},
"DashboardController.php": {
"type": "-",
"size": 858,
"lmtime": 1771149691435,
"size": 1339,
"lmtime": 1771627595983,
"modified": false
},
"GlobalTopicController.php": {
@@ -322,6 +285,12 @@
"lmtime": 1771270380033,
"modified": false
},
"LogController.php": {
"type": "-",
"size": 2597,
"lmtime": 1771618047955,
"modified": false
},
"PublishController.php": {
"type": "-",
"size": 2668,
@@ -330,20 +299,20 @@
},
"SettingsController.php": {
"type": "-",
"size": 1645,
"lmtime": 1771274258130,
"size": 1868,
"lmtime": 1771626466574,
"modified": false
},
"SiteController.php": {
"type": "-",
"size": 10788,
"lmtime": 1771375416092,
"size": 16907,
"lmtime": 1771628191087,
"modified": false
},
"TopicController.php": {
"type": "-",
"size": 3446,
"lmtime": 1771271990709,
"size": 4009,
"lmtime": 1771628730441,
"modified": false
}
},
@@ -400,8 +369,8 @@
"Helpers": {
"Logger.php": {
"type": "-",
"size": 2028,
"lmtime": 1771375416092,
"size": 3120,
"lmtime": 1771618047955,
"modified": false
},
"Validator.php": {
@@ -414,8 +383,8 @@
"Models": {
"Article.php": {
"type": "-",
"size": 4960,
"lmtime": 1771375416093,
"size": 5674,
"lmtime": 1771617909267,
"modified": false
},
"GlobalTopic.php": {
@@ -426,8 +395,14 @@
},
"Site.php": {
"type": "-",
"size": 858,
"lmtime": 1771274660626,
"size": 1441,
"lmtime": 1771627194103,
"modified": false
},
"SiteSeoMetric.php": {
"type": "-",
"size": 4817,
"lmtime": 1771627676032,
"modified": false
},
"Topic.php": {
@@ -470,8 +445,20 @@
},
"PublisherService.php": {
"type": "-",
"size": 8311,
"lmtime": 1771375416098,
"size": 11532,
"lmtime": 1771617909267,
"modified": false
},
"SemstormService.php": {
"type": "-",
"size": 5897,
"lmtime": 1771627080134,
"modified": false
},
"SiteSeoSyncService.php": {
"type": "-",
"size": 3146,
"lmtime": 1771620084332,
"modified": false
},
"TopicBalancer.php": {
@@ -492,18 +479,12 @@
"logs": {
"image_2026-02-17.log": {
"type": "-",
<<<<<<< HEAD
"size": 72,
"lmtime": 1771375416100,
=======
"size": 71,
"lmtime": 1771350104612,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"installer_2026-02-17.log": {
"type": "-",
<<<<<<< HEAD
"size": 4379,
"lmtime": 1771375416101,
"modified": false
@@ -512,21 +493,10 @@
"type": "-",
"size": 118,
"lmtime": 1771375416102,
=======
"size": 4436,
"lmtime": 1771350104684,
"modified": true
},
"openai_2026-02-17.log": {
"type": "-",
"size": 117,
"lmtime": 1771350104754,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"publish_2026-02-17.log": {
"type": "-",
<<<<<<< HEAD
"size": 2539,
"lmtime": 1771375416102,
"modified": false
@@ -535,21 +505,12 @@
"type": "-",
"size": 702,
"lmtime": 0,
=======
"size": 2508,
"lmtime": 1771350104824,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"wordpress_2026-02-17.log": {
"type": "-",
<<<<<<< HEAD
"size": 1471,
"lmtime": 1771375416103,
=======
"size": 1459,
"lmtime": 1771350285395,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
}
}
@@ -558,13 +519,8 @@
"articles": {
"index.php": {
"type": "-",
<<<<<<< HEAD
"size": 6036,
"lmtime": 1771375416104,
=======
"size": 5926,
"lmtime": 1771354089325,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"show.php": {
@@ -602,6 +558,12 @@
"size": 8067,
"lmtime": 1771375416107,
"modified": false
},
"seo-stats.php": {
"type": "-",
"size": 7955,
"lmtime": 1771627707865,
"modified": false
}
},
"global-topics": {
@@ -612,78 +574,6 @@
"modified": false
}
},
"layout": {
"header.php": {
"type": "-",
"size": 623,
<<<<<<< HEAD
"lmtime": 1771375416109,
=======
"lmtime": 1771354088998,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"main.php": {
"type": "-",
<<<<<<< HEAD
"size": 1821,
"lmtime": 1771375416109,
"modified": false
=======
"size": 1816,
"lmtime": 1771149813250,
"modified": true
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
},
"sidebar.php": {
"type": "-",
"size": 1894,
"lmtime": 1771375416110,
"modified": false
}
},
"settings": {
"index.php": {
"type": "-",
"size": 5509,
"lmtime": 1771274394027,
"modified": false
}
},
"sites": {
"create.php": {
"type": "-",
"size": 4883,
"lmtime": 1771274870419,
"modified": false
},
"dashboard.php": {
"type": "-",
"size": 12823,
"lmtime": 1771375416111,
"modified": false
},
"edit.php": {
"type": "-",
"size": 18800,
"lmtime": 1771375416112,
"modified": false
},
"index.php": {
"type": "-",
"size": 4961,
"lmtime": 1771375416113,
"modified": false
}
},
"topics": {
"index.php": {
"type": "-",
"size": 11477,
"lmtime": 1771375416114,
"modified": false
}
},
"installer": {
"index.php": {
"type": "-",
@@ -691,16 +581,94 @@
"lmtime": 1771270472738,
"modified": false
}
},
"layout": {
"header.php": {
"type": "-",
"size": 623,
"lmtime": 1771375416109,
"modified": false
},
"main.php": {
"type": "-",
"size": 1821,
"lmtime": 1771375416109,
"modified": false
},
"sidebar.php": {
"type": "-",
"size": 2261,
"lmtime": 1771627469556,
"modified": false
}
},
"settings": {
"index.php": {
"type": "-",
"size": 7022,
"lmtime": 1771626482753,
"modified": false
}
},
"sites": {
"create.php": {
"type": "-",
"size": 5295,
"lmtime": 1771620136407,
"modified": false
},
"dashboard.php": {
"type": "-",
"size": 12963,
"lmtime": 1771625982185,
"modified": false
},
"edit.php": {
"type": "-",
"size": 13837,
"lmtime": 1771628695879,
"modified": false
},
"index.php": {
"type": "-",
"size": 5194,
"lmtime": 1771625987517,
"modified": false
},
"seo.php": {
"type": "-",
"size": 7545,
"lmtime": 1771626686595,
"modified": false
}
},
"topics": {
"index.php": {
"type": "-",
"size": 14943,
"lmtime": 1771628879393,
"modified": false
}
},
"logs": {
"index.php": {
"type": "-",
"size": 3405,
"lmtime": 1771617998906,
"modified": false
}
}
},
"tmp_debug_metrics.php": {
"type": "-",
"size": 393,
"lmtime": 1771626947881,
"modified": false
},
"TODO.md": {
"type": "-",
"size": 233,
<<<<<<< HEAD
"lmtime": 1771373773622,
=======
"lmtime": 0,
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
"modified": false
},
"vendor": {
@@ -824,6 +792,12 @@
"lmtime": 1771150407034,
"modified": false
}
},
"tmp_semstorm_test.php": {
"type": "-",
"size": 1027,
"lmtime": 1771627094790,
"modified": false
}
}
},

View File

@@ -117,7 +117,7 @@ Przycisk "Opublikuj teraz" na dashboardzie wywołuje `PublishController@run`, kt
| 401 z WordPress | Złe dane API | Sprawdź api_user/api_token w konfiguracji strony |
| 429 z OpenAI | Rate limit | Zwiększ interwał CRON lub poczekaj |
## Miesieczna synchronizacja metryk SEO (SEMSTORM)
## Miesieczna synchronizacja metryk SEO (SEMSTORM + DataForSEO)
Dodaj osobne zadanie CRON uruchamiane raz w miesiacu:
@@ -128,5 +128,6 @@ Dodaj osobne zadanie CRON uruchamiane raz w miesiacu:
Skrypt:
- pobiera aktywne strony (`sites.is_active = 1`),
- pobiera metryki z SEMSTORM dla domeny strony,
- pobiera liczbe zaindeksowanych stron (zapytanie `site:domena`) z DataForSEO,
- zapisuje dane do `site_seo_metrics` dla biezacego miesiaca,
- pomija rekord, jesli miesiac jest juz zapisany (idempotencja).

View File

@@ -0,0 +1,7 @@
-- BackPRO DataForSEO indexed pages integration
ALTER TABLE sites
ADD COLUMN dataforseo_domain VARCHAR(255) NULL AFTER semstorm_domain;
ALTER TABLE site_seo_metrics
ADD COLUMN indexed_pages INT NOT NULL DEFAULT 0 AFTER traffic;

View File

@@ -25,6 +25,12 @@ class SettingsController extends Controller
'semstorm_password',
'semstorm_api_base',
'semstorm_timeout_seconds',
'dataforseo_login',
'dataforseo_password',
'dataforseo_api_base',
'dataforseo_timeout_seconds',
'dataforseo_location_code',
'dataforseo_language_code',
];
private array $settingDefaults = [
@@ -36,6 +42,10 @@ class SettingsController extends Controller
'image_generation_prompt' => ImageService::DEFAULT_FREEPIK_PROMPT_TEMPLATE,
'semstorm_api_base' => 'https://api.semstorm.com',
'semstorm_timeout_seconds' => '30',
'dataforseo_api_base' => 'https://api.dataforseo.com',
'dataforseo_timeout_seconds' => '30',
'dataforseo_location_code' => '2616',
'dataforseo_language_code' => 'pl',
];
public function index(): void

View File

@@ -68,6 +68,7 @@ class SiteController extends Controller
'name' => $this->input('name'),
'url' => rtrim($this->input('url'), '/'),
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
'dataforseo_domain' => $this->input('dataforseo_domain') ?: null,
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
@@ -149,6 +150,7 @@ class SiteController extends Controller
'name' => $this->input('name'),
'url' => rtrim($this->input('url'), '/'),
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
'dataforseo_domain' => $this->input('dataforseo_domain') ?: null,
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),

View File

@@ -39,7 +39,12 @@ class Site extends Model
ON m.site_id = s.id
AND m.metric_month = :metric_month
WHERE s.is_active = 1
AND m.id IS NULL
AND (
m.id IS NULL
OR m.source_payload IS NULL
OR m.source_payload NOT LIKE '%\"dataforseo\"%'
OR m.source_payload LIKE '%\"dataforseo\":null%'
)
ORDER BY s.id ASC
LIMIT 1";

View File

@@ -21,19 +21,57 @@ class SiteSeoMetric extends Model
return (bool) $stmt->fetchColumn();
}
public static function findForMonth(int $siteId, string $metricMonth): ?array
{
$stmt = self::db()->prepare(
'SELECT id, metric_month, top3, top10, top20, top50, traffic, indexed_pages, source_payload, updated_at
FROM site_seo_metrics
WHERE site_id = :site_id AND metric_month = :metric_month
LIMIT 1'
);
$stmt->execute([
'site_id' => $siteId,
'metric_month' => $metricMonth,
]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function hasDataforseoForMonth(int $siteId, string $metricMonth): bool
{
$row = self::findForMonth($siteId, $metricMonth);
if (!$row) {
return false;
}
$payloadRaw = trim((string) ($row['source_payload'] ?? ''));
if ($payloadRaw === '') {
return false;
}
$decoded = json_decode($payloadRaw, true);
if (!is_array($decoded) || !array_key_exists('dataforseo', $decoded)) {
return false;
}
return is_string($decoded['dataforseo']) && trim($decoded['dataforseo']) !== '';
}
public static function upsertMonthly(int $siteId, string $metricMonth, array $metrics, ?string $payload = null): void
{
$stmt = self::db()->prepare(
'INSERT INTO site_seo_metrics
(site_id, metric_month, top3, top10, top20, top50, traffic, source_payload)
(site_id, metric_month, top3, top10, top20, top50, traffic, indexed_pages, source_payload)
VALUES
(:site_id, :metric_month, :top3, :top10, :top20, :top50, :traffic, :source_payload)
(:site_id, :metric_month, :top3, :top10, :top20, :top50, :traffic, :indexed_pages, :source_payload)
ON DUPLICATE KEY UPDATE
top3 = VALUES(top3),
top10 = VALUES(top10),
top20 = VALUES(top20),
top50 = VALUES(top50),
traffic = VALUES(traffic),
indexed_pages = VALUES(indexed_pages),
source_payload = VALUES(source_payload),
updated_at = CURRENT_TIMESTAMP'
);
@@ -46,6 +84,7 @@ class SiteSeoMetric extends Model
'top20' => max(0, (int) ($metrics['top20'] ?? 0)),
'top50' => max(0, (int) ($metrics['top50'] ?? 0)),
'traffic' => max(0, (int) ($metrics['traffic'] ?? 0)),
'indexed_pages' => max(0, (int) ($metrics['indexed_pages'] ?? 0)),
'source_payload' => $payload,
]);
}
@@ -53,7 +92,7 @@ class SiteSeoMetric extends Model
public static function findBySite(int $siteId, int $limit = 12): array
{
$stmt = self::db()->prepare(
'SELECT metric_month, top3, top10, top20, top50, traffic, created_at, updated_at
'SELECT metric_month, top3, top10, top20, top50, traffic, indexed_pages, created_at, updated_at
FROM site_seo_metrics
WHERE site_id = :site_id
ORDER BY metric_month DESC
@@ -70,7 +109,7 @@ class SiteSeoMetric extends Model
public static function latestForSite(int $siteId): ?array
{
$stmt = self::db()->prepare(
'SELECT metric_month, top3, top10, top20, top50, traffic, updated_at
'SELECT metric_month, top3, top10, top20, top50, traffic, indexed_pages, updated_at
FROM site_seo_metrics
WHERE site_id = :site_id
ORDER BY metric_month DESC
@@ -84,7 +123,7 @@ class SiteSeoMetric extends Model
public static function latestForAllSites(string $sort = 'traffic', string $dir = 'desc'): array
{
$allowedSort = ['top3', 'top10', 'top20', 'top50', 'traffic', 'metric_month', 'updated_at', 'site_name'];
$allowedSort = ['top3', 'top10', 'top20', 'top50', 'traffic', 'indexed_pages', 'metric_month', 'updated_at', 'site_name'];
if (!in_array($sort, $allowedSort, true)) {
$sort = 'traffic';
}
@@ -109,6 +148,7 @@ class SiteSeoMetric extends Model
m.top20,
m.top50,
m.traffic,
m.indexed_pages,
m.updated_at,
m.created_at,
pm.metric_month AS prev_metric_month,
@@ -116,7 +156,8 @@ class SiteSeoMetric extends Model
pm.top10 AS prev_top10,
pm.top20 AS prev_top20,
pm.top50 AS prev_top50,
pm.traffic AS prev_traffic
pm.traffic AS prev_traffic,
pm.indexed_pages AS prev_indexed_pages
FROM sites s
LEFT JOIN site_seo_metrics m
ON m.id = (

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

View File

@@ -52,6 +52,7 @@
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top20')) ?>">TOP20<?= htmlspecialchars($sortMark('top20')) ?></a></th>
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top50')) ?>">TOP50<?= htmlspecialchars($sortMark('top50')) ?></a></th>
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('traffic')) ?>">Ruch<?= htmlspecialchars($sortMark('traffic')) ?></a></th>
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('indexed_pages')) ?>">Zaindeksowane<?= htmlspecialchars($sortMark('indexed_pages')) ?></a></th>
<th>Aktualizacja</th>
<th></th>
</tr>
@@ -59,7 +60,7 @@
<tbody>
<?php if (empty($rows)): ?>
<tr>
<td colspan="10" class="text-center text-muted py-4">Brak danych SEO.</td>
<td colspan="11" class="text-center text-muted py-4">Brak danych SEO.</td>
</tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
@@ -128,6 +129,14 @@
-
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['metric_month'])): ?>
<?= (int) ($row['indexed_pages'] ?? 0) ?>
<small><?= $formatDelta($row['indexed_pages'] ?? 0, $row['prev_indexed_pages'] ?? null) ?></small>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['updated_at'])): ?>
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string) $row['updated_at']))) ?>

View File

@@ -112,6 +112,46 @@
value="<?= htmlspecialchars($settings['semstorm_timeout_seconds']) ?>" min="5" max="120">
</div>
<h5 class="mb-3 mt-4 border-bottom pb-2">DataForSEO (indeksacja domeny)</h5>
<div class="mb-3">
<label for="dataforseo_login" class="form-label">Login DataForSEO</label>
<input type="text" class="form-control" id="dataforseo_login" name="dataforseo_login"
value="<?= htmlspecialchars($settings['dataforseo_login']) ?>">
</div>
<div class="mb-3">
<label for="dataforseo_password" class="form-label">Haslo DataForSEO</label>
<input type="password" class="form-control" id="dataforseo_password" name="dataforseo_password"
value="<?= htmlspecialchars($settings['dataforseo_password']) ?>">
</div>
<div class="mb-3">
<label for="dataforseo_api_base" class="form-label">Bazowy URL API</label>
<input type="text" class="form-control" id="dataforseo_api_base" name="dataforseo_api_base"
value="<?= htmlspecialchars($settings['dataforseo_api_base']) ?>"
placeholder="https://api.dataforseo.com">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="dataforseo_location_code" class="form-label">Location code</label>
<input type="number" class="form-control" id="dataforseo_location_code" name="dataforseo_location_code"
value="<?= htmlspecialchars($settings['dataforseo_location_code']) ?>" min="1">
</div>
<div class="col-md-6">
<label for="dataforseo_language_code" class="form-label">Language code</label>
<input type="text" class="form-control" id="dataforseo_language_code" name="dataforseo_language_code"
value="<?= htmlspecialchars($settings['dataforseo_language_code']) ?>" placeholder="pl">
</div>
</div>
<div class="mb-3">
<label for="dataforseo_timeout_seconds" class="form-label">Timeout API DataForSEO (sekundy)</label>
<input type="number" class="form-control" id="dataforseo_timeout_seconds" name="dataforseo_timeout_seconds"
value="<?= htmlspecialchars($settings['dataforseo_timeout_seconds']) ?>" min="5" max="120">
</div>
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
</form>
</div>

View File

@@ -22,6 +22,12 @@
<input type="text" class="form-control" id="semstorm_domain" name="semstorm_domain" placeholder="example.com">
<div class="form-text">Jeśli puste, system użyje hosta z URL WordPressa.</div>
</div>
<div class="mb-3">
<label for="dataforseo_domain" class="form-label">Domena DataForSEO (opcjonalnie)</label>
<input type="text" class="form-control" id="dataforseo_domain" name="dataforseo_domain" placeholder="example.com">
<div class="form-text">Jesli puste, system uzyje domeny SEMSTORM lub hosta z URL.</div>
</div>
<div class="mb-3">
<label for="api_user" class="form-label">Użytkownik API (WordPress)</label>

View File

@@ -36,6 +36,12 @@
<input type="text" class="form-control" id="semstorm_domain" name="semstorm_domain" value="<?= htmlspecialchars($site['semstorm_domain'] ?? '') ?>" placeholder="example.com">
<div class="form-text">Jeśli puste, system użyje hosta z URL WordPressa.</div>
</div>
<div class="mb-3">
<label for="dataforseo_domain" class="form-label">Domena DataForSEO (opcjonalnie)</label>
<input type="text" class="form-control" id="dataforseo_domain" name="dataforseo_domain" value="<?= htmlspecialchars($site['dataforseo_domain'] ?? '') ?>" placeholder="example.com">
<div class="form-text">Jesli puste, system uzyje domeny SEMSTORM lub hosta z URL.</div>
</div>
<div class="mb-3">
<label for="api_user" class="form-label">Użytkownik API</label>

View File

@@ -15,7 +15,7 @@
<div class="row g-4">
<div class="col-lg-8">
<div class="card h-100">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Widocznosc SEO (SEMSTORM)</h5>
<form method="post" action="/sites/<?= (int) $site['id'] ?>/seo/sync" data-confirm="Pobrac i nadpisac dane SEO dla biezacego miesiaca?">
@@ -25,7 +25,26 @@
</form>
</div>
<div class="card-body">
<canvas id="seoVisibilityChart" height="320"></canvas>
<div class="position-relative" style="height: 340px;">
<canvas id="seoVisibilityChart"></canvas>
</div>
</div>
</div>
<div class="card mt-4 border-secondary-subtle">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Zaindeksowane strony (DataForSEO)</h6>
</div>
<div class="card-body">
<?php if (!empty($seoLatest)): ?>
<div class="display-6 fw-semibold mb-1"><?= (int) ($seoLatest['indexed_pages'] ?? 0) ?></div>
<p class="small text-muted mb-0">
Ostatnia aktualizacja miesieczna:
<?= htmlspecialchars(date('m.Y', strtotime((string) $seoLatest['metric_month']))) ?>
</p>
<?php else: ?>
<p class="text-muted small mb-0">Brak danych o indeksacji. Uzyj przycisku "Synchronizuj teraz".</p>
<?php endif; ?>
</div>
</div>
</div>
@@ -64,6 +83,7 @@
</div>
</div>
<p class="small mb-2"><strong>Ruch:</strong> <?= (int) $seoLatest['traffic'] ?></p>
<p class="small mb-2"><strong>Zaindeksowane strony:</strong> <?= (int) ($seoLatest['indexed_pages'] ?? 0) ?></p>
<p class="small text-muted mb-0">
Ostatni zapis: <?= htmlspecialchars(date('d.m.Y H:i', strtotime((string) $seoLatest['updated_at']))) ?><br>
Miesiac: <?= htmlspecialchars(date('m.Y', strtotime((string) $seoLatest['metric_month']))) ?>