diff --git a/.env b/.env index e0bf954..15e550e 100644 --- a/.env +++ b/.env @@ -13,4 +13,5 @@ PEXELS_API_KEY= APP_URL=https://backpro.projectpro.pl APP_SECRET=bP7x9kR3mW2vN5qT8sY1 -PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1 \ No newline at end of file +PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1 +SEO_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1 \ No newline at end of file diff --git a/.env.example b/.env.example index dfcefe3..3623419 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 8898871..486a69b 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -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": { diff --git a/config/routes.php b/config/routes.php index ebdf33c..19fd6ba 100644 --- a/config/routes.php +++ b/config/routes.php @@ -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'); diff --git a/cron/semstorm.php b/cron/semstorm.php new file mode 100644 index 0000000..a905a41 --- /dev/null +++ b/cron/semstorm.php @@ -0,0 +1,73 @@ +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); + } +} diff --git a/docs/CRON.md b/docs/CRON.md index d8ca097..6733c4a 100644 --- a/docs/CRON.md +++ b/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). diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 343eadc..8bf1552 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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 | diff --git a/migrations/008_cron_logs.sql b/migrations/008_cron_logs.sql new file mode 100644 index 0000000..fcbf23d --- /dev/null +++ b/migrations/008_cron_logs.sql @@ -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); diff --git a/migrations/009_site_seo_metrics.sql b/migrations/009_site_seo_metrics.sql new file mode 100644 index 0000000..6f7fcbf --- /dev/null +++ b/migrations/009_site_seo_metrics.sql @@ -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; diff --git a/src/Controllers/DashboardController.php b/src/Controllers/DashboardController.php index 824bb79..c97309c 100644 --- a/src/Controllers/DashboardController.php +++ b/src/Controllers/DashboardController.php @@ -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, + ]); + } } diff --git a/src/Controllers/LogController.php b/src/Controllers/LogController.php new file mode 100644 index 0000000..804a215 --- /dev/null +++ b/src/Controllers/LogController.php @@ -0,0 +1,88 @@ +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, + ]; + } +} diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index e58678b..1b15d30 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -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 diff --git a/src/Controllers/SiteController.php b/src/Controllers/SiteController.php index 4e98e6a..c6da7ba 100644 --- a/src/Controllers/SiteController.php +++ b/src/Controllers/SiteController.php @@ -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(); diff --git a/src/Helpers/Logger.php b/src/Helpers/Logger.php index 546058d..37f0882 100644 --- a/src/Helpers/Logger.php +++ b/src/Helpers/Logger.php @@ -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); } diff --git a/src/Models/Article.php b/src/Models/Article.php index 5765dcc..4afa87f 100644 --- a/src/Models/Article.php +++ b/src/Models/Article.php @@ -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( diff --git a/src/Models/Site.php b/src/Models/Site.php index 009fc7e..0ea245c 100644 --- a/src/Models/Site.php +++ b/src/Models/Site.php @@ -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; + } } diff --git a/src/Models/SiteSeoMetric.php b/src/Models/SiteSeoMetric.php new file mode 100644 index 0000000..0909063 --- /dev/null +++ b/src/Models/SiteSeoMetric.php @@ -0,0 +1,142 @@ +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(); + } +} diff --git a/src/Services/PublisherService.php b/src/Services/PublisherService.php index cb7c091..787a264 100644 --- a/src/Services/PublisherService.php +++ b/src/Services/PublisherService.php @@ -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]; + } } diff --git a/src/Services/SemstormService.php b/src/Services/SemstormService.php new file mode 100644 index 0000000..8b3af27 --- /dev/null +++ b/src/Services/SemstormService.php @@ -0,0 +1,164 @@ +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)), + ]; + } +} diff --git a/src/Services/SiteSeoSyncService.php b/src/Services/SiteSeoSyncService.php new file mode 100644 index 0000000..fbee587 --- /dev/null +++ b/src/Services/SiteSeoSyncService.php @@ -0,0 +1,98 @@ +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); + } +} diff --git a/templates/dashboard/seo-stats.php b/templates/dashboard/seo-stats.php new file mode 100644 index 0000000..6c4679a --- /dev/null +++ b/templates/dashboard/seo-stats.php @@ -0,0 +1,149 @@ +

Statystyki SEO (ostatnie dane)

+ +n/a'; + } + + $curr = (float) $current; + $prev = (float) $previous; + + if ($prev == 0.0) { + if ($curr == 0.0) { + return '0%'; + } + return 'n/a'; + } + + $delta = (($curr - $prev) / $prev) * 100.0; + $class = $delta > 0 ? 'text-success' : ($delta < 0 ? 'text-danger' : 'text-muted'); + $sign = $delta > 0 ? '+' : ''; + + return '' . $sign . number_format($delta, 1, '.', '') . '%'; + }; +?> + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StronaStatusMiesiacTOP3TOP10TOP20TOP50RuchAktualizacja
Brak danych SEO.
+ + + + + + ON + + OFF + + + + + + - + + + + + + + - + + + + + + + - + + + + + + + - + + + + + + + - + + + + + + + - + + + + + + - + + + + + +
+
+
diff --git a/templates/layout/sidebar.php b/templates/layout/sidebar.php index cdebbf3..fbfddae 100644 --- a/templates/layout/sidebar.php +++ b/templates/layout/sidebar.php @@ -14,6 +14,11 @@ Strony + +