Files
animexe/app/Http/Controllers/Admin/SeoController.php
T
2026-07-14 00:01:48 +03:00

972 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
}
}