[SEO] JobPosting structured data, canonical/OG meta, noindex private pages, fuller sitemap
CI/CD / CI · dotnet build (push) Successful in 59s
CI/CD / Deploy · hamkadr (push) Successful in 2m27s

Strategy = Google-for-Jobs + clean indexing. Add schema.org JobPosting JSON-LD to shift & job detail pages (title, description, datePosted, validThrough, employmentType, hiringOrganization, jobLocation, baseSalary) plus Organization + WebSite JSON-LD on the home page (SeoJsonLd helper; System.Text.Json => valid, script-safe). Layout emits per-page canonical, Open Graph + Twitter cards, and applies robots noindex,nofollow to all private/applicant areas (/Admin,/Me,/Employer,/Account,/Preferences) so applicant data is never indexed. robots.txt now disallows those + /resume,/avatar,/report,/push,/notifications and points at the sitemap; sitemap.xml adds facility pages + content pages (Download/Help/Privacy/Rules/Terms).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 08:16:30 +03:30
parent aa61efd46f
commit 6af6a026a1
6 changed files with 194 additions and 3 deletions
+6
View File
@@ -152,3 +152,9 @@
</div>
</div>
</section>
@section Head {
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Organization(bu) + "</script>")
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.WebSite(bu) + "</script>")
}
@@ -177,3 +177,8 @@
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@section Head {
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.JobPosting(j, bu) + "</script>")
}
@@ -17,14 +17,46 @@
meHasAvatar = info?.HasAvatar ?? false;
}
var meInitial = string.IsNullOrWhiteSpace(meName) ? "؟" : meName!.Trim().Substring(0, 1);
// --- SEO context ---
var baseUrl = $"{Context.Request.Scheme}://{Context.Request.Host}";
var path = Context.Request.Path.Value ?? "/";
var canonical = baseUrl + (path == "/" ? "" : path); // canonical ignores query string
var pageDesc = ViewData["Description"] as string
?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستان‌ها و کلینیک‌های تهران.";
var pageTitle = title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر";
var ogImage = ViewData["OgImage"] as string ?? baseUrl + "/icons/icon-512.png";
// Private/applicant areas must never be indexed.
string[] noindexPrefixes = { "/Admin", "/Me", "/Employer", "/Account", "/Preferences" };
var noIndex = (ViewData["NoIndex"] as bool? ?? false)
|| noindexPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر")</title>
<meta name="description" content="@(ViewData["Description"] as string ?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستان‌ها و کلینیک‌های تهران.")" />
<title>@pageTitle</title>
<meta name="description" content="@pageDesc" />
@if (noIndex)
{
<meta name="robots" content="noindex, nofollow" />
}
else
{
<link rel="canonical" href="@canonical" />
}
@* Open Graph / Twitter — rich previews when shared in Telegram/Bale/etc. *@
<meta property="og:type" content="website" />
<meta property="og:site_name" content="همکادر" />
<meta property="og:title" content="@pageTitle" />
<meta property="og:description" content="@pageDesc" />
<meta property="og:url" content="@canonical" />
<meta property="og:image" content="@ogImage" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@pageTitle" />
<meta name="twitter:description" content="@pageDesc" />
@* Preload the body-weight font so the swap from Tahoma happens fast. Vazirmatn is
self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@
<link rel="preload" href="~/fonts/Vazirmatn-Regular.woff2" as="font" type="font/woff2" crossorigin />
@@ -38,6 +70,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="همکادر" />
@await RenderSectionAsync("Head", required: false)
</head>
<body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")">
<header class="site-header">
@@ -197,3 +197,8 @@
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@section Head {
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ShiftPosting(s, bu) + "</script>")
}
+16 -1
View File
@@ -305,7 +305,15 @@ self.addEventListener('notificationclick', e => {
app.MapGet("/robots.txt", (HttpContext ctx) =>
{
var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
return Results.Text($"User-agent: *\nAllow: /\nDisallow: /Admin\nDisallow: /Employer\nSitemap: {b}/sitemap.xml\n", "text/plain");
var rules = string.Join('\n',
"User-agent: *",
"Allow: /",
// Private / applicant areas — never index.
"Disallow: /Admin", "Disallow: /Employer", "Disallow: /Me", "Disallow: /Account",
"Disallow: /Preferences", "Disallow: /resume/", "Disallow: /avatar/",
"Disallow: /report", "Disallow: /push/", "Disallow: /notifications/",
$"Sitemap: {b}/sitemap.xml", "");
return Results.Text(rules, "text/plain");
});
app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
@@ -326,6 +334,9 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Calendar", "/Facilities" })
Url($"{b}{p}", DateTime.UtcNow, p == "" ? "daily" : "hourly");
// Static content pages (rarely change).
foreach (var p in new[] { "/Download", "/Help", "/Privacy", "/Rules", "/Terms" })
Url($"{b}{p}", null, "monthly");
foreach (var s in await db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.Select(s => new { s.Id, s.CreatedAt }).ToListAsync())
@@ -335,6 +346,10 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
.Select(j => new { j.Id, j.CreatedAt }).ToListAsync())
Url($"{b}/Jobs/Details/{j.Id}", j.CreatedAt, "weekly");
// Public facility pages.
foreach (var fId in await db.Facilities.Select(f => f.Id).ToListAsync())
Url($"{b}/Facilities/Details/{fId}", null, "weekly");
sb.Append("</urlset>");
return Results.Content(sb.ToString(), "application/xml");
});
+127
View File
@@ -0,0 +1,127 @@
using System.Text.Json;
using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services;
/// <summary>
/// Builds schema.org JSON-LD for listings so Google surfaces them as rich results
/// (Google for Jobs uses JobPosting). System.Text.Json guarantees valid, script-safe
/// output (Persian + &lt; &gt; &amp; are \u-escaped), so it can be emitted inside a
/// &lt;script type="application/ld+json"&gt; tag directly.
/// </summary>
public static class SeoJsonLd
{
private static readonly JsonSerializerOptions Opts = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public static string ShiftPosting(Shift s, string baseUrl)
{
var typeLabel = s.ShiftType switch
{
ShiftType.Day => "شیفت صبح",
ShiftType.Evening => "شیفت عصر",
ShiftType.Night => "شیفت شب",
_ => "آنکال",
};
object? salary = s.PayAmount is long amt && amt > 0
? new { type = "MonetaryAmount", currency = "IRR", value = new { type = "QuantitativeValue", value = amt, unitText = "DAY" } }
: null;
var obj = new Dictionary<string, object?>
{
["@context"] = "https://schema.org/",
["@type"] = "JobPosting",
["title"] = $"{s.Role?.Name} — {typeLabel}",
["description"] = string.IsNullOrWhiteSpace(s.Description)
? $"{typeLabel} برای {s.Role?.Name} در {s.Facility?.Name}، {s.Facility?.City?.Name}." : s.Description,
["datePosted"] = s.CreatedAt.ToString("yyyy-MM-dd"),
["validThrough"] = s.Date.ToString("yyyy-MM-dd"),
["employmentType"] = "PER_DIEM",
["directApply"] = true,
["identifier"] = new { type = "PropertyValue", name = "همکادر", value = $"shift-{s.Id}" },
["hiringOrganization"] = new { type = "Organization", name = s.Facility?.Name, sameAs = $"{baseUrl}/Facilities/Details/{s.FacilityId}" },
["jobLocation"] = new
{
type = "Place",
address = new { type = "PostalAddress", addressLocality = s.Facility?.City?.Name, addressCountry = "IR", streetAddress = s.Facility?.Address }
},
};
if (salary is not null) obj["baseSalary"] = salary;
// rename "type" keys to "@type" after serialization (anonymous objects can't use @type directly)
return Fix(JsonSerializer.Serialize(obj, Opts));
}
public static string JobPosting(JobOpening j, string baseUrl)
{
var empType = j.EmploymentType switch
{
EmploymentType.FullTime => "FULL_TIME",
EmploymentType.PartTime => "PART_TIME",
EmploymentType.Contract => "CONTRACTOR",
_ => "OTHER",
};
long? min = j.SalaryMin, max = j.SalaryMax;
object? salary = (min ?? max) is long
? new
{
type = "MonetaryAmount", currency = "IRR",
value = new { type = "QuantitativeValue", minValue = min, maxValue = max ?? min, unitText = "MONTH" }
}
: null;
var obj = new Dictionary<string, object?>
{
["@context"] = "https://schema.org/",
["@type"] = "JobPosting",
["title"] = j.Title,
["description"] = string.IsNullOrWhiteSpace(j.Description)
? $"استخدام {j.Role?.Name} در {j.Facility?.Name}، {j.Facility?.City?.Name}." : j.Description,
["datePosted"] = j.CreatedAt.ToString("yyyy-MM-dd"),
["validThrough"] = j.CreatedAt.AddDays(30).ToString("yyyy-MM-dd"),
["employmentType"] = empType,
["directApply"] = true,
["identifier"] = new { type = "PropertyValue", name = "همکادر", value = $"job-{j.Id}" },
["hiringOrganization"] = new { type = "Organization", name = j.Facility?.Name, sameAs = $"{baseUrl}/Facilities/Details/{j.FacilityId}" },
["jobLocation"] = new
{
type = "Place",
address = new { type = "PostalAddress", addressLocality = j.Facility?.City?.Name, addressCountry = "IR", streetAddress = j.Facility?.Address }
},
};
if (salary is not null) obj["baseSalary"] = salary;
return Fix(JsonSerializer.Serialize(obj, Opts));
}
public static string Organization(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "Organization",
["name"] = "همکادر",
["url"] = baseUrl,
["logo"] = $"{baseUrl}/icons/icon-512.png",
["description"] = "سامانه یافتن شیفت و استخدام کادر درمان در ایران.",
}, Opts));
public static string WebSite(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "WebSite",
["name"] = "همکادر",
["url"] = baseUrl,
["inLanguage"] = "fa-IR",
["potentialAction"] = new
{
type = "SearchAction",
target = $"{baseUrl}/Shifts?q={{search_term_string}}",
queryyy = "required name=search_term_string",
},
}, Opts));
// Nested anonymous objects use "type"/"queryyy" placeholders for @type / query-input;
// restore the @-prefixed schema.org keys here.
private static string Fix(string json) => json
.Replace("\"type\":", "\"@type\":")
.Replace("\"queryyy\":", "\"query-input\":");
}