first commit
This commit is contained in:
165
src/Services/ImageService.php
Normal file
165
src/Services/ImageService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/Services/OpenAIService.php
Normal file
98
src/Services/OpenAIService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/Services/PublisherService.php
Normal file
139
src/Services/PublisherService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
21
src/Services/TopicBalancer.php
Normal file
21
src/Services/TopicBalancer.php
Normal 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];
|
||||
}
|
||||
}
|
||||
117
src/Services/WordPressService.php
Normal file
117
src/Services/WordPressService.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user