Initial commit: Animexe Laravel platform

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:01:48 +03:00
co-authored by Claude Opus 4.8
commit a63515cfc6
366 changed files with 74773 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Tribunal;
use App\Models\TribunalVote;
class CloseTribunals extends Command
{
protected $signature = 'tribunals:close';
protected $description = 'Süresi dolan mahkemeleri kapat ve kazananı belirle';
public function handle(): int
{
$expired = Tribunal::where('status', 'open')
->where('closes_at', '<=', now())
->get();
if ($expired->isEmpty()) {
$this->info('Kapatılacak mahkeme yok.');
return 0;
}
foreach ($expired as $tribunal) {
$sides = $tribunal->allSides();
$counts = [];
foreach (array_keys($sides) as $side) {
$counts[$side] = TribunalVote::where('tribunal_id', $tribunal->id)
->where('side', $side)
->count();
}
arsort($counts);
$topSide = array_key_first($counts);
$topCount = $counts[$topSide];
$allEqual = count(array_unique(array_values($counts))) === 1 && array_sum($counts) > 0;
$verdict = null;
if (!$allEqual && $topCount > 0) {
$verdict = $sides[$topSide] ?? $topSide;
}
$tribunal->update([
'status' => 'closed',
'verdict' => $verdict,
]);
$this->line("Kapatıldı: #{$tribunal->id} — Karar: " . ($verdict ?? 'Beraberlik'));
}
$this->info("{$expired->count()} mahkeme kapatıldı.");
return 0;
}
}
@@ -0,0 +1,132 @@
<?php
namespace App\Console\Commands;
use App\Models\ImportJob;
use App\Models\Anime;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* CreateCrossImportJobs
*
* Yayınlanan animeler için çapraz kaynak job'ları oluşturur:
* Anizium import'u olan ama AnimeCix import'u olmayan anime AnimeCix job (watch_id bilgisi Python'dan)
* AnimeCix import'u olan ama Anizium import'u olmayan anime Anizium job (watch_id bilgisi job'lardan)
* Her iki kaynakta da done job'u olan anime bölüm bazlı video_sources yenileme için
* yeni job (done job'u "pending" yaparak botu yeniden çalıştırır)
*
* Kullanım:
* php artisan animexe:cross-import # İki kaynakta da eksik job'ları oluştur
* php artisan animexe:cross-import --force # Var olan done job'larını da yeniden kuyruğa al
* php artisan animexe:cross-import --only=anizium # Sadece Anizium job'larını oluştur
* php artisan animexe:cross-import --only=animecix # Sadece AnimeCix job'larını oluştur
*/
class CreateCrossImportJobs extends Command
{
protected $signature = 'animexe:cross-import
{--force : Mevcut done job\'larını yeniden pending yap}
{--only= : Sadece belirtilen kaynağı oluştur (anizium|animecix)}
{--dry-run : Job oluşturmadan listele}';
protected $description = 'Mevcut animeler için eksik kaynak job\'larını oluşturur (Anizium ↔ AnimeCix çapraz import)';
public function handle(): int
{
$force = $this->option('force');
$only = $this->option('only');
$dryRun = $this->option('dry-run');
$this->info("Animexe Çapraz Import Job Oluşturucu");
$this->info("====================================");
// Tüm yayınlanan animeleri job durumlarıyla al
$animes = Anime::where('is_published', true)
->with('importJobs')
->get();
$this->info("Toplam yayınlanan anime: {$animes->count()}");
$aniziumCreated = 0;
$animecixCreated = 0;
$skipped = 0;
$requeued = 0;
foreach ($animes as $anime) {
$jobs = $anime->importJobs;
$aniziumJobs = $jobs->where('source', 'anizium');
$animecixJobs = $jobs->where('source', 'animecix');
$hasAniziumDone = $aniziumJobs->where('status', 'done')->isNotEmpty();
$hasAnimecixDone = $animecixJobs->where('status', 'done')->isNotEmpty();
$hasAniziumAny = $aniziumJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty();
$hasAnimecixAny = $animecixJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty();
// ── Re-queue: ikisi de done olan animeleri yeniden işlet ──────────
if ($force && $hasAniziumDone && $hasAnimecixDone) {
if (!$dryRun) {
// En son done Anizium job'unu yeniden pending yap
$aj = $aniziumJobs->where('status', 'done')->sortByDesc('id')->first();
if ($aj) {
$aj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']);
}
// En son done AnimeCix job'unu yeniden pending yap
$cj = $animecixJobs->where('status', 'done')->sortByDesc('id')->first();
if ($cj) {
$cj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']);
}
$requeued++;
} else {
$this->line("[DRY] Re-queue: {$anime->title} (her iki kaynak mevcut)");
}
continue;
}
// ── Anizium job oluştur ───────────────────────────────────────────
if ((!$only || $only === 'anizium') && !$hasAniziumAny) {
// Anizium watch_id'yi bilmiyoruz — Python reimport_all.py bu görevi üstlenir.
// Buradan sadece anime_id'yi atayarak "ihtiyaç listesi" oluşturabiliriz;
// watch_id olmadan bot job'ı işleyemez. Bu yüzden sadece logluyoruz.
$this->warn("[SKIP-ANİZİUM] {$anime->title} (watch_id bilinmiyor — reimport_all.py kullan)");
$skipped++;
}
// ── AnimeCix job oluştur ──────────────────────────────────────────
if ((!$only || $only === 'animecix') && !$hasAnimecixAny) {
// animecix_title_id'yi bilmiyoruz — daemon.py bunu otomatik keşfeder.
$this->warn("[SKIP-ANİMECİX] {$anime->title} (title_id bilinmiyor — daemon.py kullan)");
$skipped++;
}
// ── Anizium'u olan ama AnimeCix'i olmayan: Anizium job'larından watch_id var ──
// ─ (bu zaten mevcut) ─
// ── AnimeCix'i olan ama Anizium'u olmayan: watch_id bilinmiyorsa skip ──
if ((!$only || $only === 'anizium') && $hasAnimecixDone && !$hasAniziumAny) {
// AnimeCix'te var ama Anizium'da yok — reimport_all.py Anizium katalogunda arayacak
$this->line("[ANİZİUM-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — reimport_all.py işleyecek");
}
// ── Anizium'u olan ama AnimeCix'i olmayan: daemon.py katalog taramasında otomatik bulur ──
if ((!$only || $only === 'animecix') && $hasAniziumDone && !$hasAnimecixAny) {
$this->line("[ANİMECİX-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — daemon.py bulacak");
}
}
// ── Var olan Anizium done job'larını yeniden pending yap (--force) ───
if ($force && !$dryRun) {
$this->info("Re-queue tamamlandı: {$requeued} anime");
}
$this->newLine();
$this->info("Özet:");
$this->info(" Anizium job oluşturuldu : {$aniziumCreated}");
$this->info(" AnimeCix job oluşturuldu: {$animecixCreated}");
$this->info(" Yeniden kuyruğa alındı : {$requeued}");
$this->info(" Atlandı (watch_id yok) : {$skipped}");
$this->newLine();
$this->info("Tüm anime + kaynak eşleştirmesi için: python reimport_all.py");
return 0;
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Console\Commands;
use App\Models\Anime;
use App\Services\AniListService;
use Illuminate\Console\Command;
class FetchAniListImages extends Command
{
protected $signature = 'anime:fetch-images
{--missing : Sadece kapak veya banneri olmayan animeleri işle (varsayılan)}
{--all : Tüm animeleri işle (dolu alanların üzerine yazmaz)}
{--id= : Belirli bir anime ID}
{--limit=50 : Tek seferinde işlenecek maksimum sayı}';
protected $description = 'AniList\'ten kapak ve banner resimlerini çek';
public function handle(): void
{
$service = new AniListService();
$query = Anime::query();
if ($id = $this->option('id')) {
$query->where('id', $id);
} elseif (!$this->option('all')) {
// Varsayılan: eksik resmi olanlar
$query->where(function ($q) {
$q->whereNull('cover_image')->orWhere('cover_image', '')
->orWhereNull('banner_image')->orWhere('banner_image', '');
});
}
$limit = (int) $this->option('limit');
$animes = $query->limit($limit)->get();
if ($animes->isEmpty()) {
$this->info('Eksik resim bulunamadı.');
return;
}
$this->info("İşlenecek: {$animes->count()} anime");
$bar = $this->output->createProgressBar($animes->count());
$bar->start();
$ok = $skip = $fail = 0;
foreach ($animes as $anime) {
try {
$updated = $service->fillImages($anime);
$updated ? $ok++ : $skip++;
} catch (\Throwable $e) {
$fail++;
$this->newLine();
$this->warn("#{$anime->id} {$anime->title}: {$e->getMessage()}");
}
$bar->advance();
usleep(500_000); // AniList rate limit: 90 req/dakika
}
$bar->finish();
$this->newLine(2);
$this->info("Bitti — güncellendi: {$ok}, değişmedi: {$skip}, hata: {$fail}");
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\Anime;
use App\Services\JikanService;
use Illuminate\Console\Command;
class FetchMalIds extends Command
{
protected $signature = 'animexe:fetch-mal-ids {--force : Overwrite existing MAL IDs}';
protected $description = 'Auto-fetch MAL IDs for all animes and fill season mal_id chain via Jikan API';
public function handle(): int
{
$jikan = new JikanService();
$query = Anime::query();
if (!$this->option('force')) {
$query->whereNull('mal_id');
}
$animes = $query->get();
$this->info("Processing {$animes->count()} anime(s)…");
$bar = $this->output->createProgressBar($animes->count());
$bar->start();
$found = 0;
foreach ($animes as $anime) {
try {
if (!$anime->mal_id || $this->option('force')) {
$malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type);
if ($malId) {
$anime->update(['mal_id' => $malId]);
$found++;
}
usleep(400_000); // rate limit
}
// Fill season chain
if ($anime->mal_id) {
$chain = $jikan->fetchSeasonMalIds($anime->mal_id);
foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) {
if (!$season->mal_id && isset($chain[$i])) {
$season->update(['mal_id' => $chain[$i]]);
}
}
}
} catch (\Throwable $e) {
$this->newLine();
$this->warn(" ⚠ [{$anime->title}]: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Done. {$found} new MAL ID(s) fetched.");
return 0;
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Models\Anime;
use App\Models\Genre;
use App\Services\DeepSeekService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class FillAnimeMeta extends Command
{
protected $signature = 'animexe:fill-anime-meta
{--limit=0 : Max anime sayısı (0 = sınırsız, hepsi)}
{--anime= : Sadece belirli bir anime ID işle}
{--force : Dolu alanlar olsa bile yeniden doldur}
{--dry-run : Gerçekten kaydetme, sadece ne yapılacağını göster}';
protected $description = 'Eksik meta verisi olan animeleri DeepSeek AI ile toplu doldurur (description, genres, studio, year, vb.)';
public function handle(DeepSeekService $ai): int
{
// PHP zaman aşımını kaldır — tüm animeler işlenene kadar çalışsın
set_time_limit(0);
if (!$ai->isConfigured()) {
$this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key');
return self::FAILURE;
}
$limit = (int) $this->option('limit');
$animeId = $this->option('anime');
$force = $this->option('force');
$dry = $this->option('dry-run');
// İşlenecek animeleri belirle
if ($animeId) {
$animes = Anime::where('id', $animeId)->with('genres')->get();
} else {
$query = Anime::with('genres');
if (!$force) {
$query->where(function ($q) {
$q->whereNull('description')->orWhere('description', '')
->orWhereNull('release_year')
->orWhereNull('studio')->orWhere('studio', '')
->orWhereNull('type')->orWhere('type', '')
->orWhereNull('status')->orWhere('status', '');
})->orDoesntHave('genres');
}
$query->orderBy('id');
if ($limit > 0) $query->limit($limit);
$animes = $query->get();
}
if ($animes->isEmpty()) {
$this->info('İşlenecek anime bulunamadı (tüm alanlar dolu).');
Log::channel('daily')->info('[FillAnimeMeta] İşlenecek anime yok.');
return self::SUCCESS;
}
$this->info("Toplam {$animes->count()} anime işlenecek" . ($dry ? ' (dry-run)' : '') . '...');
Log::channel('daily')->info("[FillAnimeMeta] Başladı. {$animes->count()} anime, limit={$limit}, force=" . ($force ? 'evet' : 'hayır'));
$done = 0;
$skipped = 0;
$failed = 0;
foreach ($animes as $anime) {
$missing = $this->missingFields($anime);
if (!$force && empty($missing)) {
$this->line(" <fg=gray>ATLA</> {$anime->title} — tüm alanlar dolu");
$skipped++;
continue;
}
$label = $force ? 'tüm alanlar' : implode(', ', $missing);
$this->line(" <fg=cyan>İŞLE</> [{$anime->id}] {$anime->title} — eksik: {$label}");
if ($dry) {
$done++;
continue;
}
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
if (!$meta) {
$this->warn(" <fg=red>HATA</> {$anime->title} — AI boş yanıt döndürdü");
Log::channel('daily')->warning("[FillAnimeMeta] HATA [{$anime->id}] {$anime->title}: AI boş yanıt");
$failed++;
sleep(3);
continue;
}
// Sadece boş alanları doldur (force modunda hepsini güncelle)
$updates = [];
$fillIfEmpty = function (string $field, $value) use ($anime, $force, &$updates) {
if ($value === null || $value === '') return;
if ($force || empty($anime->$field)) {
$updates[$field] = $value;
}
};
$fillIfEmpty('description', $meta['description'] ?? null);
$fillIfEmpty('release_year', $meta['release_year'] ?? null);
$fillIfEmpty('studio', $meta['studio'] ?? null);
$fillIfEmpty('type', $meta['type'] ?? null);
$fillIfEmpty('status', $meta['status'] ?? null);
$fillIfEmpty('title_en', $meta['title_en'] ?? null);
$fillIfEmpty('title_jp', $meta['title_jp'] ?? null);
// Rating: sadece boşsa veya 0 ise doldur
if (!empty($meta['rating']) && ($force || !$anime->rating)) {
$updates['rating'] = min(10, max(0, (float) $meta['rating']));
}
if (!empty($updates)) {
$anime->update($updates);
}
// Genres: boşsa ekle
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
$genreIds = [];
foreach ($meta['genres'] as $genreName) {
$genre = Genre::firstOrCreate(
['name' => $genreName],
['slug' => \Illuminate\Support\Str::slug($genreName)]
);
$genreIds[] = $genre->id;
}
if ($genreIds) {
$force ? $anime->genres()->sync($genreIds) : $anime->genres()->syncWithoutDetaching($genreIds);
}
}
$updatedFields = array_keys($updates);
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
$updatedFields[] = 'genres(' . implode(',', $meta['genres'] ?? []) . ')';
}
$summary = empty($updatedFields) ? 'Yeni alan yok' : implode(', ', $updatedFields);
$this->info(" <fg=green>✓ OK</> [{$anime->id}] {$anime->title}{$summary}");
Log::channel('daily')->info("[FillAnimeMeta] OK [{$anime->id}] {$anime->title}{$summary}");
$done++;
// API rate limit — DeepSeek'i boğma
sleep(2);
}
$summary = "Tamamlandı: {$done} işlendi, {$skipped} atlandı, {$failed} hata.";
$this->info($summary);
Log::channel('daily')->info("[FillAnimeMeta] {$summary}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function missingFields(Anime $anime): array
{
$missing = [];
if (empty($anime->description)) $missing[] = 'description';
if (empty($anime->release_year)) $missing[] = 'release_year';
if (empty($anime->studio)) $missing[] = 'studio';
if (empty($anime->type)) $missing[] = 'type';
if (empty($anime->status)) $missing[] = 'status';
if (!$anime->rating) $missing[] = 'rating';
if (empty($anime->title_en)) $missing[] = 'title_en';
if ($anime->genres->isEmpty()) $missing[] = 'genres';
return $missing;
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use App\Models\Episode;
use App\Models\Season;
use App\Models\Subtitle;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* FixSubtitleMismatch
*
* Anizium altyazı bug'ı: episode 1'in altyazısı diğer bölümlere yanlışlıkla kaydedilmiş olabilir.
* Altyazı URL'sindeki name=s1_b1_XX parametresini kontrol eder; yanlış bölüme işaret edenleri siler.
*
* Kullanım:
* php artisan animexe:fix-subtitles # Yanlış altyazıları sil
* php artisan animexe:fix-subtitles --dry-run # Sadece listele
* php artisan animexe:fix-subtitles --anime-id=42 # Sadece bu anime
*/
class FixSubtitleMismatch extends Command
{
protected $signature = 'animexe:fix-subtitles
{--dry-run : Silmeden önce listele}
{--anime-id= : Sadece bu anime_id\'yi kontrol et}';
protected $description = 'Anizium altyazı bölüm uyuşmazlığı bug\'ını düzelt (name parametresi yanlış episode\'a işaret eden altyazıları sil)';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$animeId = $this->option('anime-id');
$this->info("Anizium Altyazı Uyuşmazlık Düzeltici");
$this->info("=====================================");
if ($dryRun) $this->warn("DRY-RUN modu — hiçbir şey silinmeyecek");
$query = Subtitle::query()
->join('episodes', 'subtitles.episode_id', '=', 'episodes.id')
->join('seasons', 'seasons.id', '=', 'episodes.season_id')
->whereNotNull('subtitles.url')
->where('subtitles.url', 'like', '%anizium%')
->select(
'subtitles.id as subtitle_id',
'subtitles.episode_id',
'subtitles.language',
'subtitles.url',
'seasons.season_number',
'episodes.episode_number',
'episodes.anime_id',
);
if ($animeId) {
$query->where('episodes.anime_id', (int) $animeId);
}
$subtitles = $query->get();
$this->info("Kontrol edilecek Anizium altyazısı: {$subtitles->count()}");
$mismatchIds = [];
foreach ($subtitles as $sub) {
$url = $sub->url;
$season = (int) $sub->season_number;
$episode = (int) $sub->episode_number;
$lang = $sub->language;
// URL'den name parametresini çıkar
$parsed = parse_url($url);
if (!isset($parsed['query'])) continue;
parse_str($parsed['query'], $params);
$name = $params['name'] ?? '';
if (!$name) continue;
// Beklenen: s{season}_b{episode}_{lang}
$expectedPrefix = "s{$season}_b{$episode}_";
if (!str_starts_with($name, $expectedPrefix)) {
$mismatchIds[] = $sub->subtitle_id;
$this->line(
"[MISMATCH] sub_id={$sub->subtitle_id} "
. "anime_id={$sub->anime_id} "
. "S{$season}E{$episode} {$lang} "
. "| name={$name} "
. "(beklenen prefix: {$expectedPrefix})"
);
}
}
$this->newLine();
$this->info("Uyuşmazlık bulunan: " . count($mismatchIds));
if (empty($mismatchIds)) {
$this->info("Düzeltilecek altyazı bulunamadı.");
return 0;
}
if ($dryRun) {
$this->warn("--dry-run: {" . count($mismatchIds) . "} altyazı silinecekti.");
return 0;
}
$deleted = Subtitle::whereIn('id', $mismatchIds)->delete();
$this->info("Silindi: {$deleted} yanlış altyazı.");
$this->info("Botları yeniden çalıştırarak doğru altyazıları yeniden indirebilirsiniz.");
$this->info(" python anizium_scraper/bot2_upload.py --daemon");
return 0;
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands;
use App\Models\Anime;
use App\Models\BlogPost;
use App\Services\DeepSeekService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class GenerateBlogPosts extends Command
{
protected $signature = 'animexe:generate-blogs
{--count=2 : Kaç blog yazısı üretileceği}
{--anime= : Belirli bir anime ID için üret}
{--force : Daha önce blog yazılmış animeler için de üret}';
protected $description = 'DeepSeek AI ile anime blog yazıları üretir';
public function handle(DeepSeekService $deepseek): int
{
if (!$deepseek->isConfigured()) {
$this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key');
return self::FAILURE;
}
$count = (int) $this->option('count');
$animeId = $this->option('anime');
$force = $this->option('force');
if ($animeId) {
$animes = Anime::where('id', $animeId)->where('is_published', true)->with('genres')->get();
} else {
$alreadyBlogged = $force ? [] : BlogPost::whereNotNull('anime_id')->pluck('anime_id')->toArray();
$animesQuery = Anime::where('is_published', true)
->whereNotIn('id', $alreadyBlogged)
->with('genres')
->orderByDesc('rating')
->limit($count * 3)
->get();
$randomResult = $animesQuery->random(min($count, $animesQuery->count()));
$animes = collect($randomResult);
}
if ($animes->isEmpty()) {
$this->info('Blog yazısı üretilecek anime bulunamadı.');
return self::SUCCESS;
}
$generated = 0;
foreach ($animes->take($count) as $anime) {
$this->info("Blog üretiliyor: {$anime->title}...");
// Aynı türden ilgili animeler bul
$genreIds = $anime->genres->pluck('id');
$related = Anime::where('is_published', true)
->where('id', '!=', $anime->id)
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $genreIds))
->orderByDesc('rating')
->limit(5)
->get(['id', 'title', 'slug'])
->map(fn($a) => ['slug' => $a->slug, 'title' => $a->title])
->toArray();
$data = $deepseek->generateBlogPost($anime, $related);
if (!$data || empty($data['content'])) {
$this->warn(" [{$anime->title}] için içerik üretilemedi: " . $deepseek->lastError);
continue;
}
// [LINK:slug]Title[/LINK] placeholder'larını gerçek URL'lerle değiştir
$content = preg_replace_callback(
'/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/',
function ($m) {
$slug = trim($m[1]);
$label = trim($m[2]);
try {
$url = route('anime.show', $slug);
return "<a href=\"{$url}\">{$label}</a>";
} catch (\Exception $e) {
return $label;
}
},
$data['content'] ?? ''
);
$title = $data['title'] ?? ($anime->title . ' İzle — Animexe Rehberi');
$slug = BlogPost::generateSlug($title);
// Linked anime IDs
$linkedIds = [];
if (!empty($data['linked_slugs'])) {
$linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray();
}
$readingTime = max(3, (int) (str_word_count(strip_tags($content)) / 200));
BlogPost::create([
'title' => $title,
'slug' => $slug,
'excerpt' => $data['excerpt'] ?? '',
'content' => $content,
'cover_image' => $anime->cover_image,
'focus_keyword' => $data['focus_keyword'] ?? $anime->title,
'meta_title' => $data['title'] ?? null,
'meta_description' => $data['meta_description'] ?? $data['excerpt'] ?? '',
'meta_keywords' => implode(', ', array_filter([
$anime->title,
$anime->title . ' izle',
'türkçe anime',
$data['focus_keyword'] ?? '',
])),
'status' => 'published',
'ai_generated' => true,
'anime_id' => $anime->id,
'linked_anime_ids' => $linkedIds,
'faq' => $data['faq'] ?? [],
'reading_time' => $readingTime,
'published_at' => now(),
]);
$this->info(" ✓ Blog yazısı oluşturuldu: {$title}");
$generated++;
// API rate limit
sleep(2);
}
$this->info("Tamamlandı. {$generated} blog yazısı üretildi.");
return self::SUCCESS;
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use App\Models\Anime;
use App\Services\DeepSeekService;
use Illuminate\Console\Command;
class GenerateDiscoveryHooks extends Command
{
protected $signature = 'anime:generate-hooks {--limit=50 : Kaç anime işlensin} {--force : Zaten hook olanları da yeniden üret}';
protected $description = 'Keşfet sayfası için anime hook metinlerini AI ile üret';
public function handle(DeepSeekService $ai): int
{
if (!$ai->isConfigured()) {
$this->error('DeepSeek API anahtarı ayarlanmamış.');
return 1;
}
$limit = (int) $this->option('limit');
$force = $this->option('force');
$query = Anime::where('is_published', true)->with('genres:id,name');
if (!$force) {
$query->whereNull('discovery_hook');
}
$animes = $query->limit($limit)->get();
if ($animes->isEmpty()) {
$this->info('Hook üretilecek anime bulunamadı.');
return 0;
}
$this->info("Toplam {$animes->count()} anime için hook üretiliyor...");
$bar = $this->output->createProgressBar($animes->count());
$bar->start();
$done = 0; $failed = 0;
foreach ($animes as $anime) {
$hook = $ai->generateDiscoveryHook($anime);
if ($hook) {
$anime->updateQuietly(['discovery_hook' => $hook]);
$done++;
} else {
$failed++;
}
$bar->advance();
usleep(300_000); // Rate limit — 0.3sn ara
}
$bar->finish();
$this->newLine();
$this->info("Tamamlandı: {$done} başarılı, {$failed} başarısız.");
return 0;
}
}