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:
3
.env
3
.env
@@ -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
|
||||
@@ -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
|
||||
|
||||
215
.vscode/ftp-kr.sync.cache.json
vendored
215
.vscode/ftp-kr.sync.cache.json
vendored
@@ -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": {
|
||||
|
||||
@@ -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
73
cron/semstorm.php
Normal 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);
|
||||
}
|
||||
}
|
||||
14
docs/CRON.md
14
docs/CRON.md
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
12
migrations/008_cron_logs.sql
Normal file
12
migrations/008_cron_logs.sql
Normal 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);
|
||||
20
migrations/009_site_seo_metrics.sql
Normal file
20
migrations/009_site_seo_metrics.sql
Normal 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;
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
88
src/Controllers/LogController.php
Normal file
88
src/Controllers/LogController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
142
src/Models/SiteSeoMetric.php
Normal file
142
src/Models/SiteSeoMetric.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
164
src/Services/SemstormService.php
Normal file
164
src/Services/SemstormService.php
Normal 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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
98
src/Services/SiteSeoSyncService.php
Normal file
98
src/Services/SiteSeoSyncService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
149
templates/dashboard/seo-stats.php
Normal file
149
templates/dashboard/seo-stats.php
Normal 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>
|
||||
@@ -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
76
templates/logs/index.php
Normal 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; ?>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
180
templates/sites/seo.php
Normal 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>
|
||||
Reference in New Issue
Block a user