Add «آماده به کار» (talent) listing type — workers offering themselves
CI/CD / CI · dotnet build (push) Successful in 1m41s
CI/CD / Deploy · hamkadr (push) Has been cancelled

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:
soroush.asadi
2026-06-08 08:01:12 +03:30
parent bdcca5e548
commit 4e5df73cf7
24 changed files with 2327 additions and 34 deletions
+77 -6
View File
@@ -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)
{
@@ -170,6 +170,27 @@ public class IngestionService
?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First();
var district = districts.FirstOrDefault(x => x.Name == districtName && x.CityId == city.Id);
var kindStr = (d?.Kind ?? parsed.Kind.ToString()).ToLowerInvariant();
// «آماده به کار» — a worker offering themselves. No facility involved.
if (parsed.Kind == ListingKind.Talent || kindStr.Contains("talent") || kindStr.Contains("آماده"))
{
_db.TalentListings.Add(new TalentListing
{
Role = role, City = city, DistrictId = district?.Id,
PersonName = parsed.PersonName, YearsExperience = parsed.YearsExperience,
IsLicensed = parsed.IsLicensed, AreaNote = parsed.AreaNote,
Availability = parsed.EmploymentType, Gender = parsed.Gender,
PayType = parsed.SharePercent is not null && parsed.PayAmount is null ? PayType.Percentage
: parsed.PayAmount is null ? PayType.Negotiable : PayType.PerShift,
PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent,
Phone = parsed.Phone, Description = raw.RawText,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
});
raw.Status = RawListingStatus.Normalized;
return;
}
var facilityName = !string.IsNullOrWhiteSpace(d?.FacilityName) ? d!.FacilityName!.Trim()
: !string.IsNullOrWhiteSpace(parsed.FacilityName) ? parsed.FacilityName!.Trim()
: $"مرکز درمانی (از {raw.SourceChannel})";
@@ -186,8 +207,7 @@ public class IngestionService
facilities.Add(facility); // so later listings in this run match it too
}
var kind = (d?.Kind ?? parsed.Kind.ToString()).ToLowerInvariant();
if (kind.Contains("job") || kind.Contains("استخدام"))
if (kindStr.Contains("job") || kindStr.Contains("استخدام"))
{
_db.JobOpenings.Add(new JobOpening
{
@@ -42,8 +42,13 @@ public class ListingArchiver
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt < jobCutoff)
.ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Expired), ct);
if (expiredShifts + expiredJobs > 0)
_log.LogInformation("Archived {Shifts} shifts + {Jobs} jobs as expired", expiredShifts, expiredJobs);
return expiredShifts + expiredJobs;
var expiredTalent = await _db.TalentListings
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt < jobCutoff)
.ExecuteUpdateAsync(u => u.SetProperty(t => t.Status, ShiftStatus.Expired), ct);
if (expiredShifts + expiredJobs + expiredTalent > 0)
_log.LogInformation("Archived {Shifts} shifts + {Jobs} jobs + {Talent} talent as expired",
expiredShifts, expiredJobs, expiredTalent);
return expiredShifts + expiredJobs + expiredTalent;
}
}
@@ -43,6 +43,23 @@ public class ListingValidator
bool looksMedical = MedicalMarkers.Any(text.Contains);
if (!looksMedical) issues.Add("نشانه‌ای از حوزه درمان یافت نشد");
// «آماده به کار»: a worker offering themselves. No facility/shift-date expected; the role
// and a contact number are what matter.
if (parsed.Kind == ListingKind.Talent)
{
int ts = 0;
if (parsed.RoleName is not null) ts += 35; else issues.Add("نقش/رشته مشخص نیست");
if (parsed.Phone is not null) ts += 30; else issues.Add("شماره تماس یافت نشد");
if (parsed.CityName is not null || parsed.DistrictName is not null || parsed.AreaNote is not null) ts += 15;
if (parsed.YearsExperience is not null || parsed.IsLicensed) ts += 10;
if (looksMedical) ts += 10;
var tlen = text.Trim().Length;
if (tlen < 20) { ts -= 20; issues.Add("متن خیلی کوتاه است"); }
ts = Math.Clamp(ts, 0, 100);
bool tValid = !isSpam && looksMedical && ts >= 50;
return new ValidationResult(tValid, isSpam, ts, issues);
}
int score = 0;
if (parsed.RoleName is not null) score += 30; else issues.Add("نقش مشخص نیست");
if (parsed.CityName is not null || parsed.DistrictName is not null) score += 20;