This commit is contained in:
Doar Mihai
2026-04-04 02:14:57 +03:00
parent 31e666ef33
commit 3a687b0a7f
11 changed files with 686 additions and 8 deletions
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\CacheService;
use App\Services\SitemapService;
use Illuminate\Http\Response;
class SitemapController extends Controller
{
public function __construct(
private SitemapService $sitemapService,
private CacheService $cacheService
) {}
/**
* Generate and serve main sitemap
*/
public function index(): Response
{
$xml = $this->cacheService->remember(
'sitemap:main',
3600, // 1 hour cache
fn() => $this->sitemapService->generateSitemap()
);
return response($xml, 200)
->header('Content-Type', 'application/xml');
}
/**
* Generate and serve sitemap index
*/
public function sitemapIndex(): Response
{
$xml = $this->cacheService->remember(
'sitemap:index',
3600,
fn() => $this->sitemapService->generateSitemapIndex()
);
return response($xml, 200)
->header('Content-Type', 'application/xml');
}
/**
* Generate and serve main sitemap (static pages)
*/
public function mainSitemap(): Response
{
$xml = $this->cacheService->remember(
'sitemap:main-pages',
3600,
fn() => $this->sitemapService->generateMainSitemap()
);
return response($xml, 200)
->header('Content-Type', 'application/xml');
}
/**
* Generate and serve jobs sitemap
*/
public function jobsSitemap(): Response
{
$xml = $this->cacheService->remember(
'sitemap:jobs',
1800, // 30 minutes cache (jobs change more frequently)
fn() => $this->sitemapService->generateJobsSitemap()
);
return response($xml, 200)
->header('Content-Type', 'application/xml');
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
namespace App\Services;
use App\Models\Job;
use App\Models\JobCategory;
use Illuminate\Support\Str;
class SeoService
{
/**
* Generate meta tags for a job
*/
public function getJobMetaTags(Job $job): array
{
$title = $job->title . ' at ' . $job->company . ' - JobifyGarden';
$description = Str::limit(strip_tags($job->description), 155);
$url = route('jobs.show', $job->id);
return [
'title' => $title,
'description' => $description,
'keywords' => $this->generateJobKeywords($job),
'canonical' => $url,
'og_title' => $job->title . ' at ' . $job->company,
'og_description' => $description,
'og_url' => $url,
'og_type' => 'website',
'og_image' => asset('images/og-default.jpg'),
'twitter_card' => 'summary_large_image',
'twitter_title' => $job->title . ' at ' . $job->company,
'twitter_description' => $description,
'twitter_image' => asset('images/twitter-card-default.jpg'),
];
}
/**
* Generate meta tags for category page
*/
public function getCategoryMetaTags(JobCategory $category, int $jobCount): array
{
$title = $category->name . ' Jobs - JobifyGarden';
$description = "Browse {$jobCount} {$category->name} job opportunities. " . ($category->description ?? 'Find your next career in ' . $category->name . '.');
$url = route('jobs.category', $category->slug);
return [
'title' => $title,
'description' => Str::limit($description, 155),
'keywords' => $this->generateCategoryKeywords($category),
'canonical' => $url,
'og_title' => $category->name . ' Jobs',
'og_description' => Str::limit($description, 155),
'og_url' => $url,
'og_type' => 'website',
'og_image' => asset('images/og-default.jpg'),
'twitter_card' => 'summary',
'twitter_title' => $category->name . ' Jobs',
'twitter_description' => Str::limit($description, 155),
];
}
/**
* Generate meta tags for homepage
*/
public function getHomeMetaTags(int $jobCount): array
{
return [
'title' => 'JobifyGarden - Find Your Dream Job | ' . number_format($jobCount) . '+ Jobs',
'description' => 'Discover thousands of job opportunities from multiple sources. Search remote jobs, full-time positions, and more. Your next career starts here.',
'keywords' => 'jobs, careers, employment, job search, remote jobs, full-time jobs, part-time jobs, find jobs',
'canonical' => route('home'),
'og_title' => 'JobifyGarden - Find Your Dream Job',
'og_description' => 'Discover thousands of job opportunities from multiple sources. Your next career starts here.',
'og_url' => route('home'),
'og_type' => 'website',
'og_image' => asset('images/og-home.jpg'),
'twitter_card' => 'summary_large_image',
'twitter_title' => 'JobifyGarden - Find Your Dream Job',
'twitter_description' => 'Discover thousands of job opportunities from multiple sources.',
'twitter_image' => asset('images/twitter-card-home.jpg'),
];
}
/**
* Generate keywords for a job
*/
private function generateJobKeywords(Job $job): string
{
$keywords = [
$job->title,
$job->company,
$job->job_type,
$job->experience_level,
];
if ($job->category) {
$keywords[] = $job->category->name;
}
if ($job->location) {
$keywords[] = $job->location;
}
if ($job->remote_allowed) {
$keywords[] = 'remote';
$keywords[] = 'work from home';
}
return implode(', ', array_filter($keywords));
}
/**
* Generate keywords for a category
*/
private function generateCategoryKeywords(JobCategory $category): string
{
return implode(', ', [
$category->name . ' jobs',
$category->name . ' careers',
$category->name . ' employment',
'find ' . $category->name . ' jobs',
]);
}
/**
* Generate JSON-LD structured data for a job
*/
public function generateJobStructuredData(Job $job): array
{
$data = [
'@context' => 'https://schema.org/',
'@type' => 'JobPosting',
'title' => $job->title,
'description' => strip_tags($job->description),
'datePosted' => $job->posted_at?->toIso8601String() ?? $job->created_at->toIso8601String(),
'hiringOrganization' => [
'@type' => 'Organization',
'name' => $job->company,
],
'jobLocation' => $this->getJobLocationData($job),
'employmentType' => $this->mapEmploymentType($job->job_type),
];
if ($job->expires_at) {
$data['validThrough'] = $job->expires_at->toIso8601String();
}
if ($job->salary_min || $job->salary_max) {
$data['baseSalary'] = $this->getSalaryData($job);
}
if ($job->experience_level) {
$data['experienceRequirements'] = [
'@type' => 'OccupationalExperienceRequirements',
'monthsOfExperience' => $this->mapExperienceLevel($job->experience_level),
];
}
if ($job->apply_url || $job->source_url) {
$data['url'] = $job->apply_url ?? $job->source_url;
}
return $data;
}
/**
* Get job location data for structured data
*/
private function getJobLocationData(Job $job): array
{
if ($job->remote_allowed) {
return [
'@type' => 'Place',
'address' => [
'@type' => 'PostalAddress',
'addressCountry' => 'Remote',
],
];
}
$address = [
'@type' => 'PostalAddress',
];
if ($job->jobLocation) {
$address['addressLocality'] = $job->jobLocation->city;
$address['addressRegion'] = $job->jobLocation->state;
$address['addressCountry'] = $job->jobLocation->country;
} elseif ($job->location) {
$address['name'] = $job->location;
}
return [
'@type' => 'Place',
'address' => $address,
];
}
/**
* Get salary data for structured data
*/
private function getSalaryData(Job $job): array
{
$currency = $job->salary_currency ?? 'USD';
if ($job->salary_min && $job->salary_max) {
return [
'@type' => 'MonetaryAmount',
'currency' => $currency,
'value' => [
'@type' => 'QuantitativeValue',
'minValue' => $job->salary_min,
'maxValue' => $job->salary_max,
'unitText' => 'YEAR',
],
];
}
$value = $job->salary_min ?? $job->salary_max;
return [
'@type' => 'MonetaryAmount',
'currency' => $currency,
'value' => [
'@type' => 'QuantitativeValue',
'value' => $value,
'unitText' => 'YEAR',
],
];
}
/**
* Map job type to schema.org employment type
*/
private function mapEmploymentType(?string $jobType): string
{
return match ($jobType) {
'full-time', 'full_time' => 'FULL_TIME',
'part-time', 'part_time' => 'PART_TIME',
'contract' => 'CONTRACTOR',
'temporary' => 'TEMPORARY',
'internship' => 'INTERN',
'freelance' => 'CONTRACTOR',
default => 'FULL_TIME',
};
}
/**
* Map experience level to months
*/
private function mapExperienceLevel(?string $level): int
{
return match ($level) {
'entry' => 0,
'mid' => 24,
'senior' => 60,
'lead' => 84,
'executive' => 120,
default => 0,
};
}
/**
* Generate breadcrumb structured data
*/
public function generateBreadcrumbStructuredData(array $items): array
{
$listItems = [];
foreach ($items as $index => $item) {
$listItems[] = [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $item['name'],
'item' => $item['url'],
];
}
return [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $listItems,
];
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Services;
use App\Models\Job;
use App\Models\JobCategory;
use Illuminate\Support\Facades\URL;
class SitemapService
{
/**
* Generate XML sitemap
*/
public function generateSitemap(): string
{
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>');
// Add homepage
$this->addUrl($xml, route('home'), now(), 'daily', '1.0');
// Add jobs index
$this->addUrl($xml, route('jobs.index'), now(), 'daily', '0.9');
// Add remote jobs
$this->addUrl($xml, route('jobs.remote'), now(), 'daily', '0.9');
// Add categories
$categories = JobCategory::all();
foreach ($categories as $category) {
$this->addUrl(
$xml,
route('jobs.category', $category->slug),
$category->updated_at,
'daily',
'0.8'
);
}
// Add active jobs
$jobs = Job::where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->orderBy('posted_at', 'desc')
->limit(10000) // Limit to 10k most recent jobs
->get(['id', 'posted_at', 'updated_at']);
foreach ($jobs as $job) {
$this->addUrl(
$xml,
route('jobs.show', $job->id),
$job->updated_at ?? $job->posted_at,
'weekly',
'0.7'
);
}
return $xml->asXML();
}
/**
* Add URL to sitemap
*/
private function addUrl(
\SimpleXMLElement $xml,
string $url,
?\DateTimeInterface $lastmod = null,
string $changefreq = 'weekly',
string $priority = '0.5'
): void {
$urlElement = $xml->addChild('url');
$urlElement->addChild('loc', htmlspecialchars($url));
if ($lastmod) {
$urlElement->addChild('lastmod', $lastmod->format('Y-m-d'));
}
$urlElement->addChild('changefreq', $changefreq);
$urlElement->addChild('priority', $priority);
}
/**
* Generate sitemap index for large sites
*/
public function generateSitemapIndex(): string
{
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></sitemapindex>');
// Add main sitemap
$this->addSitemapToIndex($xml, route('sitemap.main'), now());
// Add jobs sitemap
$this->addSitemapToIndex($xml, route('sitemap.jobs'), now());
return $xml->asXML();
}
/**
* Add sitemap to index
*/
private function addSitemapToIndex(\SimpleXMLElement $xml, string $url, \DateTimeInterface $lastmod): void
{
$sitemapElement = $xml->addChild('sitemap');
$sitemapElement->addChild('loc', htmlspecialchars($url));
$sitemapElement->addChild('lastmod', $lastmod->format('Y-m-d'));
}
/**
* Generate main sitemap (static pages and categories)
*/
public function generateMainSitemap(): string
{
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>');
// Add homepage
$this->addUrl($xml, route('home'), now(), 'daily', '1.0');
// Add jobs index
$this->addUrl($xml, route('jobs.index'), now(), 'daily', '0.9');
// Add remote jobs
$this->addUrl($xml, route('jobs.remote'), now(), 'daily', '0.9');
// Add categories
$categories = JobCategory::all();
foreach ($categories as $category) {
$this->addUrl(
$xml,
route('jobs.category', $category->slug),
$category->updated_at,
'daily',
'0.8'
);
}
return $xml->asXML();
}
/**
* Generate jobs sitemap
*/
public function generateJobsSitemap(): string
{
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>');
// Add active jobs
$jobs = Job::where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->orderBy('posted_at', 'desc')
->limit(50000) // Sitemap limit
->get(['id', 'posted_at', 'updated_at']);
foreach ($jobs as $job) {
$this->addUrl(
$xml,
route('jobs.show', $job->id),
$job->updated_at ?? $job->posted_at,
'weekly',
'0.7'
);
}
return $xml->asXML();
}
}