Files
dockerfluxodecaixapro/modules/seo/classes/Controllers/SeoController.php
T
2026-09-01 10:46:29 -03:00

553 lines
24 KiB
PHP
Executable File

<?php
namespace Modules\Seo\Controllers;
use App\Controllers\BaseAdminController;
use Kernel\View;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Models\Module;
use Modules\Seo\Models\Redirect;
use Illuminate\Database\Capsule\Manager as DB;
use Phpml\Tokenization\WhitespaceTokenizer;
use Phpml\FeatureExtraction\TfIdfTransformer;
use Phpml\FeatureExtraction\TokenCountVectorizer;
class SeoController extends BaseAdminController
{
private $moduleName = 'seo';
/**
* Display SEO settings.
*
* @param Request $request
* @return Response
*/
public function index(Request $request): Response
{
$user = $this->getCurrentUser();
if (!$user || !$user->hasPermission('seo.view')) {
return new Response($this->view->render('components/alerts.twig', [
'type' => 'danger',
'message' => 'Access denied.'
]), 403);
}
$module = Module::where('modules_name', $this->moduleName)->first();
if (!$module) {
return new Response('Module not found', 404);
}
$settings = $module->modules_settings ?: [];
$redirects = Redirect::orderBy('created_at', 'desc')->get();
return $this->renderAdminView('@seo/admin/index.twig', [
'page_title' => 'SEO Settings',
'settings' => $settings,
'redirects' => $redirects
]);
}
/**
* Save SEO settings.
*
* @param Request $request
* @return Response
*/
public function save(Request $request): Response
{
try {
$user = $this->getCurrentUser();
if (!$user || !$user->hasPermission('seo.manage')) {
return new Response($this->view->render('components/alerts.twig', [
'type' => 'danger',
'message' => 'Access denied.'
]), 403);
}
$module = Module::where('modules_name', $this->moduleName)->first();
if (!$module) {
$this->addFlash('error', 'Módulo SEO não encontrado no banco de dados.');
return $this->redirect('/admin/seo');
}
$data = $request->request->all();
$settings = $module->modules_settings ?: [];
// Handle image upload
if ($request->files->has('og_image_file')) {
$file = $request->files->get('og_image_file');
if ($file && $file->isValid()) {
$uploadDir = dirname(__DIR__, 4) . '/storage/uploads/seo';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Delete old image
if (!empty($settings['og_image'])) {
$oldPath = $uploadDir . '/' . $settings['og_image'];
if (file_exists($oldPath) && is_file($oldPath)) {
@unlink($oldPath);
}
}
$filename = 'og_default_' . uniqid() . '.' . $file->guessExtension();
$file->move($uploadDir, $filename);
$settings['og_image'] = $filename;
}
}
$fields = [
'meta_title', 'meta_description', 'meta_keywords',
'og_title', 'og_description',
'twitter_card', 'robots_txt', 'google_analytics_id', 'google_site_verification',
'json_ld_type', 'json_ld_name', 'json_ld_description', 'json_ld_url',
'json_ld_contact_phone', 'json_ld_contact_email', 'json_ld_address',
'json_ld_city', 'json_ld_state', 'json_ld_zip', 'json_ld_country',
'json_ld_social_links',
'sitemap_freq_static', 'sitemap_prio_static',
'sitemap_freq_blog', 'sitemap_prio_blog',
'sitemap_freq_news', 'sitemap_prio_news',
'sitemap_freq_guide', 'sitemap_prio_guide',
'sitemap_freq_events', 'sitemap_prio_events',
'sitemap_freq_faq', 'sitemap_prio_faq',
'sitemap_freq_expositor', 'sitemap_prio_expositor',
'sitemap_freq_albums', 'sitemap_prio_albums',
'sitemap_freq_pages', 'sitemap_prio_pages',
'sitemap_freq_photoessays', 'sitemap_prio_photoessays'
];
foreach ($fields as $field) {
$settings[$field] = $data[$field] ?? null;
}
// Handle checkboxes explicitly
$checkboxes = [
'sitemap_include_static', 'sitemap_include_blog', 'sitemap_include_news',
'sitemap_include_guide', 'sitemap_include_events', 'sitemap_include_faq',
'sitemap_include_expositor', 'sitemap_include_albums',
'sitemap_include_pages', 'sitemap_include_photoessays'
];
foreach ($checkboxes as $cb) {
$settings[$cb] = isset($data[$cb]) ? '1' : '0';
}
$module->modules_settings = $settings;
$module->save();
// Also update physical robots.txt if requested
if (isset($data['update_robots_file']) && $data['update_robots_file'] == '1') {
$this->updateRobotsFile($settings['robots_txt'] ?? '');
}
$this->addFlash('success', __('seo.save_success'));
} catch (\Exception $e) {
\App\Helpers\Log::error('Error saving SEO settings: ' . $e->getMessage());
$this->addFlash('error', 'Erro ao salvar: ' . $e->getMessage());
}
return $this->redirect('/admin/seo');
}
/**
* Generate SEO suggestions using local PHP-ML.
*/
public function generateAi(Request $request): Response
{
try {
$user = $this->getCurrentUser();
if (!$user || !$user->hasPermission('seo.manage')) {
return new Response(json_encode(['success' => false, 'error' => 'Access denied']), 403, ['Content-Type' => 'application/json']);
}
$corpus = [];
// 1. Gather text from various modules to analyze
if (DB::schema()->hasTable('blog_posts')) {
$corpus[] = DB::table('blog_posts')->where('posts_status', 'published')->pluck('posts_title')->implode(' ');
$corpus[] = DB::table('blog_posts')->where('posts_status', 'published')->pluck('posts_content')->implode(' ');
}
if (DB::schema()->hasTable('news')) {
$corpus[] = DB::table('news')->where('news_status', 'published')->pluck('news_title')->implode(' ');
}
if (DB::schema()->hasTable('commercialguide_businesses')) {
$corpus[] = DB::table('commercialguide_businesses')->where('businesses_status', 'approved')->pluck('businesses_name')->implode(' ');
}
if (DB::schema()->hasTable('pages')) {
$corpus[] = DB::table('pages')->where('pages_is_published', 1)->pluck('pages_title')->implode(' ');
$corpus[] = DB::table('pages')->where('pages_is_published', 1)->pluck('pages_content')->implode(' ');
}
$allText = strtolower(strip_tags(implode(' ', $corpus)));
$allText = preg_replace('/[[:punct:]]/', '', $allText); // Remove punctuation
// Stop words in PT-BR
$stopWords = ['de', 'a', 'o', 'que', 'e', 'do', 'da', 'em', 'um', 'para', 'com', 'no', 'na', 'uma', 'os', 'as', 'dos', 'das', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'seus', 'quem', 'nas', 'me', 'esse', 'eles', 'você', 'essa', 'num', 'nem', 'suas', 'meu', 'às', 'minha', 'numa', 'pelos', 'elas', 'qual', 'nós', 'lhe', 'deles', 'essas', 'esses', 'pelas', 'este', 'dele', 'tu', 'te', 'vocês', 'vos', 'lhes', 'meus', 'minhas', 'teu', 'tua', 'teus', 'tuas', 'nosso', 'nossa', 'nossos', 'nossas', 'está', 'estão', 'estivemos', 'estiveram', 'estava', 'estávamos', 'estavam', 'estivera', 'estivéramos', 'esteja', 'estejamos', 'estejam', 'estivesse', 'estivéssemos', 'estivessem', 'estiver', 'estivermos', 'estiverem', 'tenho', 'tem', 'temos', 'têm', 'tinha', 'tínhamos', 'tinham', 'tive', 'teve', 'tivemos', 'tiveram', 'tivera', 'tivéramos', 'tenha', 'tenhamos', 'tenham', 'tivesse', 'tivéssemos', 'tivessem', 'tiver', 'tivermos', 'tiverem', 'houve', 'houvemos', 'houveram', 'houvera', 'houvéramos', 'haja', 'hajamos', 'hajam', 'houvesse', 'houvéssemos', 'houvessem', 'houver', 'houvermos', 'houverem', 'como', 'mais', 'foi', 'foram', 'sou', 'somos', 'são', 'era', 'éramos', 'eram', 'seja', 'sejamos', 'sejam', 'fosse', 'fôssemos', 'fossem', 'for', 'formos', 'forem', 'seria', 'seríamos', 'seriam', 'será', 'serão', 'tenho', 'tem', 'temos', 'têm', 'tinha', 'tínhamos', 'tinham', 'tive', 'teve', 'tivemos', 'tiveram', 'tivera', 'tivéramos', 'tenha', 'tenhamos', 'tenham', 'tivesse', 'tivéssemos', 'tivessem', 'tiver', 'tivermos', 'tiverem', 'houve', 'houvemos', 'houveram', 'houvera', 'houvéramos', 'haja', 'hajamos', 'hajam', 'houvesse', 'houvéssemos', 'houvessem', 'houver', 'houvermos', 'houverem', 'mais', 'menos', 'muito', 'tudo', 'nada', 'sempre', 'nunca', 'talvez', 'ainda', 'assim', 'apenas', 'quase', 'então', 'entanto', 'pode', 'podem', 'poderia', 'deveria', 'querer', 'fazer', 'ir', 'ver', 'dar', 'vir', 'saber', 'parecer', 'falar', 'dizer'];
// Tokenize and count
$tokenizer = new WhitespaceTokenizer();
$tokens = $tokenizer->tokenize($allText);
$tokens = array_filter($tokens, function($t) use ($stopWords) {
return strlen($t) > 3 && !in_array($t, $stopWords);
});
$counts = array_count_values($tokens);
arsort($counts);
$topKeywords = array_slice(array_keys($counts), 0, 10);
$topThree = array_slice($topKeywords, 0, 3);
$siteName = $_SESSION['bus_name'] ?? 'SysCMS';
if (empty($topThree)) {
$suggestedTitle = $siteName . " - Portal de Conteúdo";
$suggestedDesc = "Bem-vindo ao portal " . $siteName . ". Aqui você encontra as melhores informações, notícias e serviços da nossa região.";
$topKeywords = ['portal', 'noticias', 'serviços', 'empresas', 'eventos'];
} else {
$suggestedTitle = ucwords(implode(' | ', $topThree)) . ' - ' . $siteName;
$suggestedDesc = "Confira o melhor em " . implode(', ', array_slice($topKeywords, 0, 5)) . " no portal " . $siteName . ". Conteúdo atualizado, guia de empresas, eventos e muito mais!";
}
$sitemapUrl = url('/sitemap.xml');
$suggestedRobots = "# Gerado automaticamente via IA do SysCMS\n";
$suggestedRobots .= "User-agent: *\n";
$suggestedRobots .= "Allow: /\n";
$suggestedRobots .= "Disallow: /admin/\n";
$suggestedRobots .= "Disallow: /login\n";
$suggestedRobots .= "Disallow: /register\n";
$suggestedRobots .= "Disallow: /api/\n";
$suggestedRobots .= "Disallow: /vendor/\n";
$suggestedRobots .= "Disallow: /storage/logs/\n";
$suggestedRobots .= "\nSitemap: {$sitemapUrl}";
\App\Helpers\Log::info('SEO AI suggestions generated successfully');
return new Response(json_encode([
'success' => true,
'title' => $suggestedTitle,
'description' => $suggestedDesc,
'keywords' => implode(', ', $topKeywords),
'robots_txt' => $suggestedRobots
]), 200, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
\App\Helpers\Log::error('AI Generation error: ' . $e->getMessage());
return new Response(json_encode(['success' => false, 'error' => $e->getMessage()]), 500, ['Content-Type' => 'application/json']);
}
}
/**
* Add a redirect.
*/
public function addRedirect(Request $request): Response
{
$user = $this->getCurrentUser();
if (!$user || !$user->hasPermission('seo.manage')) {
return new Response('Access denied', 403);
}
$from = $request->request->get('redirects_from');
$to = $request->request->get('redirects_to');
$code = (int) $request->request->get('redirects_code', 301);
if ($from && $to) {
Redirect::create([
'redirects_from' => $from,
'redirects_to' => $to,
'redirects_code' => $code,
'redirects_is_active' => true
]);
$this->addFlash('success', __('seo.redirect_added'));
}
return $this->redirect('admin/seo#redirects');
}
/**
* Delete a redirect.
*/
public function deleteRedirect(Request $request, $id): Response
{
$user = $this->getCurrentUser();
if (!$user || !$user->hasPermission('seo.manage')) {
return new Response('Access denied', 403);
}
$redirect = Redirect::find($id);
if ($redirect) {
$redirect->delete();
$this->addFlash('success', __('seo.redirect_deleted'));
}
return $this->redirect('admin/seo#redirects');
}
/**
* Update physical robots.txt file in public directory.
*/
private function updateRobotsFile($content)
{
$path = dirname(__DIR__, 4) . '/public/robots.txt';
@file_put_contents($path, $content);
}
/**
* User Manual for SEO.
*/
public function manual(Request $request): Response
{
return $this->renderAdminView('seo/views/admin/manual.twig', [
'title' => 'Manual do Usuário - SEO'
]);
}
/**
* Generate Sitemap XML.
* This is a public route.
*/
public function sitemap(Request $request): Response
{
$module = Module::where('modules_name', $this->moduleName)->first();
$settings = $module->modules_settings ?: [];
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
// Static Pages
if (($settings['sitemap_include_static'] ?? '1') == '1') {
$freq = $settings['sitemap_freq_static'] ?? 'weekly';
$prio = $settings['sitemap_prio_static'] ?? '1.0';
$urls = [
url('/'),
url('/login'),
url('/register'),
url('/about'),
url('/contact'),
url('/services')
];
foreach ($urls as $u) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars($u) . '</loc>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// Blog Posts (if module exists and enabled)
if (($settings['sitemap_include_blog'] ?? '1') == '1' && DB::schema()->hasTable('blog_posts')) {
$freq = $settings['sitemap_freq_blog'] ?? 'daily';
$prio = $settings['sitemap_prio_blog'] ?? '0.8';
$posts = DB::table('blog_posts')
->where('posts_status', 'published')
->orderBy('created_at', 'desc')
->get();
foreach ($posts as $post) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('blog/' . $post->posts_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($post->posts_published_at ?: ($post->updated_at ?: $post->created_at))) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// News (if module exists and enabled)
if (($settings['sitemap_include_news'] ?? '1') == '1' && DB::schema()->hasTable('news')) {
$freq = $settings['sitemap_freq_news'] ?? 'daily';
$prio = $settings['sitemap_prio_news'] ?? '0.7';
$news = DB::table('news')
->where('news_status', 'published')
->orderBy('created_at', 'desc')
->get();
foreach ($news as $item) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('news/show/' . $item->news_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($item->news_published_at ?: ($item->updated_at ?: $item->created_at))) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// Commercial Guide (if enabled)
if (($settings['sitemap_include_guide'] ?? '1') == '1' && DB::schema()->hasTable('commercialguide_businesses')) {
$freq = $settings['sitemap_freq_guide'] ?? 'monthly';
$prio = $settings['sitemap_prio_guide'] ?? '0.7';
$businesses = DB::table('commercialguide_businesses')
->where('businesses_status', 'approved')
->get();
foreach ($businesses as $b) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('commercialguide/profile/' . $b->businesses_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($b->updated_at ?: $b->created_at)) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// Events Guide (if enabled)
if (($settings['sitemap_include_events'] ?? '1') == '1' && DB::schema()->hasTable('eventsguide_events')) {
$freq = $settings['sitemap_freq_events'] ?? 'daily';
$prio = $settings['sitemap_prio_events'] ?? '0.7';
$events = DB::table('eventsguide_events')
->where('events_status', 'approved')
->get();
foreach ($events as $e) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('eventsguide/show/' . $e->events_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($e->updated_at ?: $e->created_at)) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// FAQ (if enabled)
if (($settings['sitemap_include_faq'] ?? '1') == '1' && DB::schema()->hasTable('faqs')) {
$freq = $settings['sitemap_freq_faq'] ?? 'monthly';
$prio = $settings['sitemap_prio_faq'] ?? '0.5';
$faqs = DB::table('faqs')
->where('faqs_active', 1)
->where('faqs_visibility', 'public')
->get();
if (count($faqs) > 0) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('faq')) . '</loc>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// Expositor / Vitrine (if enabled)
if (($settings['sitemap_include_expositor'] ?? '1') == '1' && DB::schema()->hasTable('expositor_items')) {
$freq = $settings['sitemap_freq_expositor'] ?? 'daily';
$prio = $settings['sitemap_prio_expositor'] ?? '0.8';
$items = DB::table('expositor_items')
->where('items_status', 'published')
->where('items_visibility', 'public')
->get();
foreach ($items as $item) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('vitrine/show/' . $item->items_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($item->updated_at ?: $item->created_at)) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
// Expositor Categories
if (DB::schema()->hasTable('expositor_categories')) {
$categories = DB::table('expositor_categories')->get();
foreach ($categories as $cat) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('vitrine/category/' . $cat->categories_slug)) . '</loc>';
$xml .= '<changefreq>weekly</changefreq>';
$xml .= '<priority>0.5</priority>';
$xml .= '</url>';
}
}
}
// Photo Albums (if enabled)
if (($settings['sitemap_include_albums'] ?? '1') == '1' && DB::schema()->hasTable('photos_albums')) {
$freq = $settings['sitemap_freq_albums'] ?? 'monthly';
$prio = $settings['sitemap_prio_albums'] ?? '0.6';
$albums = DB::table('photos_albums')
->where('photos_albums_status', 'published')
->where('photos_albums_visibility', 'public')
->get();
foreach ($albums as $album) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('photosalbum/show/' . $album->photos_albums_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($album->updated_at ?: $album->created_at)) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// Custom Pages (PageBuilder)
if (($settings['sitemap_include_pages'] ?? '1') == '1' && DB::schema()->hasTable('pages')) {
$freq = $settings['sitemap_freq_pages'] ?? 'monthly';
$prio = $settings['sitemap_prio_pages'] ?? '0.7';
$pages = DB::table('pages')
->where('pages_is_published', 1)
->where('pages_public', 1)
->get();
foreach ($pages as $p) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url($p->pages_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($p->updated_at ?: $p->created_at)) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
// Photo Essays
if (($settings['sitemap_include_photoessays'] ?? '1') == '1' && DB::schema()->hasTable('photoessays')) {
$freq = $settings['sitemap_freq_photoessays'] ?? 'monthly';
$prio = $settings['sitemap_prio_photoessays'] ?? '0.6';
$essays = DB::table('photoessays')
->where('essay_status', 'approved')
->get();
foreach ($essays as $e) {
$xml .= '<url>';
$xml .= '<loc>' . htmlspecialchars(url('photoessay/show/' . $e->essay_slug)) . '</loc>';
$xml .= '<lastmod>' . date('Y-m-d', strtotime($e->updated_at ?: $e->created_at)) . '</lastmod>';
$xml .= '<changefreq>' . $freq . '</changefreq>';
$xml .= '<priority>' . $prio . '</priority>';
$xml .= '</url>';
}
}
$xml .= '</urlset>';
$response = new Response($xml);
$response->headers->set('Content-Type', 'application/xml');
return $response;
}
/**
* Serve virtual robots.txt if file doesn't exist or we want to override.
*/
public function robots(Request $request): Response
{
$module = Module::where('modules_name', $this->moduleName)->first();
$content = "User-agent: *\nDisallow: /admin/\nAllow: /";
if ($module && isset($module->modules_settings['robots_txt'])) {
$content = $module->modules_settings['robots_txt'];
}
$response = new Response($content);
$response->headers->set('Content-Type', 'text/plain');
return $response;
}
}