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

3
.env
View File

@@ -13,4 +13,5 @@ PEXELS_API_KEY=
APP_URL=https://backpro.projectpro.pl
APP_SECRET=bP7x9kR3mW2vN5qT8sY1
PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1
PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1
SEO_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1

View File

@@ -9,6 +9,11 @@ OPENAI_MODEL=gpt-4o
FREEPIK_API_KEY=
UNSPLASH_API_KEY=
PEXELS_API_KEY=
SEMSTORM_LOGIN=
SEMSTORM_PASSWORD=
SEMSTORM_API_BASE=https://api.semstorm.com
SEMSTORM_TIMEOUT_SECONDS=30
SEO_TRIGGER_TOKEN=change-this-to-long-random-token
APP_URL=https://backpro.projectpro.pl
APP_SECRET=change-this-to-random-string

View File

@@ -5,16 +5,72 @@
"css": {
"app.css": {
"type": "-",
"size": 1058,
"lmtime": 1771149935949,
"size": 1982,
"lmtime": 1771375416079,
"modified": false
}
},
"js": {
"app.js": {
"type": "-",
"size": 3372,
"lmtime": 1771151245969,
"size": 12352,
"lmtime": 1771375416080,
"modified": false
}
},
"wp-theme-backpro-news": {
"archive.php": {
"type": "-",
"size": 908,
"lmtime": 1771375416081,
"modified": false
},
"footer.php": {
"type": "-",
"size": 324,
"lmtime": 1771375416082,
"modified": false
},
"front-page.php": {
"type": "-",
"size": 4350,
"lmtime": 1771375416082,
"modified": false
},
"functions.php": {
"type": "-",
"size": 847,
"lmtime": 1771375416083,
"modified": false
},
"header.php": {
"type": "-",
"size": 966,
"lmtime": 1771375416084,
"modified": false
},
"index.php": {
"type": "-",
"size": 1029,
"lmtime": 1771375416085,
"modified": false
},
"search.php": {
"type": "-",
"size": 683,
"lmtime": 1771375416085,
"modified": false
},
"single.php": {
"type": "-",
"size": 6116,
"lmtime": 1771375416086,
"modified": false
},
"style.css": {
"type": "-",
"size": 10723,
"lmtime": 1771375416087,
"modified": false
}
}
@@ -60,8 +116,8 @@
"config": {
"routes.php": {
"type": "-",
"size": 2806,
"lmtime": 1771275208609,
"size": 3489,
"lmtime": 1771375416088,
"modified": false
}
},
@@ -102,7 +158,7 @@
".env": {
"type": "-",
"size": 330,
"lmtime": 1771275267867,
"lmtime": 1771446981282,
"modified": false
},
".env.example": {
@@ -159,14 +215,26 @@
"size": 679,
"lmtime": 1771274732814,
"modified": false
},
"006_article_retry_tracking.sql": {
"type": "-",
"size": 219,
"lmtime": 1771375416089,
"modified": false
},
"007_remote_service_fields.sql": {
"type": "-",
"size": 333,
"lmtime": 1771375416090,
"modified": false
}
},
"src": {
"Controllers": {
"ArticleController.php": {
"type": "-",
"size": 3850,
"lmtime": 1771273569953,
"size": 11819,
"lmtime": 1771375416091,
"modified": false
},
"AuthController.php": {
@@ -213,8 +281,8 @@
},
"SiteController.php": {
"type": "-",
"size": 6128,
"lmtime": 1771274667035,
"size": 10788,
"lmtime": 1771375416092,
"modified": false
},
"TopicController.php": {
@@ -277,8 +345,8 @@
"Helpers": {
"Logger.php": {
"type": "-",
"size": 1131,
"lmtime": 1771149676956,
"size": 2028,
"lmtime": 1771375416092,
"modified": false
},
"Validator.php": {
@@ -291,8 +359,8 @@
"Models": {
"Article.php": {
"type": "-",
"size": 2721,
"lmtime": 1771273388984,
"size": 4960,
"lmtime": 1771375416093,
"modified": false
},
"GlobalTopic.php": {
@@ -309,8 +377,8 @@
},
"Topic.php": {
"type": "-",
"size": 1311,
"lmtime": 1771272998441,
"size": 1615,
"lmtime": 1771375416094,
"modified": false
},
"User.php": {
@@ -323,8 +391,8 @@
"Services": {
"FtpService.php": {
"type": "-",
"size": 3938,
"lmtime": 1771270301490,
"size": 7464,
"lmtime": 1771375416095,
"modified": false
},
"ImageService.php": {
@@ -335,20 +403,20 @@
},
"InstallerService.php": {
"type": "-",
"size": 15569,
"lmtime": 1771274670332,
"size": 33181,
"lmtime": 1771375416096,
"modified": false
},
"OpenAIService.php": {
"type": "-",
"size": 4116,
"lmtime": 1771274377091,
"size": 4329,
"lmtime": 1771375416097,
"modified": false
},
"PublisherService.php": {
"type": "-",
"size": 4762,
"lmtime": 1771149793307,
"size": 8311,
"lmtime": 1771375416098,
"modified": false
},
"TopicBalancer.php": {
@@ -359,27 +427,64 @@
},
"WordPressService.php": {
"type": "-",
"size": 6232,
"lmtime": 1771273377064,
"size": 48742,
"lmtime": 1771375416099,
"modified": false
}
}
},
"storage": {
"logs": {}
"logs": {
"image_2026-02-17.log": {
"type": "-",
"size": 72,
"lmtime": 1771375416100,
"modified": false
},
"installer_2026-02-17.log": {
"type": "-",
"size": 4379,
"lmtime": 1771375416101,
"modified": false
},
"openai_2026-02-17.log": {
"type": "-",
"size": 118,
"lmtime": 1771375416102,
"modified": false
},
"publish_2026-02-17.log": {
"type": "-",
"size": 2539,
"lmtime": 1771375416102,
"modified": false
},
"publish_2026-02-18.log": {
"type": "-",
"size": 702,
"lmtime": 0,
"modified": false
},
"wordpress_2026-02-17.log": {
"type": "-",
"size": 1471,
"lmtime": 1771375416103,
"modified": false
}
}
},
"templates": {
"articles": {
"index.php": {
"type": "-",
"size": 3091,
"lmtime": 1771149908879,
"size": 6036,
"lmtime": 1771375416104,
"modified": false
},
"show.php": {
"type": "-",
"size": 4593,
"lmtime": 1771273596364,
"size": 5487,
"lmtime": 1771375416105,
"modified": false
}
},
@@ -400,24 +505,24 @@
"categories": {
"index.php": {
"type": "-",
"size": 13087,
"lmtime": 1771273087810,
"size": 13551,
"lmtime": 1771375416106,
"modified": false
}
},
"dashboard": {
"index.php": {
"type": "-",
"size": 7496,
"lmtime": 1771274000059,
"size": 8067,
"lmtime": 1771375416107,
"modified": false
}
},
"global-topics": {
"index.php": {
"type": "-",
"size": 11741,
"lmtime": 1771272210486,
"size": 12103,
"lmtime": 1771375416108,
"modified": false
}
},
@@ -425,19 +530,19 @@
"header.php": {
"type": "-",
"size": 623,
"lmtime": 1771149821599,
"lmtime": 1771375416109,
"modified": false
},
"main.php": {
"type": "-",
"size": 1764,
"lmtime": 1771149813250,
"size": 1821,
"lmtime": 1771375416109,
"modified": false
},
"sidebar.php": {
"type": "-",
"size": 1837,
"lmtime": 1771270163946,
"size": 1894,
"lmtime": 1771375416110,
"modified": false
}
},
@@ -456,24 +561,30 @@
"lmtime": 1771274870419,
"modified": false
},
"dashboard.php": {
"type": "-",
"size": 12823,
"lmtime": 1771375416111,
"modified": false
},
"edit.php": {
"type": "-",
"size": 18624,
"lmtime": 1771274870423,
"size": 18800,
"lmtime": 1771375416112,
"modified": false
},
"index.php": {
"type": "-",
"size": 4285,
"lmtime": 1771274870426,
"size": 4961,
"lmtime": 1771375416113,
"modified": false
}
},
"topics": {
"index.php": {
"type": "-",
"size": 10947,
"lmtime": 1771273019085,
"size": 11477,
"lmtime": 1771375416114,
"modified": false
}
},
@@ -486,6 +597,12 @@
}
}
},
"TODO.md": {
"type": "-",
"size": 233,
"lmtime": 1771373773622,
"modified": false
},
"vendor": {
"composer": {
"installed.json": {

View File

@@ -11,6 +11,10 @@ $router->post('/change-password', 'AuthController', 'changePassword');
// Dashboard
$router->get('/', 'DashboardController', 'index');
$router->get('/seo/stats', 'DashboardController', 'seoStats');
// Logs
$router->get('/logs', 'LogController', 'index');
// Global Topics (library)
$router->get('/global-topics', 'GlobalTopicController', 'index');
@@ -32,6 +36,11 @@ $router->post('/sites/{id}/dashboard/permalinks/enable', 'SiteController', 'enab
$router->post('/sites/{id}/dashboard/remote-service/update', 'SiteController', 'updateRemoteService');
$router->post('/sites/{id}/dashboard/theme/install', 'SiteController', 'installBackproNewsTheme');
$router->post('/sites/{id}/dashboard/reinstall', 'SiteController', 'reinstallWordPress');
$router->post('/sites/{id}/dashboard/seo/sync', 'SiteController', 'syncSeoMetrics');
$router->get('/sites/{id}/seo', 'SiteController', 'seoPanel');
$router->post('/sites/{id}/seo/sync', 'SiteController', 'syncSeoMetrics');
$router->get('/seo/token-sync', 'SiteController', 'syncSeoByToken');
$router->post('/seo/token-sync', 'SiteController', 'syncSeoByToken');
// Topics
$router->get('/sites/{id}/topics', 'TopicController', 'index');

73
cron/semstorm.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
$basePath = dirname(__DIR__);
$lockFile = $basePath . '/storage/logs/semstorm.lock';
if (file_exists($lockFile)) {
$lockTime = filemtime($lockFile);
if ($lockTime !== false && (time() - $lockTime) < 3600) {
echo "Another SEMSTORM sync is running. Exiting.\n";
exit(0);
}
@unlink($lockFile);
}
file_put_contents($lockFile, date('Y-m-d H:i:s'));
try {
require_once $basePath . '/vendor/autoload.php';
\App\Core\Config::load($basePath);
\App\Helpers\Logger::setBasePath($basePath);
$sites = \App\Models\Site::findActive();
$sync = new \App\Services\SiteSeoSyncService();
if (empty($sites)) {
echo "No active sites found.\n";
exit(0);
}
$saved = 0;
$skipped = 0;
$failed = 0;
foreach ($sites as $site) {
$result = $sync->syncSite($site);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'saved') {
$saved++;
} elseif ($status === 'skipped') {
$skipped++;
} else {
$failed++;
}
$message = (string) ($result['message'] ?? 'brak komunikatu');
echo sprintf(
"[%s] site_id=%d, status=%s, message=%s\n",
date('Y-m-d H:i:s'),
(int) ($site['id'] ?? 0),
$status,
$message
);
}
$summary = "SEMSTORM sync summary: saved={$saved}, skipped={$skipped}, failed={$failed}";
\App\Helpers\Logger::info($summary, 'semstorm');
echo $summary . "\n";
} catch (\Throwable $e) {
$message = 'SEMSTORM CRON Error: ' . $e->getMessage();
echo $message . "\n";
if (class_exists(\App\Helpers\Logger::class)) {
\App\Helpers\Logger::error($message, 'semstorm');
}
} finally {
if (file_exists($lockFile)) {
@unlink($lockFile);
}
}

View File

@@ -116,3 +116,17 @@ Przycisk "Opublikuj teraz" na dashboardzie wywołuje `PublishController@run`, kt
| Lockfile blokuje | Poprzedni proces nie zakończył się | Usuń `storage/logs/publish.lock` |
| 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)
Dodaj osobne zadanie CRON uruchamiane raz w miesiacu:
```bash
0 3 1 * * php /path/to/cron/semstorm.php >> /path/to/storage/logs/cron_semstorm.log 2>&1
```
Skrypt:
- pobiera aktywne strony (`sites.is_active = 1`),
- pobiera metryki z SEMSTORM dla domeny strony,
- zapisuje dane do `site_seo_metrics` dla biezacego miesiaca,
- pomija rekord, jesli miesiac jest juz zapisany (idempotencja).

View File

@@ -196,3 +196,31 @@ CREATE INDEX idx_articles_status ON articles(status);
CREATE INDEX idx_sites_is_active ON sites(is_active);
CREATE INDEX idx_sites_last_published ON sites(last_published_at);
```
### `site_seo_metrics` - Historia metryk SEO (SEMSTORM)
```sql
CREATE TABLE site_seo_metrics (
id INT AUTO_INCREMENT PRIMARY KEY,
site_id INT NOT NULL,
metric_month DATE NOT NULL,
top3 INT NOT NULL DEFAULT 0,
top10 INT NOT NULL DEFAULT 0,
top20 INT NOT NULL DEFAULT 0,
top50 INT NOT NULL DEFAULT 0,
traffic INT NOT NULL DEFAULT 0,
source_payload TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
UNIQUE KEY uniq_site_month (site_id, metric_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
| Kolumna | Typ | Opis |
|---------|-----|------|
| site_id | INT FK sites.id | Strona, dla ktorej pobrano dane |
| metric_month | DATE | Miesiac pomiaru (pierwszy dzien miesiaca) |
| top3/top10/top20/top50 | INT | Liczba slow kluczowych w danym zakresie |
| traffic | INT | Szacowany ruch organiczny |
| source_payload | TEXT | Surowa odpowiedz API do diagnostyki |

View File

@@ -0,0 +1,12 @@
-- Cron log table for publish events
CREATE TABLE IF NOT EXISTS cron_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
datetime DATETIME NOT NULL,
level VARCHAR(16) NOT NULL,
message TEXT NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'publish',
INDEX idx_channel_datetime (channel, datetime)
);
-- Usuwanie starszych niż 30 dni (możesz dodać do crona lub uruchamiać ręcznie):
-- DELETE FROM cron_logs WHERE datetime < (NOW() - INTERVAL 30 DAY);

View File

@@ -0,0 +1,20 @@
-- BackPRO SEMSTORM metrics history
ALTER TABLE sites
ADD COLUMN semstorm_domain VARCHAR(255) NULL AFTER url;
CREATE TABLE IF NOT EXISTS site_seo_metrics (
id INT AUTO_INCREMENT PRIMARY KEY,
site_id INT NOT NULL,
metric_month DATE NOT NULL,
top3 INT NOT NULL DEFAULT 0,
top10 INT NOT NULL DEFAULT 0,
top20 INT NOT NULL DEFAULT 0,
top50 INT NOT NULL DEFAULT 0,
traffic INT NOT NULL DEFAULT 0,
source_payload TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
UNIQUE KEY uniq_site_month (site_id, metric_month),
INDEX idx_site_month (site_id, metric_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Models\Site;
use App\Models\Article;
use App\Models\Topic;
use App\Models\SiteSeoMetric;
class DashboardController extends Controller
{
@@ -30,4 +31,19 @@ class DashboardController extends Controller
'recentArticles' => $recentArticles,
]);
}
public function seoStats(): void
{
Auth::requireLogin();
$sort = (string) $this->input('sort', 'traffic');
$dir = strtolower((string) $this->input('dir', 'desc')) === 'asc' ? 'asc' : 'desc';
$rows = SiteSeoMetric::latestForAllSites($sort, $dir);
$this->view('dashboard/seo-stats', [
'rows' => $rows,
'currentSort' => $sort,
'currentDir' => $dir,
]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Controller;
class LogController extends Controller
{
private const MAX_LOG_LINES = 300;
public function index(): void
{
Auth::requireLogin();
$level = $_GET['level'] ?? '';
$dateFrom = $_GET['date_from'] ?? '';
$dateTo = $_GET['date_to'] ?? '';
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 50;
$allEvents = $this->readPublishEvents();
$filtered = [];
foreach ($allEvents as $event) {
if ($level && strtoupper($event['level']) !== strtoupper($level)) {
continue;
}
if ($dateFrom && $event['datetime'] !== '-' && $event['datetime'] < $dateFrom.' 00:00:00') {
continue;
}
if ($dateTo && $event['datetime'] !== '-' && $event['datetime'] > $dateTo.' 23:59:59') {
continue;
}
$filtered[] = $event;
}
$total = count($filtered);
$pages = max(1, (int)ceil($total / $perPage));
$offset = ($page - 1) * $perPage;
$events = array_slice($filtered, $offset, $perPage);
$this->view('logs/index', [
'events' => $events,
'level' => $level,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'page' => $page,
'pages' => $pages,
'total' => $total,
]);
}
private function readPublishEvents(): array
{
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("SELECT datetime, level, message FROM cron_logs WHERE channel = 'publish' ORDER BY datetime DESC LIMIT 1000");
$stmt->execute();
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$events = [];
foreach ($rows as $row) {
$events[] = [
'datetime' => $row['datetime'],
'level' => $row['level'],
'message' => $row['message'],
];
}
return $events;
}
private function parseLine(string $line): array
{
if (preg_match('/^\[(.*?)\]\s+([A-Z]+):\s+(.*)$/', $line, $matches)) {
return [
'datetime' => $matches[1],
'level' => $matches[2],
'message' => $matches[3],
];
}
return [
'datetime' => '-',
'level' => 'INFO',
'message' => $line,
];
}
}

View File

@@ -21,6 +21,10 @@ class SettingsController extends Controller
'article_max_words',
'article_generation_prompt',
'image_generation_prompt',
'semstorm_login',
'semstorm_password',
'semstorm_api_base',
'semstorm_timeout_seconds',
];
private array $settingDefaults = [
@@ -30,6 +34,8 @@ class SettingsController extends Controller
'article_max_words' => '1200',
'article_generation_prompt' => OpenAIService::DEFAULT_ARTICLE_PROMPT_TEMPLATE,
'image_generation_prompt' => ImageService::DEFAULT_FREEPIK_PROMPT_TEMPLATE,
'semstorm_api_base' => 'https://api.semstorm.com',
'semstorm_timeout_seconds' => '30',
];
public function index(): void

View File

@@ -3,13 +3,17 @@
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Config;
use App\Core\Controller;
use App\Helpers\Logger;
use App\Helpers\Validator;
use App\Models\Article;
use App\Models\Site;
use App\Models\SiteSeoMetric;
use App\Models\Topic;
use App\Models\GlobalTopic;
use App\Services\InstallerService;
use App\Services\SiteSeoSyncService;
use App\Services\WordPressService;
class SiteController extends Controller
@@ -63,6 +67,7 @@ class SiteController extends Controller
$siteId = Site::create([
'name' => $this->input('name'),
'url' => rtrim($this->input('url'), '/'),
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
@@ -135,6 +140,7 @@ class SiteController extends Controller
Site::update((int) $id, [
'name' => $this->input('name'),
'url' => rtrim($this->input('url'), '/'),
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
@@ -207,6 +213,174 @@ class SiteController extends Controller
]);
}
public function seoPanel(string $id): void
{
Auth::requireLogin();
$site = Site::find((int) $id);
if (!$site) {
$this->flash('danger', 'Strona nie znaleziona.');
$this->redirect('/sites');
return;
}
$seoMetrics = SiteSeoMetric::findBySite((int) $id, 12);
$seoLatest = SiteSeoMetric::latestForSite((int) $id);
$this->view('sites/seo', [
'site' => $site,
'seoMetrics' => $seoMetrics,
'seoLatest' => $seoLatest,
]);
}
public function syncSeoMetrics(string $id): void
{
Auth::requireLogin();
$site = Site::find((int) $id);
if (!$site) {
$this->flash('danger', 'Strona nie znaleziona.');
$this->redirect('/sites');
return;
}
$sync = new SiteSeoSyncService();
$result = $sync->syncSite($site, null, true);
if (!empty($result['success'])) {
$this->flash('success', (string) ($result['message'] ?? 'Pobrano dane SEO.'));
} else {
$this->flash('danger', (string) ($result['message'] ?? 'Nie udalo sie pobrac danych SEO.'));
}
$this->redirect("/sites/{$id}/seo");
}
public function syncSeoByToken(): void
{
$configuredToken = (string) Config::get('SEO_TRIGGER_TOKEN', '');
$providedToken = (string) $this->input('token', '');
if ($providedToken === '') {
$providedToken = (string) ($_SERVER['HTTP_X_SEO_TOKEN'] ?? '');
}
if ($configuredToken === '') {
Logger::warning('Token SEO trigger called, but SEO_TRIGGER_TOKEN is not configured.', 'semstorm');
$this->json(['success' => false, 'message' => 'Token trigger is disabled.'], 503);
return;
}
if ($providedToken === '' || !hash_equals($configuredToken, $providedToken)) {
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
Logger::warning("Invalid SEO token attempt from {$ip}", 'semstorm');
$this->json(['success' => false, 'message' => 'Forbidden'], 403);
return;
}
$force = ((int) $this->input('force', 0)) === 1;
$siteId = (int) $this->input('site_id', 0);
$sync = new SiteSeoSyncService();
$saved = 0;
$skipped = 0;
$failed = 0;
$details = [];
if ($siteId > 0) {
$site = Site::find($siteId);
if (!$site) {
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.'], 404);
return;
}
$result = $sync->syncSite($site, null, $force);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'saved') {
$saved++;
} elseif ($status === 'skipped') {
$skipped++;
} else {
$failed++;
}
$details[] = [
'site_id' => (int) $site['id'],
'site_name' => (string) $site['name'],
'status' => $status,
'message' => (string) ($result['message'] ?? ''),
];
} elseif (!$force) {
$metricMonth = (new \DateTimeImmutable('first day of this month'))->format('Y-m-01');
$site = Site::findNextDueForSeoSync($metricMonth);
if (!$site) {
$this->json([
'success' => true,
'message' => 'SEMSTORM sync: brak stron do synchronizacji w tym miesiacu.',
'saved' => 0,
'skipped' => 0,
'failed' => 0,
'force' => false,
'details' => [],
], 200);
return;
}
$result = $sync->syncSite($site, null, false);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'saved') {
$saved++;
} elseif ($status === 'skipped') {
$skipped++;
} else {
$failed++;
}
$details[] = [
'site_id' => (int) $site['id'],
'site_name' => (string) $site['name'],
'status' => $status,
'message' => (string) ($result['message'] ?? ''),
];
} else {
$sites = Site::findActive();
foreach ($sites as $site) {
$result = $sync->syncSite($site, null, $force);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'saved') {
$saved++;
} elseif ($status === 'skipped') {
$skipped++;
} else {
$failed++;
}
$details[] = [
'site_id' => (int) $site['id'],
'site_name' => (string) $site['name'],
'status' => $status,
'message' => (string) ($result['message'] ?? ''),
];
}
}
$this->json([
'success' => $failed === 0,
'message' => "SEMSTORM sync: saved={$saved}, skipped={$skipped}, failed={$failed}",
'saved' => $saved,
'skipped' => $skipped,
'failed' => $failed,
'force' => $force,
'details' => $details,
], 200);
}
public function enablePrettyPermalinks(string $id): void
{
Auth::requireLogin();

View File

@@ -4,7 +4,7 @@ namespace App\Helpers;
class Logger
{
private const LOG_RETENTION_DAYS = 7;
private const LOG_RETENTION_DAYS = 30;
private static string $basePath = '';
private static bool $cleanupDone = false;
@@ -30,19 +30,40 @@ class Logger
private static function write(string $level, string $message, string $channel): void
{
$date = date('Y-m-d');
$time = date('Y-m-d H:i:s');
$logDir = self::$basePath . '/storage/logs';
if ($channel === 'publish') {
// Zapis do bazy
try {
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("INSERT INTO cron_logs (datetime, level, message, channel) VALUES (:dt, :level, :msg, :ch)");
$stmt->execute([
'dt' => $time,
'level' => $level,
'msg' => $message,
'ch' => $channel,
]);
// Usuwanie starszych niż 30 dni
$db->exec("DELETE FROM cron_logs WHERE datetime < (NOW() - INTERVAL 30 DAY)");
} catch (\Throwable $e) {
// Fallback do pliku jeśli błąd bazy
self::writeFileFallback($level, $message, $channel, $time);
}
return;
}
// Standardowy plikowy log
self::writeFileFallback($level, $message, $channel, $time);
}
private static function writeFileFallback(string $level, string $message, string $channel, string $time): void
{
$date = substr($time, 0, 10);
$logDir = self::$basePath . '/storage/logs';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
self::cleanupOldLogs($logDir);
$logFile = "{$logDir}/{$channel}_{$date}.log";
$line = "[{$time}] {$level}: {$message}" . PHP_EOL;
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}

View File

@@ -122,6 +122,25 @@ class Article extends Model
return $result ?: null;
}
public static function findNextPublishedWithoutImageBySite(int $siteId): ?array
{
$stmt = self::db()->prepare(
"SELECT a.*
FROM articles a
WHERE a.site_id = :site_id
AND a.status = 'published'
AND a.wp_post_id IS NOT NULL
AND a.content <> ''
AND COALESCE(a.retry_count, 0) < 5
AND (a.image_url IS NULL OR a.image_url = '')
ORDER BY COALESCE(a.published_at, a.created_at) ASC, a.id ASC
LIMIT 1"
);
$stmt->execute(['site_id' => $siteId]);
$result = $stmt->fetch();
return $result ?: null;
}
public static function markRetryAttempt(int $articleId): void
{
$stmt = self::db()->prepare(

View File

@@ -30,4 +30,23 @@ class Site extends Model
{
self::update($id, ['last_published_at' => date('Y-m-d H:i:s')]);
}
public static function findNextDueForSeoSync(string $metricMonth): ?array
{
$sql = "SELECT s.*
FROM sites s
LEFT JOIN site_seo_metrics m
ON m.site_id = s.id
AND m.metric_month = :metric_month
WHERE s.is_active = 1
AND m.id IS NULL
ORDER BY s.id ASC
LIMIT 1";
$stmt = self::db()->prepare($sql);
$stmt->execute(['metric_month' => $metricMonth]);
$row = $stmt->fetch();
return $row ?: null;
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Models;
use App\Core\Model;
class SiteSeoMetric extends Model
{
protected static string $table = 'site_seo_metrics';
public static function existsForMonth(int $siteId, string $metricMonth): bool
{
$stmt = self::db()->prepare(
'SELECT 1 FROM site_seo_metrics WHERE site_id = :site_id AND metric_month = :metric_month LIMIT 1'
);
$stmt->execute([
'site_id' => $siteId,
'metric_month' => $metricMonth,
]);
return (bool) $stmt->fetchColumn();
}
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)
VALUES
(:site_id, :metric_month, :top3, :top10, :top20, :top50, :traffic, :source_payload)
ON DUPLICATE KEY UPDATE
top3 = VALUES(top3),
top10 = VALUES(top10),
top20 = VALUES(top20),
top50 = VALUES(top50),
traffic = VALUES(traffic),
source_payload = VALUES(source_payload),
updated_at = CURRENT_TIMESTAMP'
);
$stmt->execute([
'site_id' => $siteId,
'metric_month' => $metricMonth,
'top3' => max(0, (int) ($metrics['top3'] ?? 0)),
'top10' => max(0, (int) ($metrics['top10'] ?? 0)),
'top20' => max(0, (int) ($metrics['top20'] ?? 0)),
'top50' => max(0, (int) ($metrics['top50'] ?? 0)),
'traffic' => max(0, (int) ($metrics['traffic'] ?? 0)),
'source_payload' => $payload,
]);
}
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
FROM site_seo_metrics
WHERE site_id = :site_id
ORDER BY metric_month DESC
LIMIT :limit_rows'
);
$stmt->bindValue(':site_id', $siteId, \PDO::PARAM_INT);
$stmt->bindValue(':limit_rows', max(1, $limit), \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
return array_reverse($rows);
}
public static function latestForSite(int $siteId): ?array
{
$stmt = self::db()->prepare(
'SELECT metric_month, top3, top10, top20, top50, traffic, updated_at
FROM site_seo_metrics
WHERE site_id = :site_id
ORDER BY metric_month DESC
LIMIT 1'
);
$stmt->execute(['site_id' => $siteId]);
$result = $stmt->fetch();
return $result ?: null;
}
public static function latestForAllSites(string $sort = 'traffic', string $dir = 'desc'): array
{
$allowedSort = ['top3', 'top10', 'top20', 'top50', 'traffic', 'metric_month', 'updated_at', 'site_name'];
if (!in_array($sort, $allowedSort, true)) {
$sort = 'traffic';
}
$direction = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
if ($sort === 'site_name') {
$orderBy = "s.name {$direction}";
} else {
$orderBy = "(m.{$sort} IS NULL) ASC, m.{$sort} {$direction}, s.name ASC";
}
$stmt = self::db()->query(
'SELECT
s.id AS site_id,
s.name AS site_name,
s.url AS site_url,
s.is_active,
m.metric_month,
m.top3,
m.top10,
m.top20,
m.top50,
m.traffic,
m.updated_at,
m.created_at,
pm.metric_month AS prev_metric_month,
pm.top3 AS prev_top3,
pm.top10 AS prev_top10,
pm.top20 AS prev_top20,
pm.top50 AS prev_top50,
pm.traffic AS prev_traffic
FROM sites s
LEFT JOIN site_seo_metrics m
ON m.id = (
SELECT m2.id
FROM site_seo_metrics m2
WHERE m2.site_id = s.id
ORDER BY m2.metric_month DESC
LIMIT 1
)
LEFT JOIN site_seo_metrics pm
ON pm.id = (
SELECT m3.id
FROM site_seo_metrics m3
WHERE m3.site_id = s.id
ORDER BY m3.metric_month DESC
LIMIT 1 OFFSET 1
)
ORDER BY ' . $orderBy
);
return $stmt->fetchAll();
}
}

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

View File

@@ -0,0 +1,149 @@
<h2 class="mb-4">Statystyki SEO (ostatnie dane)</h2>
<?php
$currentSort = (string) ($currentSort ?? 'traffic');
$currentDir = (string) ($currentDir ?? 'desc');
$sortLink = static function (string $column) use ($currentSort, $currentDir): string {
$nextDir = ($currentSort === $column && $currentDir === 'asc') ? 'desc' : 'asc';
return '/seo/stats?sort=' . urlencode($column) . '&dir=' . urlencode($nextDir);
};
$sortMark = static function (string $column) use ($currentSort, $currentDir): string {
if ($currentSort !== $column) {
return '';
}
return $currentDir === 'asc' ? ' ↑' : ' ↓';
};
$formatDelta = static function ($current, $previous): string {
if ($previous === null || $previous === '') {
return '<span class="text-muted">n/a</span>';
}
$curr = (float) $current;
$prev = (float) $previous;
if ($prev == 0.0) {
if ($curr == 0.0) {
return '<span class="text-muted">0%</span>';
}
return '<span class="text-muted">n/a</span>';
}
$delta = (($curr - $prev) / $prev) * 100.0;
$class = $delta > 0 ? 'text-success' : ($delta < 0 ? 'text-danger' : 'text-muted');
$sign = $delta > 0 ? '+' : '';
return '<span class="' . $class . '">' . $sign . number_format($delta, 1, '.', '') . '%</span>';
};
?>
<div class="card">
<div class="card-body p-0 table-responsive">
<table class="table table-sm table-hover mb-0 align-middle small">
<thead>
<tr>
<th>Strona</th>
<th>Status</th>
<th>Miesiac</th>
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top3')) ?>">TOP3<?= htmlspecialchars($sortMark('top3')) ?></a></th>
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top10')) ?>">TOP10<?= htmlspecialchars($sortMark('top10')) ?></a></th>
<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>Aktualizacja</th>
<th></th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr>
<td colspan="10" class="text-center text-muted py-4">Brak danych SEO.</td>
</tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<?php
$siteUrl = (string) ($row['site_url'] ?? '');
$host = parse_url($siteUrl, PHP_URL_HOST);
$domain = is_string($host) && $host !== '' ? $host : $siteUrl;
?>
<tr>
<td class="text-nowrap" style="max-width: 260px;">
<a href="/sites/<?= (int) $row['site_id'] ?>/seo" class="text-decoration-none fw-semibold">
<?= htmlspecialchars($domain) ?>
</a>
</td>
<td class="text-nowrap">
<?php if ((int) ($row['is_active'] ?? 0) === 1): ?>
<span class="badge bg-success">ON</span>
<?php else: ?>
<span class="badge bg-secondary">OFF</span>
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['metric_month'])): ?>
<?= htmlspecialchars(date('m.Y', strtotime((string) $row['metric_month']))) ?>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['metric_month'])): ?>
<?= (int) $row['top3'] ?>
<small><?= $formatDelta($row['top3'], $row['prev_top3'] ?? null) ?></small>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['metric_month'])): ?>
<?= (int) $row['top10'] ?>
<small><?= $formatDelta($row['top10'], $row['prev_top10'] ?? null) ?></small>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['metric_month'])): ?>
<?= (int) $row['top20'] ?>
<small><?= $formatDelta($row['top20'], $row['prev_top20'] ?? null) ?></small>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['metric_month'])): ?>
<?= (int) $row['top50'] ?>
<small><?= $formatDelta($row['top50'], $row['prev_top50'] ?? null) ?></small>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if (!empty($row['metric_month'])): ?>
<?= (int) $row['traffic'] ?>
<small><?= $formatDelta($row['traffic'], $row['prev_traffic'] ?? 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']))) ?>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-nowrap">
<a href="/sites/<?= (int) $row['site_id'] ?>/seo" class="btn btn-sm btn-outline-primary" title="SEO Panel">
<i class="bi bi-graph-up"></i>
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -14,6 +14,11 @@
<i class="bi bi-wordpress me-2"></i>Strony
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/seo/stats">
<i class="bi bi-graph-up-arrow me-2"></i>Statystyki SEO
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/global-topics">
<i class="bi bi-bookmarks me-2"></i>Tematy
@@ -24,6 +29,11 @@
<i class="bi bi-file-earmark-text me-2"></i>Artykuły
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/logs">
<i class="bi bi-journal-text me-2"></i>Log
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/installer">
<i class="bi bi-cloud-upload me-2"></i>Instalator WP

76
templates/logs/index.php Normal file
View File

@@ -0,0 +1,76 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Log crona</h2>
<span class="badge text-bg-secondary">Kanał: publish</span>
</div>
<form class="row g-2 mb-3" method="get" action="">
<div class="col-auto">
<select name="level" class="form-select">
<option value="">Wszystkie poziomy</option>
<option value="INFO" <?= $level === 'INFO' ? 'selected' : '' ?>>INFO</option>
<option value="WARNING" <?= $level === 'WARNING' ? 'selected' : '' ?>>WARNING</option>
<option value="ERROR" <?= $level === 'ERROR' ? 'selected' : '' ?>>ERROR</option>
</select>
</div>
<div class="col-auto">
<input type="date" name="date_from" class="form-control" value="<?= htmlspecialchars($dateFrom) ?>" placeholder="Od">
</div>
<div class="col-auto">
<input type="date" name="date_to" class="form-control" value="<?= htmlspecialchars($dateTo) ?>" placeholder="Do">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">Filtruj</button>
</div>
</form>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr>
<th style="width: 190px;">Data zdarzenia</th>
<th style="width: 110px;">Poziom</th>
<th>Zdarzenie</th>
</tr>
</thead>
<tbody>
<?php if (empty($events)): ?>
<tr>
<td colspan="3" class="text-center text-muted py-4">Brak zdarzeń w logach crona.</td>
</tr>
<?php else: ?>
<?php foreach ($events as $event): ?>
<tr>
<td class="text-nowrap"><?= htmlspecialchars($event['datetime']) ?></td>
<td>
<?php if ($event['level'] === 'ERROR'): ?>
<span class="badge text-bg-danger">ERROR</span>
<?php elseif ($event['level'] === 'WARNING'): ?>
<span class="badge text-bg-warning">WARNING</span>
<?php else: ?>
<span class="badge text-bg-success">INFO</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($event['message']) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php if ($pages > 1): ?>
<nav class="mt-3">
<ul class="pagination justify-content-center">
<?php for ($i = 1; $i <= $pages; $i++): ?>
<li class="page-item<?= $i === $page ? ' active' : '' ?>">
<a class="page-link" href="?<?= http_build_query(array_merge($_GET, ['page' => $i])) ?>"><?= $i ?></a>
</li>
<?php endfor; ?>
</ul>
</nav>
<?php endif; ?>

View File

@@ -85,6 +85,33 @@
value="<?= htmlspecialchars($settings['pexels_api_key']) ?>">
</div>
<h5 class="mb-3 mt-4 border-bottom pb-2">SEMSTORM (metryki SEO)</h5>
<div class="mb-3">
<label for="semstorm_login" class="form-label">Login SEMSTORM</label>
<input type="text" class="form-control" id="semstorm_login" name="semstorm_login"
value="<?= htmlspecialchars($settings['semstorm_login']) ?>">
</div>
<div class="mb-3">
<label for="semstorm_password" class="form-label">Hasło SEMSTORM</label>
<input type="password" class="form-control" id="semstorm_password" name="semstorm_password"
value="<?= htmlspecialchars($settings['semstorm_password']) ?>">
</div>
<div class="mb-3">
<label for="semstorm_api_base" class="form-label">Bazowy URL API</label>
<input type="text" class="form-control" id="semstorm_api_base" name="semstorm_api_base"
value="<?= htmlspecialchars($settings['semstorm_api_base']) ?>"
placeholder="https://api.semstorm.com">
</div>
<div class="mb-3">
<label for="semstorm_timeout_seconds" class="form-label">Timeout API SEMSTORM (sekundy)</label>
<input type="number" class="form-control" id="semstorm_timeout_seconds" name="semstorm_timeout_seconds"
value="<?= htmlspecialchars($settings['semstorm_timeout_seconds']) ?>" min="5" max="120">
</div>
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
</form>
</div>

View File

@@ -17,6 +17,12 @@
<div class="form-text">Podaj pełny URL strony WordPress (bez końcowego /)</div>
</div>
<div class="mb-3">
<label for="semstorm_domain" class="form-label">Domena SEMSTORM (opcjonalnie)</label>
<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="api_user" class="form-label">Użytkownik API (WordPress)</label>
<input type="text" class="form-control" id="api_user" name="api_user" required>

View File

@@ -1,6 +1,9 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>WP Dashboard: <?= htmlspecialchars($site['name']) ?></h2>
<div class="d-flex gap-2">
<a href="/sites/<?= (int) $site['id'] ?>/seo" class="btn btn-outline-primary">
<i class="bi bi-graph-up me-1"></i>SEO Panel
</a>
<a href="/sites/<?= $site['id'] ?>/edit" class="btn btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edytuj strone
</a>

View File

@@ -1,6 +1,9 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Edytuj stronę: <?= htmlspecialchars($site['name']) ?></h2>
<div class="d-flex gap-2">
<a href="/sites/<?= $site['id'] ?>/seo" class="btn btn-primary">
<i class="bi bi-graph-up me-1"></i>SEO Panel
</a>
<a href="/sites/<?= $site['id'] ?>/dashboard" class="btn btn-dark">
<i class="bi bi-sliders me-1"></i>WP Dashboard
</a>
@@ -28,6 +31,12 @@
<input type="url" class="form-control" id="url" name="url" value="<?= htmlspecialchars($site['url']) ?>" required>
</div>
<div class="mb-3">
<label for="semstorm_domain" class="form-label">Domena SEMSTORM (opcjonalnie)</label>
<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="api_user" class="form-label">Użytkownik API</label>
<input type="text" class="form-control" id="api_user" name="api_user" value="<?= htmlspecialchars($site['api_user']) ?>" required>

View File

@@ -60,6 +60,9 @@
<a href="/sites/<?= $site['id'] ?>/dashboard" class="btn btn-outline-dark" title="WP Dashboard">
<i class="bi bi-sliders"></i>
</a>
<a href="/sites/<?= $site['id'] ?>/seo" class="btn btn-outline-primary" title="SEO Panel">
<i class="bi bi-graph-up"></i>
</a>
<a href="/sites/<?= $site['id'] ?>/topics" class="btn btn-outline-info" title="Tematy">
<i class="bi bi-tags"></i>
</a>

180
templates/sites/seo.php Normal file
View File

@@ -0,0 +1,180 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>SEO Panel: <?= htmlspecialchars((string) ($site['url'] ?? '')) ?></h2>
<div class="d-flex gap-2">
<a href="/sites/<?= (int) $site['id'] ?>/dashboard" class="btn btn-outline-dark">
<i class="bi bi-sliders me-1"></i>WP Dashboard
</a>
<a href="/sites/<?= (int) $site['id'] ?>/edit" class="btn btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edytuj strone
</a>
<a href="/sites" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Lista stron
</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card h-100">
<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?">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-repeat me-1"></i>Synchronizuj teraz
</button>
</form>
</div>
<div class="card-body">
<canvas id="seoVisibilityChart" height="320"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Aktualne metryki</h5>
</div>
<div class="card-body">
<?php if (!empty($seoLatest)): ?>
<div class="row g-2 mb-3">
<div class="col-6">
<div class="border rounded p-2">
<div class="small text-muted">TOP3</div>
<div class="fw-semibold"><?= (int) $seoLatest['top3'] ?></div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2">
<div class="small text-muted">TOP10</div>
<div class="fw-semibold"><?= (int) $seoLatest['top10'] ?></div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2">
<div class="small text-muted">TOP20</div>
<div class="fw-semibold"><?= (int) $seoLatest['top20'] ?></div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2">
<div class="small text-muted">TOP50</div>
<div class="fw-semibold"><?= (int) $seoLatest['top50'] ?></div>
</div>
</div>
</div>
<p class="small mb-2"><strong>Ruch:</strong> <?= (int) $seoLatest['traffic'] ?></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']))) ?>
</p>
<?php else: ?>
<p class="text-muted small mb-0">Brak zapisanych danych SEO. Uzyj przycisku "Synchronizuj teraz".</p>
<?php endif; ?>
</div>
</div>
<div class="card border-info">
<div class="card-header bg-info-subtle">
<h5 class="mb-0">Kolejne zrodla (plan)</h5>
</div>
<div class="card-body">
<p class="small text-muted mb-0">Ten panel jest przygotowany pod dodatkowe integracje SEO (np. GSC, Ahrefs, Senuto) bez mieszania z ustawieniami WordPress.</p>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script>
(function () {
var seoMetrics = <?= json_encode($seoMetrics ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var seoCanvas = document.getElementById('seoVisibilityChart');
if (!seoCanvas || !window.Chart) {
return;
}
if (!Array.isArray(seoMetrics) || seoMetrics.length === 0) {
seoCanvas.style.display = 'none';
return;
}
var labels = seoMetrics.map(function (item) {
var date = new Date(item.metric_month);
if (isNaN(date.getTime())) return item.metric_month;
var month = String(date.getMonth() + 1).padStart(2, '0');
return month + '.' + date.getFullYear();
});
new Chart(seoCanvas, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'TOP3',
data: seoMetrics.map(function (item) { return parseInt(item.top3 || 0, 10); }),
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.15)',
tension: 0.25,
yAxisID: 'y'
},
{
label: 'TOP10',
data: seoMetrics.map(function (item) { return parseInt(item.top10 || 0, 10); }),
borderColor: '#20c997',
backgroundColor: 'rgba(32,201,151,0.15)',
tension: 0.25,
yAxisID: 'y'
},
{
label: 'TOP20',
data: seoMetrics.map(function (item) { return parseInt(item.top20 || 0, 10); }),
borderColor: '#fd7e14',
backgroundColor: 'rgba(253,126,20,0.15)',
tension: 0.25,
yAxisID: 'y'
},
{
label: 'TOP50',
data: seoMetrics.map(function (item) { return parseInt(item.top50 || 0, 10); }),
borderColor: '#dc3545',
backgroundColor: 'rgba(220,53,69,0.15)',
tension: 0.25,
yAxisID: 'y'
},
{
label: 'Ruch',
data: seoMetrics.map(function (item) { return parseInt(item.traffic || 0, 10); }),
borderColor: '#6f42c1',
backgroundColor: 'rgba(111,66,193,0.15)',
tension: 0.25,
yAxisID: 'yTraffic'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
y: {
beginAtZero: true,
position: 'left',
title: { display: true, text: 'TOP' }
},
yTraffic: {
beginAtZero: true,
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: 'Ruch' }
}
}
}
});
})();
</script>