'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 (30–70)', '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 100–160 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'); } }