SEO landing pages: dynamic role+city titles, pretty URLs, sitemap combos
CI/CD / CI · dotnet build (push) Successful in 4m5s
CI/CD / Deploy · hamkadr (push) Successful in 3m36s

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:
soroush.asadi
2026-06-19 14:03:57 +03:30
parent 38031cb189
commit 0cf5b30dd8
8 changed files with 154 additions and 10 deletions
+32 -1
View File
@@ -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");
});