first commit

This commit is contained in:
2026-02-15 11:37:27 +01:00
commit 884ee9cc88
299 changed files with 41102 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use App\Core\Config;
use App\Helpers\Logger;
class ImageService
{
private Client $client;
public function __construct()
{
$this->client = new Client(['timeout' => 60]);
}
public function generate(string $articleTitle, string $topicName): ?array
{
$provider = Config::getDbSetting('image_provider', 'freepik');
return match ($provider) {
'freepik' => $this->generateFreepik($articleTitle, $topicName),
'unsplash' => $this->searchUnsplash($topicName),
'pexels' => $this->searchPexels($topicName),
default => $this->searchPexels($topicName),
};
}
private function generateFreepik(string $articleTitle, string $topicName): ?array
{
$apiKey = Config::getDbSetting('freepik_api_key', Config::get('FREEPIK_API_KEY'));
if (empty($apiKey)) {
Logger::warning('Freepik API key not configured, falling back to Pexels', 'image');
return $this->searchPexels($topicName);
}
try {
$prompt = "Professional blog header image about {$topicName}: {$articleTitle}, high quality, photorealistic";
$response = $this->client->post('https://api.freepik.com/v1/ai/text-to-image', [
'headers' => [
'x-freepik-api-key' => $apiKey,
'Content-Type' => 'application/json',
],
'json' => [
'prompt' => $prompt,
'negative_prompt' => 'text, watermark, logo, blurry, low quality',
'image' => ['size' => 'landscape_16_9'],
],
]);
$data = json_decode($response->getBody()->getContents(), true);
if (!empty($data['data'][0]['base64'])) {
$imageData = base64_decode($data['data'][0]['base64']);
return [
'data' => $imageData,
'filename' => 'article-' . time() . '.jpg',
'mime' => 'image/jpeg',
];
}
Logger::error('Freepik returned empty image data', 'image');
return null;
} catch (GuzzleException $e) {
Logger::error('Freepik API error: ' . $e->getMessage(), 'image');
return $this->searchPexels($topicName);
}
}
private function searchUnsplash(string $query): ?array
{
$apiKey = Config::getDbSetting('unsplash_api_key', Config::get('UNSPLASH_API_KEY'));
if (empty($apiKey)) {
Logger::warning('Unsplash API key not configured', 'image');
return null;
}
try {
$response = $this->client->get('https://api.unsplash.com/search/photos', [
'headers' => ['Authorization' => "Client-ID {$apiKey}"],
'query' => [
'query' => $query,
'orientation' => 'landscape',
'per_page' => 5,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$results = $data['results'] ?? [];
if (empty($results)) {
return null;
}
$photo = $results[array_rand($results)];
$imageUrl = $photo['urls']['regular'] ?? null;
if (!$imageUrl) {
return null;
}
$imageData = $this->client->get($imageUrl)->getBody()->getContents();
return [
'data' => $imageData,
'filename' => 'article-' . time() . '.jpg',
'mime' => 'image/jpeg',
];
} catch (GuzzleException $e) {
Logger::error('Unsplash API error: ' . $e->getMessage(), 'image');
return null;
}
}
private function searchPexels(string $query): ?array
{
$apiKey = Config::getDbSetting('pexels_api_key', Config::get('PEXELS_API_KEY'));
if (empty($apiKey)) {
Logger::warning('Pexels API key not configured', 'image');
return null;
}
try {
$response = $this->client->get('https://api.pexels.com/v1/search', [
'headers' => ['Authorization' => $apiKey],
'query' => [
'query' => $query,
'orientation' => 'landscape',
'per_page' => 5,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$photos = $data['photos'] ?? [];
if (empty($photos)) {
return null;
}
$photo = $photos[array_rand($photos)];
$imageUrl = $photo['src']['large'] ?? $photo['src']['original'] ?? null;
if (!$imageUrl) {
return null;
}
$imageData = $this->client->get($imageUrl)->getBody()->getContents();
return [
'data' => $imageData,
'filename' => 'article-' . time() . '.jpg',
'mime' => 'image/jpeg',
];
} catch (GuzzleException $e) {
Logger::error('Pexels API error: ' . $e->getMessage(), 'image');
return null;
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use App\Core\Config;
use App\Helpers\Logger;
class OpenAIService
{
private Client $client;
public function __construct()
{
$this->client = new Client([
'base_uri' => 'https://api.openai.com/v1/',
'timeout' => 120,
]);
}
public function generateArticle(string $topicName, string $topicDescription, array $existingTitles): ?array
{
$apiKey = Config::getDbSetting('openai_api_key', Config::get('OPENAI_API_KEY'));
$model = Config::getDbSetting('openai_model', Config::get('OPENAI_MODEL', 'gpt-4o'));
$minWords = Config::getDbSetting('article_min_words', '800');
$maxWords = Config::getDbSetting('article_max_words', '1200');
if (empty($apiKey)) {
Logger::error('OpenAI API key not configured', 'openai');
return null;
}
$existingList = !empty($existingTitles)
? implode("\n- ", $existingTitles)
: '(brak - to pierwszy artykuł z tego tematu)';
$systemPrompt = "Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, "
. "optymalizowane pod SEO. Artykuł powinien mieć {$minWords}-{$maxWords} słów, "
. "zawierać nagłówki H2 i H3, być angażujący i merytoryczny. "
. "Formatuj treść w HTML (bez tagów <html>, <body>, <head>). "
. "Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {\"title\": \"tytuł artykułu\", \"content\": \"treść HTML artykułu\"}";
$userPrompt = "Napisz artykuł na temat: {$topicName}\n";
if (!empty($topicDescription)) {
$userPrompt .= "Wytyczne: {$topicDescription}\n";
}
$userPrompt .= "\nWAŻNE - NIE pisz o następujących tematach, bo artykuły o nich już istnieją na stronie:\n- {$existingList}";
$fullPrompt = $systemPrompt . "\n\n" . $userPrompt;
try {
$response = $this->client->post('chat/completions', [
'headers' => [
'Authorization' => "Bearer {$apiKey}",
'Content-Type' => 'application/json',
],
'json' => [
'model' => $model,
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
'temperature' => 0.8,
'max_tokens' => 4000,
'response_format' => ['type' => 'json_object'],
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$content = $data['choices'][0]['message']['content'] ?? null;
if (!$content) {
Logger::error('Empty response from OpenAI', 'openai');
return null;
}
$article = json_decode($content, true);
if (!isset($article['title']) || !isset($article['content'])) {
Logger::error('Invalid JSON structure from OpenAI: ' . $content, 'openai');
return null;
}
Logger::info("Generated article: {$article['title']}", 'openai');
return [
'title' => $article['title'],
'content' => $article['content'],
'model' => $model,
'prompt' => $fullPrompt,
];
} catch (GuzzleException $e) {
Logger::error('OpenAI API error: ' . $e->getMessage(), 'openai');
return null;
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Services;
use App\Models\Site;
use App\Models\Topic;
use App\Models\Article;
use App\Helpers\Logger;
class PublisherService
{
private TopicBalancer $topicBalancer;
private OpenAIService $openAI;
private ImageService $imageService;
private WordPressService $wordpress;
public function __construct()
{
$this->topicBalancer = new TopicBalancer();
$this->openAI = new OpenAIService();
$this->imageService = new ImageService();
$this->wordpress = new WordPressService();
}
public function publishNext(): array
{
Logger::info('Rozpoczynam automatyczną publikację', 'publish');
$sites = Site::findDueForPublishing();
if (empty($sites)) {
Logger::info('Brak stron do publikacji', 'publish');
return ['success' => false, 'message' => 'Brak stron wymagających publikacji.'];
}
$site = $sites[0];
return $this->publishForSite($site);
}
public function publishForSite(array $site): array
{
Logger::info("Publikacja dla strony: {$site['name']} (ID: {$site['id']})", 'publish');
// 1. Select topic
$topic = $this->topicBalancer->getNextTopic($site['id']);
if (!$topic) {
Logger::error("Brak aktywnych tematów dla strony {$site['name']}", 'publish');
return ['success' => false, 'message' => "Brak aktywnych tematów dla strony {$site['name']}."];
}
Logger::info("Wybrany temat: {$topic['name']} (ID: {$topic['id']})", 'publish');
// 2. Get existing titles to avoid repetition
$existingTitles = Article::getRecentTitlesByTopic($topic['id'], 20);
// 3. Generate article
$article = $this->openAI->generateArticle(
$topic['name'],
$topic['description'] ?? '',
$existingTitles
);
if (!$article) {
$this->saveFailedArticle($site, $topic, 'Nie udało się wygenerować artykułu przez OpenAI.');
return ['success' => false, 'message' => 'Błąd generowania artykułu przez AI.'];
}
Logger::info("Wygenerowano artykuł: {$article['title']}", 'publish');
// 4. Generate/fetch image
$imageUrl = null;
$mediaId = null;
$image = $this->imageService->generate($article['title'], $topic['name']);
if ($image) {
$mediaId = $this->wordpress->uploadMedia($site, $image['data'], $image['filename']);
if ($mediaId) {
Logger::info("Upload obrazka: media_id={$mediaId}", 'publish');
}
} else {
Logger::warning('Nie udało się wygenerować obrazka, publikacja bez obrazka', 'publish');
}
// 5. Publish to WordPress
$wpPostId = $this->wordpress->createPost(
$site,
$article['title'],
$article['content'],
$topic['wp_category_id'],
$mediaId
);
if (!$wpPostId) {
$this->saveFailedArticle($site, $topic, 'Nie udało się opublikować posta na WordPress.', $article);
return ['success' => false, 'message' => 'Błąd publikacji na WordPress.'];
}
Logger::info("Opublikowano post: wp_post_id={$wpPostId}", 'publish');
// 6. Save article in database
Article::create([
'site_id' => $site['id'],
'topic_id' => $topic['id'],
'title' => $article['title'],
'content' => $article['content'],
'wp_post_id' => $wpPostId,
'image_url' => $imageUrl,
'status' => 'published',
'ai_model' => $article['model'],
'prompt_used' => $article['prompt'],
'published_at' => date('Y-m-d H:i:s'),
]);
// 7. Update counters
Topic::incrementArticleCount($topic['id']);
Site::updateLastPublished($site['id']);
$message = "Opublikowano: \"{$article['title']}\" na {$site['name']}";
Logger::info($message, 'publish');
return ['success' => true, 'message' => $message];
}
private function saveFailedArticle(array $site, array $topic, string $error, ?array $article = null): void
{
Article::create([
'site_id' => $site['id'],
'topic_id' => $topic['id'],
'title' => $article['title'] ?? 'FAILED - nie wygenerowano',
'content' => $article['content'] ?? '',
'status' => 'failed',
'ai_model' => $article['model'] ?? null,
'prompt_used' => $article['prompt'] ?? null,
'error_message' => $error,
]);
Logger::error("Publikacja nieudana: {$error}", 'publish');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Services;
use App\Models\Topic;
class TopicBalancer
{
public function getNextTopic(int $siteId): ?array
{
$topics = Topic::findActiveBySite($siteId);
if (empty($topics)) {
return null;
}
// Topics are already ordered by article_count ASC, RAND()
// So the first one has the fewest articles (with random tiebreaker)
return $topics[0];
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use App\Helpers\Logger;
class WordPressService
{
private Client $client;
public function __construct()
{
$this->client = new Client(['timeout' => 30]);
}
public function testConnection(array $site): array
{
try {
$response = $this->client->get($site['url'] . '/wp-json/wp/v2/posts', [
'auth' => [$site['api_user'], $site['api_token']],
'query' => ['per_page' => 1],
]);
if ($response->getStatusCode() === 200) {
return ['success' => true, 'message' => 'Połączenie OK'];
}
return ['success' => false, 'message' => 'Nieoczekiwany kod: ' . $response->getStatusCode()];
} catch (GuzzleException $e) {
Logger::error("WP test connection failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
return ['success' => false, 'message' => 'Błąd: ' . $e->getMessage()];
}
}
public function getCategories(array $site): array|false
{
try {
$response = $this->client->get($site['url'] . '/wp-json/wp/v2/categories', [
'auth' => [$site['api_user'], $site['api_token']],
'query' => ['per_page' => 100],
]);
return json_decode($response->getBody()->getContents(), true);
} catch (GuzzleException $e) {
Logger::error("WP getCategories failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
return false;
}
}
public function uploadMedia(array $site, string $imageData, string $filename): ?int
{
try {
$response = $this->client->post($site['url'] . '/wp-json/wp/v2/media', [
'auth' => [$site['api_user'], $site['api_token']],
'headers' => [
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
'Content-Type' => $this->getMimeType($filename),
],
'body' => $imageData,
]);
$data = json_decode($response->getBody()->getContents(), true);
return $data['id'] ?? null;
} catch (GuzzleException $e) {
Logger::error("WP uploadMedia failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
return null;
}
}
public function createPost(
array $site,
string $title,
string $content,
?int $categoryId = null,
?int $mediaId = null
): ?int {
try {
$postData = [
'title' => $title,
'content' => $content,
'status' => 'publish',
];
if ($categoryId) {
$postData['categories'] = [$categoryId];
}
if ($mediaId) {
$postData['featured_media'] = $mediaId;
}
$response = $this->client->post($site['url'] . '/wp-json/wp/v2/posts', [
'auth' => [$site['api_user'], $site['api_token']],
'json' => $postData,
]);
$data = json_decode($response->getBody()->getContents(), true);
return $data['id'] ?? null;
} catch (GuzzleException $e) {
Logger::error("WP createPost failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
return null;
}
}
private function getMimeType(string $filename): string
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return match ($ext) {
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'image/jpeg',
};
}
}