Add «آماده به کار» (talent) listing type — workers offering themselves
Adds a third listing kind alongside Shift/Job for healthcare staff who advertise their own availability (very common in Iranian medical channels, e.g. "دندانپزشک آماده همکاری… ۵۰٪ تسویه"). These have no facility; the contact phone is the key field. - Model: TalentListing (role, person name, years, licensed, city/district, area note, availability, gender, comp, phone) + ListingKind.Talent + RawListing.LinkedTalentId + DbSet/relations/indexes + EF migration. - Parser: detect آمادهبهکار/جویای کار → Kind=Talent; extract person name, years of experience, licensed flag, area («منطقه ۱»), phone. Facility name extraction now skipped for talent. - Validator: talent path scores role + phone + medical (no facility/pay required). - Ingestion auto-publish: creates a TalentListing for talent kind. - Review (manual publish): Talent option + talent fields; publishes a TalentListing without a facility. Shift/Job facility now falls back to a shared «نامشخص / ثبت نشده» record when the ad names none — publishing never fails on a missing facility. - Browse /Talent (indexable, filters: city/district/role/gender), details /Talent/Details (noindex — personal contact, tel: call button), _TalentCard, badge-talent, nav link, home section. - Sitemap includes /Talent; robots disallows /Talent/Details. Archiver expires stale talent listings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,12 @@ public class ParsedListing
|
||||
public string? DistrictName { get; set; }
|
||||
public string? FacilityName { get; set; } // hospital/clinic name guessed from the text
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// «آماده به کار» (talent) extras — populated when Kind == Talent.
|
||||
public string? PersonName { get; set; } // «دکتر سپیده علیزاده»
|
||||
public int? YearsExperience { get; set; } // سابقه (سال)
|
||||
public bool IsLicensed { get; set; } // پروانهدار
|
||||
public string? AreaNote { get; set; } // «فقط منطقه ۱»
|
||||
public List<string> Notes { get; set; } = new(); // what was/wasn't detected (shown to admin)
|
||||
}
|
||||
|
||||
@@ -41,11 +47,25 @@ public class HeuristicListingParser : IListingParser
|
||||
var p = new ParsedListing();
|
||||
var text = Normalize(raw);
|
||||
|
||||
// --- Kind: shift vs hiring ---
|
||||
bool jobSignals = ContainsAny(text, "استخدام", "جذب", "دعوت به همکاری", "تمام وقت", "تماموقت", "قرارداد", "ماهانه", "حقوق ثابت");
|
||||
// --- Kind: talent (worker offers themselves) vs shift vs hiring ---
|
||||
// Talent is checked first: «آماده به کار/همکاری», «جویای کار» mean the *person* is
|
||||
// available — distinct from an employer's «دعوت به همکاری».
|
||||
bool talentSignals = ContainsAny(text,
|
||||
"آماده به کار", "آمادهبهکار", "آماده همکاری", "آمادهی همکاری", "آماده ی همکاری",
|
||||
"آماده فعالیت", "جویای کار", "جویای کار هستم", "متقاضی کار", "نیازمند کار",
|
||||
"آماده انجام", "میتوانم همکاری", "میتوانم همکاری", "حاضر به همکاری");
|
||||
bool jobSignals = ContainsAny(text, "استخدام", "جذب", "دعوت به همکاری", "نیازمندیم", "نیازمند است", "حقوق ثابت");
|
||||
bool shiftSignals = ContainsAny(text, "شیفت", "آنکال", "انکال", "نوبت", "کشیک");
|
||||
p.Kind = (jobSignals && !shiftSignals) ? ListingKind.Job : ListingKind.Shift;
|
||||
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)");
|
||||
if (talentSignals)
|
||||
{
|
||||
p.Kind = ListingKind.Talent;
|
||||
p.Notes.Add("نوع: آماده به کار (تشخیص خودکار)");
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Kind = (jobSignals && !shiftSignals) ? ListingKind.Job : ListingKind.Shift;
|
||||
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)");
|
||||
}
|
||||
|
||||
// --- Role (longest match first so «پزشک متخصص» beats «پزشک») ---
|
||||
foreach (var role in knownRoles.OrderByDescending(r => r.Length))
|
||||
@@ -108,9 +128,31 @@ public class HeuristicListingParser : IListingParser
|
||||
else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد");
|
||||
}
|
||||
|
||||
// --- Talent extras (only meaningful for «آماده به کار») ---
|
||||
if (p.Kind == ListingKind.Talent)
|
||||
{
|
||||
var latinT = ToLatinDigits(text);
|
||||
var exp = Regex.Match(latinT, @"سابقه[^\d]{0,8}(\d{1,2})\s*سال");
|
||||
if (!exp.Success) exp = Regex.Match(latinT, @"(\d{1,2})\s*سال\s*سابقه");
|
||||
if (exp.Success && int.TryParse(exp.Groups[1].Value, out var yrs) && yrs is > 0 and <= 60)
|
||||
{ p.YearsExperience = yrs; p.Notes.Add($"سابقه: {yrs} سال"); }
|
||||
|
||||
p.IsLicensed = ContainsAny(text, "پروانه دار", "پروانهدار", "دارای پروانه", "پروانه فعالیت", "پروانه طبابت");
|
||||
if (p.IsLicensed) p.Notes.Add("پروانهدار");
|
||||
|
||||
p.PersonName = ExtractPersonName(text);
|
||||
if (p.PersonName is not null) p.Notes.Add($"نام: {p.PersonName}");
|
||||
|
||||
var area = Regex.Match(text, @"منطقه\s*[۰-۹0-9]{1,2}");
|
||||
if (area.Success) { p.AreaNote = area.Value.Trim(); p.Notes.Add($"محدوده: {p.AreaNote}"); }
|
||||
}
|
||||
|
||||
// --- Facility name (بیمارستان/درمانگاه/کلینیک ... + the distinctive name) ---
|
||||
p.FacilityName = ExtractFacilityName(text);
|
||||
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}");
|
||||
if (p.Kind != ListingKind.Talent)
|
||||
{
|
||||
p.FacilityName = ExtractFacilityName(text);
|
||||
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}");
|
||||
}
|
||||
|
||||
// --- Phone ---
|
||||
var phone = Regex.Match(ToLatinDigits(text), @"0?9\d{9}");
|
||||
@@ -161,6 +203,35 @@ public class HeuristicListingParser : IListingParser
|
||||
return null;
|
||||
}
|
||||
|
||||
// Titles that introduce a person's name in «آماده به کار» posts.
|
||||
private static readonly string[] PersonTitles = { "دکتر", "خانم دکتر", "آقای دکتر", "مهندس", "سرکار خانم", "جناب آقای", "خانم", "آقای" };
|
||||
|
||||
/// <summary>Best-effort person name: a title (دکتر/خانم/…) plus up to two following words.</summary>
|
||||
private static string? ExtractPersonName(string text)
|
||||
{
|
||||
foreach (var title in PersonTitles)
|
||||
{
|
||||
var idx = text.IndexOf(title, StringComparison.Ordinal);
|
||||
if (idx < 0) continue;
|
||||
var after = text[(idx + title.Length)..];
|
||||
var words = after.Split(
|
||||
new[] { ' ', '\n', '\r', '\t', '،', ',', '.', '؛', ':', '(', ')', '-', '/' },
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
var picked = new List<string>();
|
||||
foreach (var w in words)
|
||||
{
|
||||
if (NameStops.Contains(w)) break;
|
||||
if (Regex.IsMatch(w, @"[\d]")) break;
|
||||
if (w.Length == 1) break;
|
||||
picked.Add(w);
|
||||
if (picked.Count >= 2) break;
|
||||
}
|
||||
if (picked.Count == 0) continue;
|
||||
return (title + " " + string.Join(" ", picked)).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Pull a Toman figure out of free text, handling «میلیون» and Persian digits.</summary>
|
||||
private static long? ExtractAmount(string text)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user