feat: Add SEMSTORM domain input and SEO panel links

- Added optional SEMSTORM domain input field in site creation and editing forms.
- Introduced SEO panel links in site dashboard and edit pages.
- Created a new cron job for SEMSTORM data synchronization.
- Implemented database migrations for cron logs and site SEO metrics.
- Developed SiteSeoSyncService to handle SEMSTORM data fetching and storage.
- Added logging functionality for cron events.
- Created a new LogController to display cron logs with filtering options.
- Added SEO statistics dashboard with visual representation of metrics.
- Implemented site SEO metrics model for data retrieval and manipulation.
This commit is contained in:
2026-02-20 23:49:40 +01:00
parent 3d3432866c
commit e9a3602576
29 changed files with 1611 additions and 56 deletions

View File

@@ -41,6 +41,12 @@ class PublisherService
{
Logger::info("Publikacja dla strony: {$site['name']} (ID: {$site['id']})", 'publish');
// 0. Najpierw uzupelnij obrazki w juz opublikowanych artykulach bez miniatury.
$publishedWithoutImage = Article::findNextPublishedWithoutImageBySite((int) $site['id']);
if ($publishedWithoutImage) {
return $this->attachMissingImageToPublishedArticle($site, $publishedWithoutImage);
}
// 1. Najpierw publikuj gotowe, nieopublikowane artykuly.
$retryArticle = Article::findNextRetryableBySite((int) $site['id']);
if ($retryArticle) {
@@ -104,6 +110,7 @@ class PublisherService
if ($image) {
$mediaId = $this->wordpress->uploadMedia($site, $image['data'], $image['filename']);
if ($mediaId) {
$imageUrl = 'wp_media:' . $mediaId;
Logger::info("Upload obrazka: media_id={$mediaId}", 'publish');
}
} else {
@@ -215,4 +222,63 @@ class PublisherService
Logger::error("Publikacja nieudana: {$error}", 'publish');
}
private function attachMissingImageToPublishedArticle(array $site, array $article): array
{
$articleId = (int) $article['id'];
$wpPostId = (int) ($article['wp_post_id'] ?? 0);
if ($wpPostId <= 0) {
Logger::warning("Artykul ID {$articleId} oznaczony jako published bez wp_post_id - pomijam", 'publish');
return ['success' => false, 'message' => 'Brak wp_post_id dla opublikowanego artykulu.'];
}
$existingFeaturedMediaId = $this->wordpress->getPostFeaturedMedia($site, $wpPostId);
if ($existingFeaturedMediaId !== null) {
Article::update($articleId, ['image_url' => 'wp_media:' . $existingFeaturedMediaId]);
$message = "Artykul ID {$articleId} juz mial miniaturke (media_id={$existingFeaturedMediaId}) - zaktualizowano marker lokalny.";
Logger::info($message, 'publish');
return ['success' => true, 'message' => $message];
}
$topic = Topic::find((int) ($article['topic_id'] ?? 0));
$topicName = $topic['name'] ?? (string) $article['title'];
$title = (string) ($article['title'] ?? 'Artykul bez tytulu');
Article::markRetryAttempt($articleId);
Logger::info("Proba uzupelnienia obrazka dla artykulu ID {$articleId}: {$title}", 'publish');
$image = $this->imageService->generate($title, (string) $topicName);
if (!$image) {
$message = "Nie udalo sie wygenerowac obrazka dla opublikowanego artykulu ID {$articleId}.";
Logger::warning($message, 'publish');
return ['success' => false, 'message' => $message];
}
$mediaId = $this->wordpress->uploadMedia($site, $image['data'], $image['filename']);
if (!$mediaId) {
$message = "Nie udalo sie wyslac obrazka do WordPress dla artykulu ID {$articleId}.";
Logger::warning($message, 'publish');
return ['success' => false, 'message' => $message];
}
$updated = $this->wordpress->updatePostFeaturedMedia($site, $wpPostId, $mediaId);
if (!$updated) {
$this->wordpress->deleteMedia($site, $mediaId);
$message = "Nie udalo sie podpiac miniaturki (media_id={$mediaId}) do wpisu wp_post_id={$wpPostId}.";
Logger::warning($message, 'publish');
return ['success' => false, 'message' => $message];
}
Article::update($articleId, [
'image_url' => 'wp_media:' . $mediaId,
'error_message' => null,
]);
$message = "Uzupelniono miniaturke dla artykulu ID {$articleId} (wp_post_id={$wpPostId}, media_id={$mediaId}).";
Logger::info($message, 'publish');
return ['success' => true, 'message' => $message];
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Config;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class SemstormService
{
private Client $http;
public function __construct()
{
$timeout = (float) Config::getDbSetting('semstorm_timeout_seconds', Config::get('SEMSTORM_TIMEOUT_SECONDS', '30'));
$this->http = new Client([
'timeout' => max(5, $timeout),
'verify' => false,
]);
}
public function fetchDomainMetrics(string $domain, \DateTimeImmutable $metricMonth): array
{
$baseUrl = rtrim((string) Config::getDbSetting('semstorm_api_base', Config::get('SEMSTORM_API_BASE', 'https://api.semstorm.com')), '/');
$login = trim((string) Config::getDbSetting('semstorm_login', Config::get('SEMSTORM_LOGIN', '')));
$password = trim((string) Config::getDbSetting('semstorm_password', Config::get('SEMSTORM_PASSWORD', '')));
if ($login === '' || $password === '') {
throw new \RuntimeException('Brak danych logowania SEMSTORM (login/haslo).');
}
$accessToken = $this->requestAccessToken($baseUrl, $login, $password);
$payload = [
'domains' => [$domain],
];
try {
$response = $this->http->post($baseUrl . '/semstorm/v4/explorer/domain-stats', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $accessToken,
'User-Agent' => 'BackPRO/1.0',
],
'json' => $payload,
]);
} catch (GuzzleException $e) {
throw new \RuntimeException('Blad pobierania statystyk SEMSTORM: ' . $e->getMessage(), 0, $e);
}
$raw = (string) $response->getBody();
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new \RuntimeException('SEMSTORM zwrocil niepoprawny JSON.');
}
$metrics = $this->extractDomainMetrics($decoded, $domain);
return [
'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),
'payload' => $raw,
];
}
private function requestAccessToken(string $baseUrl, string $login, string $password): string
{
try {
$response = $this->http->post($baseUrl . '/consumer/login', [
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'BackPRO/1.0',
],
'form_params' => [
'username' => $login,
'password' => $password,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Nie udalo sie uzyskac tokenu SEMSTORM: ' . $e->getMessage(), 0, $e);
}
$decoded = json_decode((string) $response->getBody(), true);
$token = is_array($decoded) ? (string) ($decoded['token'] ?? '') : '';
if ($token === '') {
throw new \RuntimeException('Nie udalo sie uzyskac tokenu SEMSTORM: Brak pola token w odpowiedzi.');
}
return $token;
}
private function extractDomainMetrics(array $payload, string $requestedDomain): array
{
$results = $payload['results'] ?? null;
if (!is_array($results) || empty($results)) {
return ['top3' => 0, 'top10' => 0, 'top20' => 0, 'top50' => 0, 'traffic' => 0];
}
$normalizedDomain = strtolower($requestedDomain);
$domainData = null;
foreach ($results as $domain => $statsByDate) {
if (!is_array($statsByDate)) {
continue;
}
if (strtolower((string) $domain) === $normalizedDomain) {
$domainData = $statsByDate;
break;
}
if ($domainData === null) {
$domainData = $statsByDate;
}
}
if (!is_array($domainData) || empty($domainData)) {
return ['top3' => 0, 'top10' => 0, 'top20' => 0, 'top50' => 0, 'traffic' => 0];
}
$latestDateKey = null;
$latestDateNumeric = null;
foreach (array_keys($domainData) as $dateKeyRaw) {
$dateKey = (string) $dateKeyRaw;
$dateNumeric = (int) preg_replace('/\D+/', '', $dateKey);
if ($dateNumeric <= 0) {
continue;
}
if ($latestDateNumeric === null || $dateNumeric > $latestDateNumeric) {
$latestDateNumeric = $dateNumeric;
$latestDateKey = $dateKeyRaw;
}
}
if ($latestDateKey === null || !isset($domainData[$latestDateKey]) || !is_array($domainData[$latestDateKey])) {
return ['top3' => 0, 'top10' => 0, 'top20' => 0, 'top50' => 0, 'traffic' => 0];
}
$latest = $domainData[$latestDateKey];
$keywordsData = [];
if (is_array($latest['keywords'] ?? null)) {
$keywordsData = $latest['keywords'];
} elseif (is_array($latest['keywords_data'] ?? null)) {
$keywordsData = $latest['keywords_data'];
}
return [
'top3' => max(0, (int) ($keywordsData['top3'] ?? 0)),
'top10' => max(0, (int) ($keywordsData['top10'] ?? ($latest['keywords_top'] ?? 0))),
'top20' => max(0, (int) ($keywordsData['top20'] ?? 0)),
'top50' => max(0, (int) ($keywordsData['top50'] ?? ($latest['keywords'] ?? 0))),
'traffic' => max(0, (int) ($latest['traffic'] ?? 0)),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Helpers\Logger;
use App\Models\SiteSeoMetric;
class SiteSeoSyncService
{
private SemstormService $semstorm;
public function __construct()
{
$this->semstorm = new SemstormService();
}
public function syncSite(array $site, ?\DateTimeImmutable $month = null, bool $force = false): array
{
$siteId = (int) ($site['id'] ?? 0);
if ($siteId <= 0) {
return ['success' => false, 'status' => 'error', 'message' => 'Nieprawidlowe site_id.'];
}
$metricMonth = ($month ?? new \DateTimeImmutable('first day of this month'))->format('Y-m-01');
if (!$force && SiteSeoMetric::existsForMonth($siteId, $metricMonth)) {
return [
'success' => true,
'status' => 'skipped',
'message' => 'Dane SEO dla tego miesiaca juz istnieja.',
'metric_month' => $metricMonth,
];
}
$domain = $this->resolveDomain($site);
if ($domain === '') {
return ['success' => false, 'status' => 'error', 'message' => 'Brak domeny SEMSTORM dla strony.'];
}
try {
$metrics = $this->semstorm->fetchDomainMetrics($domain, new \DateTimeImmutable($metricMonth));
SiteSeoMetric::upsertMonthly($siteId, $metricMonth, $metrics, $metrics['payload'] ?? null);
Logger::info(
"SEMSTORM sync OK: site_id={$siteId}, domain={$domain}, month={$metricMonth}",
'semstorm'
);
return [
'success' => true,
'status' => 'saved',
'message' => 'Zapisano dane SEO z SEMSTORM.',
'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),
],
];
} catch (\Throwable $e) {
Logger::error(
"SEMSTORM sync FAIL: site_id={$siteId}, domain={$domain}, month={$metricMonth}, error={$e->getMessage()}",
'semstorm'
);
return [
'success' => false,
'status' => 'error',
'message' => 'Blad pobierania SEMSTORM: ' . $e->getMessage(),
'metric_month' => $metricMonth,
];
}
}
private function resolveDomain(array $site): string
{
$manual = trim((string) ($site['semstorm_domain'] ?? ''));
if ($manual !== '') {
return strtolower($manual);
}
$url = trim((string) ($site['url'] ?? ''));
if ($url === '') {
return '';
}
$host = parse_url($url, PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return '';
}
return strtolower($host);
}
}