Applicants: 1→N contact methods with types (phone/email/Instagram/Telegram/Bale/site)
- 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:
@@ -20,6 +20,7 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
public DbSet<Shift> Shifts => Set<Shift>();
|
public DbSet<Shift> Shifts => Set<Shift>();
|
||||||
public DbSet<JobOpening> JobOpenings => Set<JobOpening>();
|
public DbSet<JobOpening> JobOpenings => Set<JobOpening>();
|
||||||
public DbSet<TalentListing> TalentListings => Set<TalentListing>();
|
public DbSet<TalentListing> TalentListings => Set<TalentListing>();
|
||||||
|
public DbSet<ContactMethod> ContactMethods => Set<ContactMethod>();
|
||||||
public DbSet<Application> Applications => Set<Application>();
|
public DbSet<Application> Applications => Set<Application>();
|
||||||
public DbSet<RawListing> RawListings => Set<RawListing>();
|
public DbSet<RawListing> RawListings => Set<RawListing>();
|
||||||
public DbSet<Visitor> Visitors => Set<Visitor>();
|
public DbSet<Visitor> Visitors => Set<Visitor>();
|
||||||
@@ -155,6 +156,9 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
|
||||||
b.Entity<TalentListing>().HasIndex(t => t.Status);
|
b.Entity<TalentListing>().HasIndex(t => t.Status);
|
||||||
b.Entity<TalentListing>().HasIndex(t => new { t.CityId, t.RoleId });
|
b.Entity<TalentListing>().HasIndex(t => new { t.CityId, t.RoleId });
|
||||||
|
b.Entity<ContactMethod>()
|
||||||
|
.HasOne(c => c.TalentListing).WithMany(t => t.Contacts)
|
||||||
|
.HasForeignKey(c => c.TalentListingId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
|
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ContactMethods : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ContactMethods",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
TalentListingId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Value = table.Column<string>(type: "character varying(250)", maxLength: 250, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ContactMethods", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ContactMethods_TalentListings_TalentListingId",
|
||||||
|
column: x => x.TalentListingId,
|
||||||
|
principalTable: "TalentListings",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ContactMethods_TalentListingId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
column: "TalentListingId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ContactMethods");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,6 +285,35 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.ToTable("Cities");
|
b.ToTable("Cities");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TalentListingId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("character varying(250)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TalentListingId");
|
||||||
|
|
||||||
|
b.ToTable("ContactMethods");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1218,6 +1247,17 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Navigation("Shift");
|
b.Navigation("Shift");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("JobsMedical.Web.Models.TalentListing", "TalentListing")
|
||||||
|
.WithMany("Contacts")
|
||||||
|
.HasForeignKey("TalentListingId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("TalentListing");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||||
@@ -1500,6 +1540,11 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Navigation("Applications");
|
b.Navigation("Applications");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Contacts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Applications");
|
b.Navigation("Applications");
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One contact channel for an applicant («آماده به کار») listing. A listing can carry several —
|
||||||
|
/// e.g. three phones + an email + an Instagram page. <see cref="Value"/> holds the raw handle /
|
||||||
|
/// number / address; <see cref="Type"/> decides how it's linked (tel:, mailto:, t.me/…, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public class ContactMethod
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int TalentListingId { get; set; }
|
||||||
|
public TalentListing TalentListing { get; set; } = null!;
|
||||||
|
|
||||||
|
public ContactType Type { get; set; }
|
||||||
|
|
||||||
|
[Required, MaxLength(250)]
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -104,6 +104,20 @@ public enum IngestionMode
|
|||||||
Automatic = 1 // موارد تأییدشده (طبق آستانه/هوش مصنوعی) خودکار منتشر میشوند
|
Automatic = 1 // موارد تأییدشده (طبق آستانه/هوش مصنوعی) خودکار منتشر میشوند
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A way to reach an applicant («آماده به کار»). One listing can have several.</summary>
|
||||||
|
public enum ContactType
|
||||||
|
{
|
||||||
|
Mobile = 0, // موبایل
|
||||||
|
Phone = 1, // تلفن ثابت
|
||||||
|
Email = 2, // ایمیل
|
||||||
|
Telegram = 3, // تلگرام
|
||||||
|
Bale = 4, // بله
|
||||||
|
WhatsApp = 5, // واتساپ
|
||||||
|
Instagram = 6, // اینستاگرام
|
||||||
|
Website = 7, // وبسایت / لینک
|
||||||
|
Other = 8 // سایر
|
||||||
|
}
|
||||||
|
|
||||||
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
|
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
|
||||||
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ public class TalentListing
|
|||||||
public int? SharePercent { get; set; } // درصد/سهم درآمد مدنظر («۵۰٪ تسویه»)
|
public int? SharePercent { get; set; } // درصد/سهم درآمد مدنظر («۵۰٪ تسویه»)
|
||||||
|
|
||||||
[MaxLength(30)]
|
[MaxLength(30)]
|
||||||
public string? Phone { get; set; } // شماره تماس — مهمترین فیلد
|
public string? Phone { get; set; } // primary phone (kept for cards/back-compat)
|
||||||
|
|
||||||
|
/// <summary>All contact channels (phones, email, Instagram, Telegram, Bale, website…).</summary>
|
||||||
|
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
|
||||||
|
|
||||||
[MaxLength(2000)]
|
[MaxLength(2000)]
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ public class ReviewModel : PageModel
|
|||||||
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
|
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
|
||||||
return RedirectToPage(new { id });
|
return RedirectToPage(new { id });
|
||||||
}
|
}
|
||||||
|
// Re-parse the raw text to recover all contact channels (phones/email/socials).
|
||||||
|
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
||||||
|
var parsedContacts = _parser
|
||||||
|
.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync())
|
||||||
|
.Contacts.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
|
||||||
|
.ToList();
|
||||||
|
// Include the admin-typed phone if it isn't already captured.
|
||||||
|
if (!string.IsNullOrWhiteSpace(Phone))
|
||||||
|
{
|
||||||
|
var digits = new string(Phone.Where(char.IsDigit).ToArray());
|
||||||
|
if (!parsedContacts.Any(c => new string(c.Value.Where(char.IsDigit).ToArray()) == digits))
|
||||||
|
parsedContacts.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = Phone.Trim(), SortOrder = -1 });
|
||||||
|
}
|
||||||
var talent = new TalentListing
|
var talent = new TalentListing
|
||||||
{
|
{
|
||||||
RoleId = RoleId,
|
RoleId = RoleId,
|
||||||
@@ -153,6 +166,7 @@ public class ReviewModel : PageModel
|
|||||||
Status = ShiftStatus.Open,
|
Status = ShiftStatus.Open,
|
||||||
Source = ShiftSource.Aggregated,
|
Source = ShiftSource.Aggregated,
|
||||||
SourceUrl = Raw.SourceUrl,
|
SourceUrl = Raw.SourceUrl,
|
||||||
|
Contacts = parsedContacts,
|
||||||
};
|
};
|
||||||
_db.TalentListings.Add(talent);
|
_db.TalentListings.Add(talent);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -66,11 +66,31 @@
|
|||||||
|
|
||||||
<aside>
|
<aside>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h3 style="margin-top:0;">تماس</h3>
|
<h3 style="margin-top:0;">راههای ارتباطی</h3>
|
||||||
@if (telHref is not null)
|
@{ var contacts = (t.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).OrderBy(c => c.SortOrder).ToList(); }
|
||||||
|
@if (contacts.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var c in contacts)
|
||||||
|
{
|
||||||
|
var href = JobsMedical.Web.Services.ContactInfo.Href(c.Type, c.Value);
|
||||||
|
var label = JobsMedical.Web.Services.ContactInfo.Label(c.Type);
|
||||||
|
var icon = JobsMedical.Web.Services.ContactInfo.Icon(c.Type);
|
||||||
|
var cls = c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "btn-accent" : "btn-outline";
|
||||||
|
if (href is not null)
|
||||||
|
{
|
||||||
|
<a href="@href" class="btn @cls btn-block" dir="ltr" style="margin-bottom:6px; justify-content:space-between;" target="_blank" rel="nofollow noopener">
|
||||||
|
<span>@c.Value</span><span>@icon @label</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="muted" style="margin-bottom:6px;">@icon @label: <span dir="ltr">@c.Value</span></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (telHref is not null)
|
||||||
{
|
{
|
||||||
<a href="@telHref" class="btn btn-accent btn-block btn-lg" dir="ltr">📞 @t.Phone</a>
|
<a href="@telHref" class="btn btn-accent btn-block btn-lg" dir="ltr">📞 @t.Phone</a>
|
||||||
<p class="muted" style="font-size:12px; margin:10px 0 0;">با این فرد مستقیم تماس بگیرید.</p>
|
|
||||||
}
|
}
|
||||||
else if (isDivar)
|
else if (isDivar)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class DetailsModel : PageModel
|
|||||||
.Include(t => t.City)
|
.Include(t => t.City)
|
||||||
.Include(t => t.District)
|
.Include(t => t.District)
|
||||||
.Include(t => t.Role)
|
.Include(t => t.Role)
|
||||||
|
.Include(t => t.Contacts)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id);
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
if (Item is null) return NotFound();
|
if (Item is null) return NotFound();
|
||||||
return Page();
|
return Page();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ using JobsMedical.Web.Models;
|
|||||||
|
|
||||||
namespace JobsMedical.Web.Services;
|
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>
|
/// <summary>Structured guess extracted from a raw channel post. All fields are best-effort.</summary>
|
||||||
public class ParsedListing
|
public class ParsedListing
|
||||||
{
|
{
|
||||||
@@ -25,6 +28,7 @@ public class ParsedListing
|
|||||||
public int? YearsExperience { get; set; } // سابقه (سال)
|
public int? YearsExperience { get; set; } // سابقه (سال)
|
||||||
public bool IsLicensed { get; set; } // پروانهدار
|
public bool IsLicensed { get; set; } // پروانهدار
|
||||||
public string? AreaNote { 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)
|
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}");
|
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Phone (mobile preferred, landline as fallback) ---
|
// --- Contacts (phones, email, socials — one ad may have several) ---
|
||||||
var latinPhone = ToLatinDigits(text);
|
p.Contacts = ExtractContacts(raw ?? text);
|
||||||
var mobile = Regex.Match(latinPhone, @"(?:\+?98|0)?9\d{9}");
|
p.Phone = p.Contacts.FirstOrDefault(c => c.Type is ContactType.Mobile or ContactType.Phone)?.Value;
|
||||||
if (mobile.Success)
|
if (p.Contacts.Count > 0)
|
||||||
{
|
p.Notes.Add("راههای ارتباطی: " + string.Join("، ", p.Contacts.Select(c => ContactLabel(c.Type))));
|
||||||
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", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
@@ -304,6 +298,70 @@ public class HeuristicListingParser : IListingParser
|
|||||||
return best > 0 ? best : null;
|
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
|
private static string Normalize(string s) => s
|
||||||
.Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim();
|
.Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim();
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ public class IngestionService
|
|||||||
Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone,
|
Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone,
|
||||||
Description = raw.RawText,
|
Description = raw.RawText,
|
||||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
|
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
|
||||||
|
Contacts = BuildContacts(d, parsed), // fresh instances per listing
|
||||||
});
|
});
|
||||||
raw.Status = RawListingStatus.Normalized;
|
raw.Status = RawListingStatus.Normalized;
|
||||||
return;
|
return;
|
||||||
@@ -259,6 +260,18 @@ public class IngestionService
|
|||||||
raw.Status = RawListingStatus.Normalized;
|
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
|
private static ShiftType MapShiftType(string? ai, ShiftType? parsed) => (ai?.ToLowerInvariant()) switch
|
||||||
{
|
{
|
||||||
"day" => ShiftType.Day, "evening" => ShiftType.Evening, "night" => ShiftType.Night, "oncall" => ShiftType.OnCall,
|
"day" => ShiftType.Day, "evening" => ShiftType.Evening, "night" => ShiftType.Night, "oncall" => ShiftType.OnCall,
|
||||||
|
|||||||
Reference in New Issue
Block a user