[SEO] JobPosting structured data, canonical/OG meta, noindex private pages, fuller sitemap
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:
@@ -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>")
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 + < > & are \u-escaped), so it can be emitted inside a
|
||||
/// <script type="application/ld+json"> 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\":");
|
||||
}
|
||||
Reference in New Issue
Block a user