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
@@ -0,0 +1,971 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Anime;
use App\Models\Genre;
use App\Models\Setting;
use App\Models\SeoKeyword;
use App\Models\SeoRedirect;
use App\Models\Episode;
use App\Models\User;
use App\Models\Comment;
use App\Models\Watchlist;
use App\Models\AnimeRating;
use App\Models\BlogPost;
use App\Services\DeepSeekService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\DB;
class SeoController extends Controller
{
private array $defaults = [
'seo_site_name' => 'Animexe',
'seo_title_template' => '%s — Animexe | Türkçe Anime İzle',
'seo_home_title' => 'Animexe — Türkçe Anime İzle | Ücretsiz HD',
'seo_home_description' => 'Animexe\'de binlerce anime dizisi ve filmini Türkçe altyazılı veya dublajlı, ücretsiz ve yüksek kalitede izleyin.',
'seo_home_keywords' => 'anime izle, türkçe anime, anime dizi, anime film, ücretsiz anime izle, hd anime, türkçe altyazılı anime, türkçe dublajlı anime',
'seo_og_image' => '/logo.jpg',
'seo_twitter_site' => '',
'seo_facebook_app_id' => '',
'seo_canonical_domain' => '',
'seo_google_analytics' => '',
'seo_gtm_id' => '',
'seo_gsc_verification' => '',
'seo_bing_verification' => '',
'seo_yandex_verification' => '',
'seo_enable_schema' => '1',
'seo_enable_breadcrumb' => '1',
'seo_noindex_search' => '1',
'seo_noindex_profile' => '1',
'seo_noindex_watch' => '0',
'seo_org_logo' => '/logo.jpg',
'seo_org_twitter' => '',
'seo_org_facebook' => '',
'seo_org_instagram' => '',
'seo_robots_custom' => '',
'seo_pagespeed_api_key' => '',
'seo_looker_embed_url' => '',
'seo_enable_faq_schema' => '1',
'seo_enable_video_schema' => '1',
];
public function index()
{
$settings = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key')->toArray();
foreach ($this->defaults as $key => $val) {
if (!array_key_exists($key, $settings)) {
$settings[$key] = $val;
}
}
$robotsPath = public_path('robots.txt');
$robotsTxt = File::exists($robotsPath) ? File::get($robotsPath) : '';
$audit = $this->runAudit();
$sitemapStats = [
'anime_count' => Anime::where('is_published', true)->count(),
'genre_count' => Genre::where('is_active', true)->count(),
'static_count' => 2,
'last_updated' => Setting::get('seo_sitemap_generated_at', null),
];
// Keyword tracker
$keywords = SeoKeyword::orderBy('keyword')->get();
// Redirect manager
$redirects = SeoRedirect::orderByDesc('hits')->paginate(25, ['*'], 'rpage');
// Bulk SEO — animelerin SEO verileri (seo_title veya seo_meta_desc eksik olanlar önce)
$animes = Anime::where('is_published', true)
->orderByRaw('(seo_title IS NULL OR seo_title = "") DESC')
->orderBy('title')
->select('id', 'title', 'slug', 'description', 'seo_title', 'seo_meta_desc', 'seo_keywords')
->paginate(30, ['*'], 'apage');
$animeSeoCoverage = [
'total' => Anime::where('is_published', true)->count(),
'has_seo_title'=> Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(),
'has_seo_desc' => Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count(),
];
// Image alt audit — animes with cover
$missingAlt = Anime::where('is_published', true)
->whereNotNull('cover_image')->where('cover_image', '!=', '')
->whereNull('title')->count(); // titles serve as alt text, so just check no-title
// Duplicate descriptions
$dupDesc = DB::table('animes')
->select('description', DB::raw('COUNT(*) as cnt'))
->where('is_published', true)
->whereNotNull('description')
->where('description', '!=', '')
->groupBy('description')
->having('cnt', '>', 1)
->count();
// ── Analytics stats for Google tab ───────────────────────────────────
$analyticsStats = [
'total_anime' => Anime::where('is_published', true)->count(),
'total_episodes' => class_exists(Episode::class) ? Episode::count() : 0,
'total_users' => User::count(),
'total_genres' => Genre::where('is_active', true)->count(),
'total_comments' => class_exists(Comment::class) ? Comment::count() : 0,
'total_watchlists' => class_exists(Watchlist::class) ? Watchlist::count() : 0,
'total_ratings' => class_exists(AnimeRating::class) ? AnimeRating::count() : 0,
'total_blog_posts' => class_exists(BlogPost::class) ? BlogPost::count() : 0,
'total_redirects' => SeoRedirect::where('is_active', true)->count(),
'total_redirect_hits'=> SeoRedirect::sum('hits'),
'seo_title_pct' => $sitemapStats['anime_count'] > 0
? round($animeSeoCoverage['has_seo_title'] / $sitemapStats['anime_count'] * 100)
: 0,
'seo_desc_pct' => $sitemapStats['anime_count'] > 0
? round($animeSeoCoverage['has_seo_desc'] / $sitemapStats['anime_count'] * 100)
: 0,
'new_anime_this_month' => Anime::where('is_published', true)
->where('created_at', '>=', now()->startOfMonth())->count(),
'new_users_this_month' => User::where('created_at', '>=', now()->startOfMonth())->count(),
];
// Integration status
$integrations = [
'ga4' => !empty($settings['seo_google_analytics'] ?? ''),
'gtm' => !empty($settings['seo_gtm_id'] ?? ''),
'gsc' => !empty($settings['seo_gsc_verification'] ?? ''),
'bing' => !empty($settings['seo_bing_verification'] ?? ''),
'yandex' => !empty($settings['seo_yandex_verification'] ?? ''),
];
return view('admin.seo.index', compact(
'settings', 'robotsTxt', 'audit', 'sitemapStats',
'keywords', 'redirects', 'animes', 'animeSeoCoverage', 'dupDesc',
'analyticsStats', 'integrations'
));
}
public function update(Request $request)
{
// Tüm alanlar opsiyonel — her tab kendi alanlarını gönderir (partial update)
$rules = [
'seo_site_name' => 'nullable|string|max:100',
'seo_title_template' => 'nullable|string|max:200',
'seo_home_title' => 'nullable|string|max:200',
'seo_home_description' => 'nullable|string|max:500',
'seo_home_keywords' => 'nullable|string|max:500',
'seo_og_image' => 'nullable|string|max:500',
'seo_twitter_site' => 'nullable|string|max:100',
'seo_facebook_app_id' => 'nullable|string|max:100',
'seo_canonical_domain' => 'nullable|url|max:200',
'seo_google_analytics' => 'nullable|string|max:50',
'seo_gtm_id' => 'nullable|string|max:50',
'seo_gsc_verification' => 'nullable|string|max:200',
'seo_bing_verification' => 'nullable|string|max:200',
'seo_yandex_verification' => 'nullable|string|max:200',
'seo_org_logo' => 'nullable|string|max:500',
'seo_org_twitter' => 'nullable|string|max:200',
'seo_org_facebook' => 'nullable|string|max:200',
'seo_org_instagram' => 'nullable|string|max:200',
'seo_pagespeed_api_key' => 'nullable|string|max:100',
'seo_looker_embed_url' => 'nullable|string|max:500',
];
$validated = $request->validate($rules);
// Checkbox alanları: sadece request'te varsa güncelle
$checkboxes = [
'seo_enable_schema', 'seo_enable_breadcrumb', 'seo_noindex_search',
'seo_noindex_profile', 'seo_noindex_watch', 'seo_enable_faq_schema', 'seo_enable_video_schema',
];
foreach ($checkboxes as $key) {
if ($request->has($key) || $request->has('_seo_section')) {
$value = $request->input($key);
$validated[$key] = ($value === '1' || $value === 'on') ? '1' : '0';
}
}
// Sadece gönderilen (non-null) alanları kaydet
foreach ($validated as $key => $value) {
if ($value !== null) {
Setting::set($key, $value, 'seo');
}
}
cache()->forget('seo_settings');
if ($request->wantsJson()) {
return response()->json(['ok' => true, 'message' => 'SEO ayarları kaydedildi.']);
}
return back()->with('success', 'SEO ayarları başarıyla kaydedildi.');
}
public function updateRobots(Request $request)
{
$request->validate(['robots_txt' => 'required|string|max:10000']);
File::put(public_path('robots.txt'), $request->input('robots_txt'));
return back()->with('success', 'robots.txt güncellendi.');
}
public function pingSearchEngines(Request $request)
{
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
$sitemapUrl = urlencode($domain . '/sitemap.xml');
$results = [];
foreach (['google' => "https://www.google.com/ping?sitemap={$sitemapUrl}", 'bing' => "https://www.bing.com/ping?sitemap={$sitemapUrl}"] as $engine => $url) {
try {
$r = Http::timeout(5)->get($url);
$results[$engine] = $r->successful() ? 'success' : 'error';
} catch (\Throwable) {
$results[$engine] = 'error';
}
}
Setting::set('seo_sitemap_pinged_at', now()->toDateTimeString(), 'seo');
return back()->with('ping_results', $results)->with('success', 'Arama motorlarına bildirim gönderildi.');
}
public function auditJson()
{
return response()->json($this->runAudit());
}
// ── Keyword Tracker ───────────────────────────────────────────────────────
public function storeKeyword(Request $request)
{
$data = $request->validate([
'keyword' => 'required|string|max:255',
'target_url' => 'nullable|string|max:500',
'search_volume' => 'nullable|integer|min:0',
'difficulty' => 'nullable|integer|min:0|max:100',
'notes' => 'nullable|string|max:1000',
]);
SeoKeyword::create($data);
return back()->with('success', 'Anahtar kelime eklendi.');
}
public function destroyKeyword(SeoKeyword $keyword)
{
$keyword->delete();
return back()->with('success', 'Anahtar kelime silindi.');
}
// ── Redirect Manager ─────────────────────────────────────────────────────
public function storeRedirect(Request $request)
{
$data = $request->validate([
'from_path' => 'required|string|max:500',
'to_path' => 'required|string|max:500',
'type' => 'required|in:301,302',
]);
$data['from_path'] = '/' . ltrim($data['from_path'], '/');
SeoRedirect::updateOrCreate(['from_path' => $data['from_path']], $data);
cache()->forget('seo_redirect_' . md5($data['from_path']));
return back()->with('success', 'Yönlendirme eklendi/güncellendi.');
}
public function destroyRedirect(SeoRedirect $redirect)
{
cache()->forget('seo_redirect_' . md5($redirect->from_path));
$redirect->delete();
return back()->with('success', 'Yönlendirme silindi.');
}
public function toggleRedirect(SeoRedirect $redirect)
{
$redirect->update(['is_active' => !$redirect->is_active]);
cache()->forget('seo_redirect_' . md5($redirect->from_path));
return response()->json(['is_active' => $redirect->is_active]);
}
// ── Bulk Anime SEO ────────────────────────────────────────────────────────
public function bulkSaveAnime(Request $request)
{
$data = $request->validate([
'animes' => 'required|array',
'animes.*.id' => 'required|integer|exists:animes,id',
'animes.*.seo_title' => 'nullable|string|max:100',
'animes.*.seo_meta_desc' => 'nullable|string|max:320',
'animes.*.seo_keywords' => 'nullable|string|max:500',
]);
foreach ($data['animes'] as $row) {
Anime::where('id', $row['id'])->update([
'seo_title' => $row['seo_title'] ?? null,
'seo_meta_desc' => $row['seo_meta_desc'] ?? null,
'seo_keywords' => $row['seo_keywords'] ?? null,
]);
}
return back()->with('success', count($data['animes']) . ' anime için SEO verileri kaydedildi.');
}
public function generateAnimeSeo(Anime $anime)
{
$title = trim($anime->title);
$seoTitle = $title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe';
$seoTitle = mb_substr($seoTitle, 0, 70);
$desc = $anime->description
? mb_substr(strip_tags($anime->description), 0, 130)
: '';
$seoDesc = $desc
? $desc . ' Animexe\'de Türkçe altyazılı izle.'
: $title . '\'yi Türkçe altyazılı veya dublajlı, ücretsiz ve HD kalitede Animexe\'de izleyin.';
$seoDesc = mb_substr($seoDesc, 0, 160);
$keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe, ' . strtolower($title) . ' türkçe altyazılı';
$anime->update([
'seo_title' => $seoTitle,
'seo_meta_desc' => $seoDesc,
'seo_keywords' => $keywords,
]);
return response()->json([
'seo_title' => $seoTitle,
'seo_meta_desc' => $seoDesc,
'seo_keywords' => $keywords,
]);
}
public function bulkGenerateAllSeo(Request $request)
{
$animes = Anime::where('is_published', true)
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
->get(['id', 'title', 'type', 'description']);
$count = 0;
foreach ($animes as $anime) {
$title = trim($anime->title);
$seoTitle = mb_substr($title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe', 0, 70);
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : '';
$seoDesc = mb_substr($desc ? $desc . ' Animexe\'de Türkçe izle.' : $title . '\'yi Animexe\'de ücretsiz izleyin.', 0, 160);
$keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı';
$anime->update([
'seo_title' => $seoTitle,
'seo_meta_desc' => $seoDesc,
'seo_keywords' => $keywords,
]);
$count++;
}
return response()->json(['generated' => $count]);
}
/**
* DeepSeek ile toplu AI SEO üretimi SEO başlığı olmayan animeleri işler.
* Her batch 10 anime, aralarında 1s bekleme (rate limit önlemi).
* İstek başına max 10 anime işler; frontend'den tekrar tekrar çağrılarak tamamlanır.
*/
public function aiBulkGenerateSeo(Request $request)
{
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
}
$batchSize = min((int)$request->input('batch', 10), 20);
$animes = Anime::where('is_published', true)
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
->with('genres:id,name')
->limit($batchSize)
->get();
$remaining = Anime::where('is_published', true)
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
->count();
$done = 0;
$errors = 0;
foreach ($animes as $anime) {
$result = $ai->generateAnimeSeoMeta($anime);
if ($result) {
$anime->update([
'seo_title' => $result['seo_title'] ?? null,
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
'seo_keywords' => $result['seo_keywords'] ?? null,
]);
$done++;
} else {
$errors++;
}
sleep(1); // DeepSeek rate limit
}
return response()->json([
'done' => $done,
'errors' => $errors,
'remaining' => max(0, $remaining - $done),
]);
}
// ── PageSpeed ─────────────────────────────────────────────────────────────
public function pagespeedCheck(Request $request)
{
$request->validate(['url' => 'required|url', 'strategy' => 'in:mobile,desktop']);
$apiKey = Setting::get('seo_pagespeed_api_key', '');
$url = $request->url;
$strategy = $request->input('strategy', 'mobile');
if (empty($apiKey)) {
return response()->json(['error' => 'PageSpeed API anahtarı girilmemiş. SEO ayarlarından ekleyin.'], 422);
}
try {
$endpoint = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=" . urlencode($url) . "&strategy={$strategy}&key={$apiKey}";
$resp = Http::timeout(20)->get($endpoint);
if (!$resp->successful()) {
return response()->json(['error' => 'PageSpeed API hatası: ' . $resp->status()], 422);
}
$data = $resp->json();
$categories = $data['lighthouseResult']['categories'] ?? [];
$audits = $data['lighthouseResult']['audits'] ?? [];
$scores = [
'performance' => round(($categories['performance']['score'] ?? 0) * 100),
'accessibility' => round(($categories['accessibility']['score'] ?? 0) * 100),
'seo' => round(($categories['seo']['score'] ?? 0) * 100),
'best_practices'=> round(($categories['best-practices']['score'] ?? 0) * 100),
];
$opportunities = [];
foreach ($audits as $id => $audit) {
if (($audit['score'] ?? 1) < 0.9 && isset($audit['details']['type']) && $audit['details']['type'] === 'opportunity') {
$opportunities[] = [
'title' => $audit['title'],
'description' => $audit['description'] ?? '',
'savings' => $audit['details']['overallSavingsMs'] ?? null,
];
}
}
$fcp = $audits['first-contentful-paint']['displayValue'] ?? null;
$lcp = $audits['largest-contentful-paint']['displayValue'] ?? null;
$cls = $audits['cumulative-layout-shift']['displayValue'] ?? null;
$tbt = $audits['total-blocking-time']['displayValue'] ?? null;
return response()->json([
'scores' => $scores,
'vitals' => compact('fcp', 'lcp', 'cls', 'tbt'),
'opportunities' => array_slice($opportunities, 0, 8),
]);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
// ── Internal Links Audit ──────────────────────────────────────────────────
public function internalLinksAudit()
{
// Find animes with no other anime referencing them in descriptions (orphaned)
$allAnimes = Anime::where('is_published', true)->get(['id', 'title', 'slug']);
$result = [];
foreach ($allAnimes as $anime) {
$mentionedIn = Anime::where('is_published', true)
->where('id', '!=', $anime->id)
->where('description', 'like', '%' . $anime->title . '%')
->count();
$result[] = [
'id' => $anime->id,
'title' => $anime->title,
'slug' => $anime->slug,
'mentioned_in'=> $mentionedIn,
];
}
usort($result, fn($a, $b) => $a['mentioned_in'] <=> $b['mentioned_in']);
return response()->json(array_slice($result, 0, 50));
}
// ── Duplicate Content ─────────────────────────────────────────────────────
public function duplicateContent()
{
$dups = DB::table('animes')
->select('description', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(title ORDER BY title SEPARATOR ", ") as titles'))
->where('is_published', true)
->whereNotNull('description')
->where('description', '!=', '')
->groupBy('description')
->having('cnt', '>', 1)
->get();
return response()->json($dups);
}
// ── AI SEO Methods ────────────────────────────────────────────────────────
public function aiChat(Request $request)
{
$request->validate(['messages' => 'required|array', 'messages.*.role' => 'required|in:user,assistant', 'messages.*.content' => 'required|string|max:4000']);
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar sayfasından ekleyin.'], 422);
}
$total = Anime::where('is_published', true)->count();
$covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
$audit = $this->runAudit();
$sitemap = $total + Genre::where('is_active', true)->count() + 2;
$context = [
'anime_count' => $total,
'seo_covered' => $covered,
'seo_score' => $audit['score'],
'sitemap_urls' => $sitemap,
];
$reply = $ai->seoChat($request->messages, $context);
if (!$reply) {
return response()->json(['error' => 'DeepSeek yanıt vermedi. API anahtarını kontrol edin.'], 500);
}
return response()->json(['reply' => $reply]);
}
public function aiGenerateAnimeSeo(Anime $anime)
{
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
}
$anime->loadMissing('genres');
$result = $ai->generateAnimeSeoMeta($anime);
if (!$result) {
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
}
$anime->update([
'seo_title' => $result['seo_title'] ?? null,
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
'seo_keywords' => $result['seo_keywords'] ?? null,
]);
return response()->json($result);
}
public function aiKeywordSuggest(Request $request)
{
$request->validate(['topic' => 'required|string|max:200']);
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
}
$result = $ai->suggestKeywords($request->topic);
if (!$result) {
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
}
return response()->json($result);
}
public function aiPageAnalysis(Request $request)
{
$request->validate(['url' => 'required|url', 'title' => 'nullable|string', 'description' => 'nullable|string', 'content' => 'nullable|string']);
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
}
$result = $ai->analyzePageSeo(
$request->url,
$request->input('title', ''),
$request->input('description', ''),
$request->input('content', '')
);
if (!$result) {
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
}
return response()->json($result);
}
public function aiFaqSchema(Request $request)
{
$request->validate(['anime_id' => 'required|integer|exists:animes,id']);
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
}
$anime = Anime::with('genres')->findOrFail($request->anime_id);
$result = $ai->generateFaqSchema($anime);
if (!$result) {
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
}
return response()->json($result);
}
public function aiContentStrategy(Request $request)
{
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
}
$total = Anime::where('is_published', true)->count();
$covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
$audit = $this->runAudit();
$kwds = SeoKeyword::orderBy('search_volume', 'desc')->take(10)->pluck('keyword')->toArray();
$strategy = $ai->generateContentStrategy([
'seo_score' => $audit['score'],
'anime_count' => $total,
'seo_covered' => $covered,
], $kwds);
if (!$strategy) {
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
}
return response()->json(['strategy' => $strategy]);
}
public function aiRobotsTxt(Request $request)
{
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
}
$domain = Setting::get('seo_canonical_domain', 'animexe.com');
$result = $ai->generateRobotsTxt($domain);
if (!$result) {
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
}
return response()->json(['robots_txt' => $result]);
}
// ── Toplu Doldurma (Batch) ────────────────────────────────────────────────
/**
* Yayınlanan animelerin coverage istatistiklerini döndür.
* GET /admin/seo/bulk-fill-stats
*/
public function bulkFillStats()
{
$total = Anime::where('is_published', true)->count();
$hasDesc = Anime::where('is_published', true)->whereNotNull('description')->where('description', '!=', '')->count();
$hasSeoTitle = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
$hasSeoDesc = Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count();
$hasYear = Anime::where('is_published', true)->whereNotNull('release_year')->count();
$hasGenres = Anime::where('is_published', true)->has('genres')->count();
// Kaç adet işlenecek (her mode için)
$needsSeo = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count();
$needsMeta = Anime::where('is_published', true)->where(fn($q) =>
$q->whereNull('description')->orWhere('description', '')
->orWhereNull('release_year')
)->count();
$needsAll = Anime::where('is_published', true)->where(fn($q) =>
$q->whereNull('seo_title')->orWhere('seo_title', '')
->orWhereNull('description')->orWhere('description', '')
)->count();
return response()->json([
'total' => $total,
'has_desc' => $hasDesc,
'has_seo_title'=> $hasSeoTitle,
'has_seo_desc' => $hasSeoDesc,
'has_year' => $hasYear,
'has_genres' => $hasGenres,
'needs_seo' => $needsSeo,
'needs_meta' => $needsMeta,
'needs_all' => $needsAll,
'pct_seo' => $total > 0 ? round($hasSeoTitle / $total * 100) : 0,
'pct_desc' => $total > 0 ? round($hasDesc / $total * 100) : 0,
]);
}
/**
* Toplu doldurma batch tabanlı, timeout olmaz.
*
* POST /admin/seo/bulk-fill-batch
* body: {
* mode: 'template_seo' | 'ai_seo' | 'ai_meta' | 'ai_all',
* last_id: 0, // son işlenen anime id'si (pagination için)
* batch_size: 5, // kaç anime işlensin
* force: false, // dolu alanları da üzerine yaz
* }
* returns: { done, errors, last_id, remaining, total }
*/
public function bulkFillBatch(Request $request)
{
$mode = $request->input('mode', 'template_seo');
$lastId = (int) $request->input('last_id', 0);
$batchSize = min((int) $request->input('batch_size', 10), 50);
$force = $request->boolean('force', false);
$isAi = str_starts_with($mode, 'ai_');
if ($isAi) {
$ai = new DeepSeekService();
if (!$ai->isConfigured()) {
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek ekleyin.'], 422);
}
}
// Hangi animelere ihtiyaç var?
$query = Anime::where('is_published', true)->where('id', '>', $lastId);
if (!$force) {
if ($mode === 'template_seo' || $mode === 'ai_seo') {
$query->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''));
} elseif ($mode === 'ai_meta') {
$query->where(fn($q) =>
$q->whereNull('description')->orWhere('description', '')
->orWhereNull('release_year')
);
} elseif ($mode === 'ai_all') {
$query->where(fn($q) =>
$q->whereNull('seo_title')->orWhere('seo_title', '')
->orWhereNull('description')->orWhere('description', '')
);
}
}
$total = $query->clone()->count();
$animes = $query->with('genres:id,name')->orderBy('id')->limit($batchSize)->get();
$done = 0;
$errors = 0;
$newLastId = $lastId;
foreach ($animes as $anime) {
$newLastId = $anime->id;
try {
if ($mode === 'template_seo') {
// Hızlı template — AI çağrısı yok
$title = trim($anime->title);
$seoTitle = mb_substr(
$title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime') . ' İzle | Animexe',
0, 70
);
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : '';
$seoDesc = mb_substr(
$desc
? $desc . ' Animexe\'de Türkçe altyazılı izle.'
: $title . '\'yi Türkçe altyazılı veya dublajlı ücretsiz HD olarak Animexe\'de izleyin.',
0, 160
);
$kwds = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı, ' . strtolower($title) . ' türkçe dublaj';
$updates = ['seo_title' => $seoTitle, 'seo_meta_desc' => $seoDesc, 'seo_keywords' => $kwds];
if ($force) {
$anime->update($updates);
} else {
$anime->update(array_filter($updates, fn($v) => !empty($v)));
}
$done++;
} elseif ($mode === 'ai_seo') {
$result = $ai->generateAnimeSeoMeta($anime);
if ($result) {
$updates = array_filter([
'seo_title' => $result['seo_title'] ?? null,
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
'seo_keywords' => $result['seo_keywords'] ?? null,
]);
if ($force || empty($anime->seo_title)) {
$anime->update($updates);
}
$done++;
} else {
$errors++;
}
} elseif ($mode === 'ai_meta') {
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
if ($meta) {
$this->applyAnimeMeta($anime, $meta, $force);
$done++;
} else {
$errors++;
}
} elseif ($mode === 'ai_all') {
// Meta + SEO birlikte — 2 AI çağrısı
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
if ($meta) {
$this->applyAnimeMeta($anime->fresh(), $meta, $force);
}
$anime->loadMissing('genres');
$seoResult = $ai->generateAnimeSeoMeta($anime->fresh(['genres']));
if ($seoResult) {
$anime->update(array_filter([
'seo_title' => $seoResult['seo_title'] ?? null,
'seo_meta_desc' => $seoResult['seo_meta_desc'] ?? null,
'seo_keywords' => $seoResult['seo_keywords'] ?? null,
]));
$done++;
} else {
$errors++;
}
}
} catch (\Throwable $e) {
$errors++;
\Illuminate\Support\Facades\Log::warning("[bulkFillBatch] Hata [{$anime->id}] {$anime->title}: " . $e->getMessage());
}
// AI çağrıları arası kısa bekleme (rate limit önlemi)
if ($isAi && $done + $errors < count($animes)) {
usleep(800_000); // 0.8s
}
}
// Kalan animeler (bu batch'ten sonra)
$remaining = max(0, $total - $done - $errors);
return response()->json([
'done' => $done,
'errors' => $errors,
'last_id' => $newLastId,
'remaining' => $remaining,
'total' => $total,
'finished' => $animes->count() < $batchSize || $remaining === 0,
]);
}
private function applyAnimeMeta(Anime $anime, array $meta, bool $force): void
{
$updates = [];
$fill = function (string $field, $value) use ($anime, $force, &$updates) {
if ($value === null || $value === '') return;
if ($force || empty($anime->$field)) $updates[$field] = $value;
};
$fill('description', $meta['description'] ?? null);
$fill('release_year', $meta['release_year'] ?? null);
$fill('studio', $meta['studio'] ?? null);
$fill('type', $meta['type'] ?? null);
$fill('status', $meta['status'] ?? null);
$fill('title_en', $meta['title_en'] ?? null);
$fill('title_jp', $meta['title_jp'] ?? null);
if (!empty($meta['rating']) && ($force || !$anime->rating)) {
$updates['rating'] = min(10, max(0, (float) $meta['rating']));
}
if (!empty($updates)) $anime->update($updates);
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
$ids = [];
foreach ($meta['genres'] as $name) {
$g = \App\Models\Genre::firstOrCreate(
['name' => $name],
['slug' => \Illuminate\Support\Str::slug($name)]
);
$ids[] = $g->id;
}
if ($ids) {
$force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids);
}
}
}
// ── Private ───────────────────────────────────────────────────────────────
private function runAudit(): array
{
$total = Anime::where('is_published', true)->count();
$noDesc = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count();
$noCover = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('cover_image')->orWhere('cover_image', ''))->count();
$shortDesc = Anime::where('is_published', true)->whereNotNull('description')->whereRaw('CHAR_LENGTH(description) < 100')->count();
$noSlug = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('slug')->orWhere('slug', ''))->count();
$noSeoTitle = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count();
$seo = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key');
$robots = File::exists(public_path('robots.txt')) ? File::get(public_path('robots.txt')) : '';
$hasSitemap = file_exists(public_path('sitemap.xml'));
$dupDesc = DB::table('animes')->select('description')->where('is_published', true)
->whereNotNull('description')->where('description', '!=', '')
->groupBy('description')->havingRaw('COUNT(*) > 1')->count();
$checks = [];
// Site config
$checks[] = $this->check('site_name', !empty($seo['seo_site_name']), 'Site Adı Ayarlandı', 'Site adı eksik', 10);
$checks[] = $this->check('home_title', !empty($seo['seo_home_title']), 'Anasayfa Başlığı Mevcut', 'Anasayfa başlığı eksik', 10);
$checks[] = $this->check('home_desc', !empty($seo['seo_home_description']), 'Anasayfa Meta Açıklaması Mevcut', 'Anasayfa meta açıklaması eksik', 10);
$checks[] = $this->check('title_length', strlen($seo['seo_home_title'] ?? '') <= 70 && strlen($seo['seo_home_title'] ?? '') >= 30, 'Başlık Uzunluğu İdeal (3070)', 'Başlık çok kısa veya çok uzun', 5);
$checks[] = $this->check('desc_length', strlen($seo['seo_home_description'] ?? '') <= 160 && strlen($seo['seo_home_description'] ?? '') >= 100, 'Meta Açıklama Uzunluğu İdeal', 'Meta açıklama 100160 karakter arası olmalı', 5);
$checks[] = $this->check('og_image', !empty($seo['seo_og_image']), 'OG Görseli Tanımlandı', 'Varsayılan OG görseli eksik', 8);
$checks[] = $this->check('canonical', !empty($seo['seo_canonical_domain']), 'Canonical Domain Ayarlı', 'Canonical domain ayarlanmamış', 8);
$checks[] = $this->check('analytics', !empty($seo['seo_google_analytics']), 'Google Analytics Entegre', 'GA4 ID girilmemiş', 7);
$checks[] = $this->check('gsc', !empty($seo['seo_gsc_verification']), 'Search Console Doğrulandı', 'GSC doğrulama kodu eksik', 7);
$checks[] = $this->check('schema', ($seo['seo_enable_schema'] ?? '1') === '1', 'Schema.org İşaretleme Aktif', 'Schema.org işaretleme kapalı', 7);
$checks[] = $this->check('faq_schema', ($seo['seo_enable_faq_schema'] ?? '1') === '1', 'FAQ Schema Aktif', 'FAQ şema kapalı (rich snippets kayıp)', 5);
$checks[] = $this->check('video_schema', ($seo['seo_enable_video_schema'] ?? '1') === '1', 'Video Schema Aktif', 'Video şema kapalı', 5);
// Technical SEO
$checks[] = $this->check('sitemap', $hasSitemap, 'Sitemap Mevcut', 'sitemap.xml bulunamadı', 8);
$checks[] = $this->check('robots_exists', !empty($robots), 'robots.txt Mevcut', 'robots.txt yok veya boş', 6);
$checks[] = $this->check('robots_admin', str_contains($robots, 'Disallow: /admin'), 'robots.txt Admin Kapalı', 'robots.txt /admin dizini kapalı değil', 6);
$checks[] = $this->check('noindex_search', ($seo['seo_noindex_search'] ?? '1') === '1', 'Arama Sayfası Noindex', 'Arama sayfası indexleniyor', 5);
$checks[] = $this->check('twitter', !empty($seo['seo_twitter_site']), 'Twitter Card Yapılandırıldı', 'Twitter hesabı girilmemiş', 4);
$checks[] = $this->check('bing', !empty($seo['seo_bing_verification']), 'Bing Webmaster Doğrulandı', 'Bing doğrulama kodu eksik', 3);
// Content quality
$checks[] = $this->check('anime_desc', $noDesc === 0, 'Tüm Animelerin Açıklaması Var', "{$noDesc} animenin açıklaması eksik", 8);
$checks[] = $this->check('anime_cover', $noCover === 0, 'Tüm Animelerin Kapağı Var', "{$noCover} animenin görseli eksik", 7);
$checks[] = $this->check('desc_quality', $shortDesc < max(1, $total * 0.1), 'Açıklama Kalitesi İyi', "{$shortDesc} animenin açıklaması çok kısa", 4);
$checks[] = $this->check('slug_coverage', $noSlug === 0, 'Tüm Animeler URL Slug\'a Sahip', "{$noSlug} animenin slug\'u eksik", 6);
$checks[] = $this->check('seo_titles', $noSeoTitle < $total * 0.2, 'Anime SEO Başlıkları Yeterli', "{$noSeoTitle} animenin SEO başlığı eksik", 6);
$checks[] = $this->check('dup_desc', $dupDesc === 0, 'Tekrarlayan İçerik Yok', "{$dupDesc} grup tekrarlayan açıklama var", 5);
$score = $weight = 0;
foreach ($checks as $c) {
$weight += $c['weight'];
if ($c['pass']) $score += $c['weight'];
}
$scorePercent = $weight > 0 ? round(($score / $weight) * 100) : 0;
return [
'score' => $scorePercent,
'checks' => $checks,
'totals' => ['total' => $total, 'noDesc' => $noDesc, 'noCover' => $noCover, 'shortDesc' => $shortDesc, 'noSeoTitle' => $noSeoTitle, 'dupDesc' => $dupDesc],
'pass_count' => collect($checks)->where('pass', true)->count(),
'fail_count' => collect($checks)->where('pass', false)->count(),
];
}
private function check(string $id, bool $pass, string $passMsg, string $failMsg, int $weight): array
{
return compact('id', 'pass', 'passMsg', 'failMsg', 'weight');
}
}