Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user