2026-06-03 01:43:55 +03:30
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
|
using JobsMedical.Web.Models;
|
|
|
|
|
|
|
|
|
|
|
|
namespace JobsMedical.Web.Services;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>Structured guess extracted from a raw channel post. All fields are best-effort.</summary>
|
|
|
|
|
|
public class ParsedListing
|
|
|
|
|
|
{
|
|
|
|
|
|
public ListingKind Kind { get; set; } = ListingKind.Shift;
|
|
|
|
|
|
public string? RoleName { get; set; }
|
|
|
|
|
|
public ShiftType? ShiftType { get; set; }
|
|
|
|
|
|
public EmploymentType? EmploymentType { get; set; }
|
|
|
|
|
|
public long? PayAmount { get; set; } // shift pay or single salary figure
|
2026-06-03 06:26:54 +03:30
|
|
|
|
public int? SharePercent { get; set; } // profit-share % (درصدی / سهم درآمد)
|
2026-06-03 01:43:55 +03:30
|
|
|
|
public bool PayNegotiable { get; set; }
|
2026-06-04 00:19:32 +03:30
|
|
|
|
public Gender Gender { get; set; } = Gender.Any; // جنسیت مورد نیاز
|
2026-06-03 01:43:55 +03:30
|
|
|
|
public string? CityName { get; set; }
|
|
|
|
|
|
public string? DistrictName { get; set; }
|
|
|
|
|
|
public string? Phone { get; set; }
|
|
|
|
|
|
public List<string> Notes { get; set; } = new(); // what was/wasn't detected (shown to admin)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Turns a messy Persian channel/Divar post into a structured listing guess. This is the
|
|
|
|
|
|
/// Stage-1 implementation: transparent keyword + regex heuristics, no AI dependency (important
|
|
|
|
|
|
/// since LLM APIs are blocked from Iran). A future LlmListingParser can implement the same
|
|
|
|
|
|
/// interface and be swapped in via DI without touching the admin queue.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public interface IListingParser
|
|
|
|
|
|
{
|
|
|
|
|
|
ParsedListing Parse(string rawText, IEnumerable<string> knownRoles,
|
|
|
|
|
|
IEnumerable<string> knownCities, IEnumerable<string> knownDistricts);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public class HeuristicListingParser : IListingParser
|
|
|
|
|
|
{
|
|
|
|
|
|
public ParsedListing Parse(string raw, IEnumerable<string> knownRoles,
|
|
|
|
|
|
IEnumerable<string> knownCities, IEnumerable<string> knownDistricts)
|
|
|
|
|
|
{
|
|
|
|
|
|
var p = new ParsedListing();
|
|
|
|
|
|
var text = Normalize(raw);
|
|
|
|
|
|
|
|
|
|
|
|
// --- Kind: shift vs hiring ---
|
|
|
|
|
|
bool jobSignals = ContainsAny(text, "استخدام", "جذب", "دعوت به همکاری", "تمام وقت", "تماموقت", "قرارداد", "ماهانه", "حقوق ثابت");
|
|
|
|
|
|
bool shiftSignals = ContainsAny(text, "شیفت", "آنکال", "انکال", "نوبت", "کشیک");
|
|
|
|
|
|
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))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (text.Contains(Normalize(role))) { p.RoleName = role; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (p.RoleName is null && ContainsAny(text, "پزشک", "دکتر")) p.RoleName = "پزشک عمومی";
|
|
|
|
|
|
p.Notes.Add(p.RoleName is null ? "نقش: تشخیص داده نشد" : $"نقش: {p.RoleName}");
|
|
|
|
|
|
|
|
|
|
|
|
// --- Shift type ---
|
|
|
|
|
|
if (ContainsAny(text, "آنکال", "انکال")) p.ShiftType = Models.ShiftType.OnCall;
|
|
|
|
|
|
else if (text.Contains("شب")) p.ShiftType = Models.ShiftType.Night;
|
|
|
|
|
|
else if (text.Contains("عصر")) p.ShiftType = Models.ShiftType.Evening;
|
|
|
|
|
|
else if (ContainsAny(text, "صبح", "روز")) p.ShiftType = Models.ShiftType.Day;
|
|
|
|
|
|
|
|
|
|
|
|
// --- Employment type ---
|
|
|
|
|
|
if (ContainsAny(text, "پاره وقت", "پارهوقت", "پارت تایم")) p.EmploymentType = Models.EmploymentType.PartTime;
|
|
|
|
|
|
else if (text.Contains("طرح")) p.EmploymentType = Models.EmploymentType.Plan;
|
|
|
|
|
|
else if (text.Contains("قرارداد")) p.EmploymentType = Models.EmploymentType.Contract;
|
|
|
|
|
|
else if (ContainsAny(text, "تمام وقت", "تماموقت")) p.EmploymentType = Models.EmploymentType.FullTime;
|
|
|
|
|
|
|
2026-06-04 00:19:32 +03:30
|
|
|
|
// --- Gender requirement ---
|
|
|
|
|
|
if (ContainsAny(text, "خانم", "خانوم", "بانو", "زن ", "مامای")) p.Gender = Gender.Female;
|
|
|
|
|
|
else if (ContainsAny(text, "آقا", "اقا", "مرد ", "مرد،", "پسر")) p.Gender = Gender.Male;
|
|
|
|
|
|
if (p.Gender != Gender.Any)
|
|
|
|
|
|
p.Notes.Add($"جنسیت: {(p.Gender == Gender.Female ? "خانم" : "آقا")}");
|
|
|
|
|
|
|
2026-06-03 01:43:55 +03:30
|
|
|
|
// --- City / district ---
|
|
|
|
|
|
p.CityName = knownCities.FirstOrDefault(c => text.Contains(Normalize(c)));
|
|
|
|
|
|
p.DistrictName = knownDistricts.OrderByDescending(d => d.Length)
|
|
|
|
|
|
.FirstOrDefault(d => text.Contains(Normalize(d)));
|
|
|
|
|
|
|
2026-06-03 06:26:54 +03:30
|
|
|
|
// --- Profit share (درصدی / سهم) ---
|
|
|
|
|
|
var latinForShare = ToLatinDigits(text);
|
|
|
|
|
|
var share = Regex.Match(latinForShare, @"(\d{1,3})\s*(?:٪|%|درصد)");
|
|
|
|
|
|
if (!share.Success) share = Regex.Match(latinForShare, @"(?:٪|%)\s*(\d{1,3})");
|
|
|
|
|
|
if (share.Success && int.TryParse(share.Groups[1].Value, out var pct) && pct is > 0 and <= 100)
|
|
|
|
|
|
{ p.SharePercent = pct; p.Notes.Add($"سهم درآمد: {pct}٪"); }
|
|
|
|
|
|
else if (ContainsAny(text, "درصدی", "سهم درآمد", "شراکت", "پورسانت"))
|
|
|
|
|
|
{ p.Notes.Add("پرداخت درصدی/سهمی (درصد نامشخص)"); }
|
|
|
|
|
|
|
|
|
|
|
|
// --- Fixed pay ---
|
2026-06-03 01:43:55 +03:30
|
|
|
|
if (ContainsAny(text, "توافقی", "توافق")) { p.PayNegotiable = true; p.Notes.Add("حقوق: توافقی"); }
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
var amount = ExtractAmount(text);
|
|
|
|
|
|
if (amount is not null) { p.PayAmount = amount; p.Notes.Add($"حقوق تخمینی: {amount:#,0} تومان"); }
|
2026-06-03 06:26:54 +03:30
|
|
|
|
else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد");
|
2026-06-03 01:43:55 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Phone ---
|
|
|
|
|
|
var phone = Regex.Match(ToLatinDigits(text), @"0?9\d{9}");
|
|
|
|
|
|
if (phone.Success) p.Phone = phone.Value;
|
|
|
|
|
|
|
|
|
|
|
|
return p;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>Pull a Toman figure out of free text, handling «میلیون» and Persian digits.</summary>
|
|
|
|
|
|
private static long? ExtractAmount(string text)
|
|
|
|
|
|
{
|
|
|
|
|
|
var latin = ToLatinDigits(text);
|
|
|
|
|
|
// e.g. "۲ میلیون" / "2.5 میلیون"
|
|
|
|
|
|
var million = Regex.Match(latin, @"(\d+(?:[.,]\d+)?)\s*میلیون");
|
|
|
|
|
|
if (million.Success && double.TryParse(million.Groups[1].Value.Replace(",", "."),
|
|
|
|
|
|
System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var m))
|
|
|
|
|
|
return (long)(m * 1_000_000);
|
|
|
|
|
|
|
|
|
|
|
|
// Otherwise the largest plain number that looks like money (>= 6 digits after removing separators).
|
|
|
|
|
|
long best = 0;
|
|
|
|
|
|
foreach (Match num in Regex.Matches(latin, @"[\d٬,،.]{6,}"))
|
|
|
|
|
|
{
|
|
|
|
|
|
var digits = Regex.Replace(num.Value, @"[^\d]", "");
|
|
|
|
|
|
if (digits.Length >= 6 && long.TryParse(digits, out var v) && v > best) best = v;
|
|
|
|
|
|
}
|
|
|
|
|
|
return best > 0 ? best : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string Normalize(string s) => s
|
|
|
|
|
|
.Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim();
|
|
|
|
|
|
|
|
|
|
|
|
private static bool ContainsAny(string text, params string[] needles)
|
|
|
|
|
|
=> needles.Any(n => text.Contains(n));
|
|
|
|
|
|
|
|
|
|
|
|
private static string ToLatinDigits(string s)
|
|
|
|
|
|
{
|
|
|
|
|
|
var chars = s.ToCharArray();
|
|
|
|
|
|
for (var i = 0; i < chars.Length; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (chars[i] >= '۰' && chars[i] <= '۹') chars[i] = (char)('0' + (chars[i] - '۰'));
|
|
|
|
|
|
else if (chars[i] >= '٠' && chars[i] <= '٩') chars[i] = (char)('0' + (chars[i] - '٠'));
|
|
|
|
|
|
}
|
|
|
|
|
|
return new string(chars);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|