Applicants: 1→N contact methods with types (phone/email/Instagram/Telegram/Bale/site)
CI/CD / CI · dotnet build (push) Successful in 1m32s
CI/CD / Deploy · hamkadr (push) Successful in 1m31s

- ContactMethod entity (Type + Value + SortOrder) 1→N on TalentListing (+ migration).
- Parser extracts ALL contacts: multiple phones + landlines, email, and
  socials (Instagram/Telegram/Bale/WhatsApp/website) via URLs and Persian
  keyword cues; primary Phone kept for cards.
- ContactInfo helper: per-type label/icon/clickable href (tel:/mailto:/t.me/…).
- Ingestion attaches contacts to each (fanned-out) talent listing; manual
  Review re-parses to attach them + the admin-typed phone.
- Talent details renders the full contact list as buttons; falls back to the
  single phone, then the Divar source link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 11:10:19 +03:30
parent 48760c4e83
commit e4dc5180ad
13 changed files with 1882 additions and 19 deletions
@@ -0,0 +1,52 @@
using System.Linq;
using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services;
/// <summary>Presentation helpers for <see cref="ContactType"/> — label, icon, and a clickable href.</summary>
public static class ContactInfo
{
public static string Label(ContactType t) => t switch
{
ContactType.Mobile => "موبایل",
ContactType.Phone => "تلفن ثابت",
ContactType.Email => "ایمیل",
ContactType.Telegram => "تلگرام",
ContactType.Bale => "بله",
ContactType.WhatsApp => "واتساپ",
ContactType.Instagram => "اینستاگرام",
ContactType.Website => "وب‌سایت",
_ => "تماس",
};
public static string Icon(ContactType t) => t switch
{
ContactType.Mobile or ContactType.Phone => "📞",
ContactType.Email => "✉️",
ContactType.Telegram => "📨",
ContactType.Bale => "💬",
ContactType.WhatsApp => "🟢",
ContactType.Instagram => "📷",
ContactType.Website => "🌐",
_ => "🔗",
};
/// <summary>A clickable URL for the contact (tel:/mailto:/t.me/…), or null when not linkable.</summary>
public static string? Href(ContactType t, string value)
{
var v = value.Trim();
var handle = v.TrimStart('@');
string Digits() => new(v.Where(char.IsDigit).ToArray());
return t switch
{
ContactType.Mobile or ContactType.Phone => "tel:" + Digits(),
ContactType.Email => "mailto:" + v,
ContactType.Telegram => "https://t.me/" + handle,
ContactType.Bale => "https://ble.ir/" + handle,
ContactType.WhatsApp => "https://wa.me/" + Digits(),
ContactType.Instagram => "https://instagram.com/" + handle,
ContactType.Website => v.StartsWith("http") ? v : "https://" + v,
_ => null,
};
}
}
+73 -15
View File
@@ -3,6 +3,9 @@ using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services;
/// <summary>One contact channel pulled from a post (type + raw value).</summary>
public record ParsedContact(ContactType Type, string Value);
/// <summary>Structured guess extracted from a raw channel post. All fields are best-effort.</summary>
public class ParsedListing
{
@@ -25,6 +28,7 @@ public class ParsedListing
public int? YearsExperience { get; set; } // سابقه (سال)
public bool IsLicensed { get; set; } // پروانه‌دار
public string? AreaNote { get; set; } // «فقط منطقه ۱»
public List<ParsedContact> Contacts { get; set; } = new(); // phones, email, socials…
public List<string> Notes { get; set; } = new(); // what was/wasn't detected (shown to admin)
}
@@ -166,21 +170,11 @@ public class HeuristicListingParser : IListingParser
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}");
}
// --- Phone (mobile preferred, landline as fallback) ---
var latinPhone = ToLatinDigits(text);
var mobile = Regex.Match(latinPhone, @"(?:\+?98|0)?9\d{9}");
if (mobile.Success)
{
var d = Regex.Replace(mobile.Value, @"\D", "");
if (d.StartsWith("98")) d = "0" + d[2..];
if (d.Length == 10 && d.StartsWith("9")) d = "0" + d;
p.Phone = d;
}
else
{
var land = Regex.Match(latinPhone, @"0\d{2,3}[\s-]?\d{7,8}");
if (land.Success) p.Phone = Regex.Replace(land.Value, @"\D", "");
}
// --- Contacts (phones, email, socials — one ad may have several) ---
p.Contacts = ExtractContacts(raw ?? text);
p.Phone = p.Contacts.FirstOrDefault(c => c.Type is ContactType.Mobile or ContactType.Phone)?.Value;
if (p.Contacts.Count > 0)
p.Notes.Add("راه‌های ارتباطی: " + string.Join("، ", p.Contacts.Select(c => ContactLabel(c.Type))));
return p;
}
@@ -304,6 +298,70 @@ public class HeuristicListingParser : IListingParser
return best > 0 ? best : null;
}
private static readonly Regex EmailRx = new(@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", RegexOptions.Compiled);
private static readonly Regex UrlRx = new(@"https?://[^\s]+", RegexOptions.Compiled);
private static string ContactLabel(ContactType t) => ContactInfo.Label(t);
/// <summary>Pull every contact channel out of a post: phones, email, and socials (Instagram /
/// Telegram / Bale / WhatsApp / website) via URLs and Persian keyword cues.</summary>
private static List<ParsedContact> ExtractContacts(string raw)
{
var latin = ToLatinDigits(raw);
var list = new List<ParsedContact>();
void Add(ContactType t, string v)
{
v = v.Trim().Trim('.', '،', ',', ')', '(', ':', '«', '»', '"', '/').Trim();
if (v.Length < 2) return;
if (!list.Any(c => c.Type == t && string.Equals(c.Value, v, StringComparison.OrdinalIgnoreCase)))
list.Add(new ParsedContact(t, v));
}
foreach (Match m in EmailRx.Matches(latin)) Add(ContactType.Email, m.Value);
foreach (Match m in UrlRx.Matches(latin))
{
var u = m.Value.TrimEnd('.', '،', ')', '(', '"');
var low = u.ToLowerInvariant();
if (low.Contains("instagram.com") || low.Contains("instagr.am")) Add(ContactType.Instagram, UrlHandle(u));
else if (low.Contains("t.me") || low.Contains("telegram.me")) Add(ContactType.Telegram, UrlHandle(u));
else if (low.Contains("ble.ir") || low.Contains("bale.ai")) Add(ContactType.Bale, UrlHandle(u));
else if (low.Contains("wa.me") || low.Contains("whatsapp")) Add(ContactType.WhatsApp, UrlHandle(u));
else Add(ContactType.Website, u);
}
// Persian keyword → handle (latin handles only, so Persian words after the cue don't match).
void Keyed(ContactType t, params string[] kws)
{
foreach (var kw in kws)
foreach (Match m in Regex.Matches(latin, kw + @"\s*[:]?\s*@?([A-Za-z0-9_.]{3,30})"))
Add(t, m.Groups[1].Value);
}
Keyed(ContactType.Instagram, "اینستاگرام", "اینستگرام", "اینستا", "پیج");
Keyed(ContactType.Telegram, "تلگرام");
Keyed(ContactType.WhatsApp, "واتساپ", "واتس اپ");
// phones — mobiles then landlines (multiple), boundary-guarded.
foreach (Match m in Regex.Matches(latin, @"(?<!\d)(?:\+?98|0)?9\d{9}(?!\d)"))
{
var d = Regex.Replace(m.Value, @"\D", "");
if (d.StartsWith("98")) d = "0" + d[2..];
if (d.Length == 10 && d[0] == '9') d = "0" + d;
Add(ContactType.Mobile, d);
}
foreach (Match m in Regex.Matches(latin, @"(?<!\d)0\d{2,3}[\s-]?\d{7,8}(?!\d)"))
Add(ContactType.Phone, Regex.Replace(m.Value, @"\D", ""));
return list.Take(8).ToList();
}
private static string UrlHandle(string url)
{
var u = url.Split('?')[0].TrimEnd('/');
var seg = u.Contains('/') ? u[(u.LastIndexOf('/') + 1)..] : u;
return string.IsNullOrWhiteSpace(seg) ? url : seg;
}
private static string Normalize(string s) => s
.Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim();
@@ -204,6 +204,7 @@ public class IngestionService
Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone,
Description = raw.RawText,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
Contacts = BuildContacts(d, parsed), // fresh instances per listing
});
raw.Status = RawListingStatus.Normalized;
return;
@@ -259,6 +260,18 @@ public class IngestionService
raw.Status = RawListingStatus.Normalized;
}
/// <summary>Fresh ContactMethod rows for one talent listing (parser contacts + AI phone).</summary>
private static List<ContactMethod> BuildContacts(AiStructured? d, ParsedListing parsed)
{
var contacts = parsed.Contacts
.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
.ToList();
if (!string.IsNullOrWhiteSpace(d?.Phone)
&& !contacts.Any(c => c.Type is ContactType.Mobile or ContactType.Phone))
contacts.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = d!.Phone!.Trim(), SortOrder = -1 });
return contacts;
}
private static ShiftType MapShiftType(string? ai, ShiftType? parsed) => (ai?.ToLowerInvariant()) switch
{
"day" => ShiftType.Day, "evening" => ShiftType.Evening, "night" => ShiftType.Night, "oncall" => ShiftType.OnCall,