SEO landing pages: dynamic role+city titles, pretty URLs, sitemap combos
Google Search Console shows all top queries are «استخدام [نقش] [شهر]», but the
filtered index pages all shared the generic title «موقعیتهای استخدامی» and
weren't in the sitemap, so nothing ranked for those exact searches.
- Jobs/Shifts/Talent index pages now set a dynamic <title>/<h1>/meta from the
active role+city (e.g. «استخدام پزشک عمومی در تهران»).
- Pretty SEO routes /استخدام/{role}/{city?} and /شیفت/{role}/{city?} (via
AddPageRoute) resolve slugs → filters; unknown slug → 404. The layout already
derives the canonical from the path, so each pretty URL is its own canonical
and the query-string forms canonicalize to /Jobs (no duplicate content).
- sitemap.xml now lists role-only and role×city landing URLs for every combo
with live listings (URL-encoded), so Google discovers them.
- New SeoSlug helper (Persian-tolerant: ي/ك, ZWNJ, hyphen/space).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRazorPages(options =>
|
||||
{
|
||||
// Pretty SEO landing routes that target «استخدام [نقش] [شهر]» / «شیفت …» searches, in addition
|
||||
// to the query-string forms (/Jobs?RoleId=…&CityId=…). The page resolves the slugs to filters.
|
||||
options.Conventions.AddPageRoute("/Jobs/Index", "استخدام/{roleSlug}/{citySlug?}");
|
||||
options.Conventions.AddPageRoute("/Shifts/Index", "شیفت/{roleSlug}/{citySlug?}");
|
||||
});
|
||||
|
||||
// Interest tracking + recommendation engine.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -355,6 +361,31 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
|
||||
foreach (var fId in await db.Facilities.Select(f => f.Id).ToListAsync())
|
||||
Url($"{b}/Facilities/Details/{fId}", null, "weekly");
|
||||
|
||||
// SEO landing pages: role-only and role×city combos that actually have live listings, so
|
||||
// Google indexes pages targeting «استخدام [نقش] [شهر]» / «شیفت …». URL-encode each segment.
|
||||
var roleNames = await db.Roles.ToDictionaryAsync(r => r.Id, r => r.Name);
|
||||
var cityNames = await db.Cities.ToDictionaryAsync(c => c.Id, c => c.Name);
|
||||
string Seg(string s) => Uri.EscapeDataString(s);
|
||||
void Landing(string kind, int roleId, int? cityId)
|
||||
{
|
||||
if (!roleNames.TryGetValue(roleId, out var role)) return;
|
||||
var loc = $"{b}/{Seg(kind)}/{Seg(SeoSlug.Of(role))}";
|
||||
if (cityId is int c && cityNames.TryGetValue(c, out var city)) loc += $"/{Seg(SeoSlug.Of(city))}";
|
||||
Url(loc, null, "daily");
|
||||
}
|
||||
|
||||
var jobCombos = await db.JobOpenings
|
||||
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
|
||||
.Select(j => new { j.RoleId, j.Facility.CityId }).Distinct().ToListAsync();
|
||||
foreach (var rid in jobCombos.Select(x => x.RoleId).Distinct()) Landing("استخدام", rid, null);
|
||||
foreach (var x in jobCombos) Landing("استخدام", x.RoleId, x.CityId);
|
||||
|
||||
var shiftCombos = await db.Shifts
|
||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
||||
.Select(s => new { s.RoleId, s.Facility.CityId }).Distinct().ToListAsync();
|
||||
foreach (var rid in shiftCombos.Select(x => x.RoleId).Distinct()) Landing("شیفت", rid, null);
|
||||
foreach (var x in shiftCombos) Landing("شیفت", x.RoleId, x.CityId);
|
||||
|
||||
sb.Append("</urlset>");
|
||||
return Results.Content(sb.ToString(), "application/xml");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user