Applicants: auto-tags + deep search w/ highlight; never delete (archive instead)
- Tags: parser extracts cert/skill keywords (mmt, ICU/CCU, دیالیز, اتاق عمل, اورژانس, مسئول فنی, پروانهدار…) + role + city into TalentListing.Tags (+ migration); shown as chips on cards. - Deep search on /Talent: «جستجوی عمیق» box does Postgres ILIKE across tags, description, person, area, role, city (every term must match); matches are highlighted with <mark> via SearchHighlight. - Never delete: ShiftStatus.Archived + the admin «بایگانی گروهی» action now ARCHIVES aggregated posts (hidden from site, kept in DB) and leaves the raw crawl rows intact — a permanent archive for future analytics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class TalentTags : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Tags",
|
||||||
|
table: "TalentListings",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Tags",
|
||||||
|
table: "TalentListings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1032,6 +1032,10 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Tags")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
b.Property<int?>("YearsExperience")
|
b.Property<int?>("YearsExperience")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ public enum ShiftStatus
|
|||||||
Open = 0, // باز
|
Open = 0, // باز
|
||||||
Filled = 1, // پر شده
|
Filled = 1, // پر شده
|
||||||
Expired = 2, // منقضی
|
Expired = 2, // منقضی
|
||||||
Cancelled = 3 // لغو شده
|
Cancelled = 3, // لغو شده
|
||||||
|
Archived = 4 // بایگانیشده (پنهان از سایت، نگهداری برای تحلیل)
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ShiftSource
|
public enum ShiftSource
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public class TalentListing
|
|||||||
[MaxLength(150)]
|
[MaxLength(150)]
|
||||||
public string? AreaNote { get; set; } // «فقط منطقه ۱» وقتی محله دقیق نگاشت نشد
|
public string? AreaNote { get; set; } // «فقط منطقه ۱» وقتی محله دقیق نگاشت نشد
|
||||||
|
|
||||||
|
/// <summary>Searchable keyword tags (space-separated): certs/skills (mmt, icu…), پروانهدار,
|
||||||
|
/// role, city. Drives deep search + tag chips.</summary>
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
public EmploymentType? Availability { get; set; } // تماموقت/پارهوقت/قراردادی...
|
public EmploymentType? Availability { get; set; } // تماموقت/پارهوقت/قراردادی...
|
||||||
public Gender Gender { get; set; } = Gender.Any; // جنسیت فرد
|
public Gender Gender { get; set; } = Gender.Any; // جنسیت فرد
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,13 @@
|
|||||||
@{ int publishedCount = Model.Counts.GetValueOrDefault(JobsMedical.Web.Models.RawListingStatus.Normalized); }
|
@{ int publishedCount = Model.Counts.GetValueOrDefault(JobsMedical.Web.Models.RawListingStatus.Normalized); }
|
||||||
@if (publishedCount > 0)
|
@if (publishedCount > 0)
|
||||||
{
|
{
|
||||||
<form method="post" asp-page-handler="DeletePublished"
|
<form method="post" asp-page-handler="ArchivePublished"
|
||||||
onsubmit="return confirm('همه آگهیهای منتشرشده از جمعآوری (شیفت/استخدام/آمادهبهکار) و آیتمهای تأییدشدهی متناظر حذف میشوند. این کار بازگشتناپذیر است. ادامه میدهی؟');"
|
onsubmit="return confirm('همه آگهیهای منتشرشده از جمعآوری از سایت پنهان (بایگانی) میشوند. دادهها حذف نمیشوند و برای تحلیل باقی میمانند. ادامه میدهی؟');"
|
||||||
style="margin-bottom:14px;">
|
style="margin-bottom:14px;">
|
||||||
<button type="submit" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">
|
<button type="submit" class="btn btn-outline">
|
||||||
🗑 حذف گروهی همهی منتشرشدهها (@JalaliDate.ToPersianDigits(publishedCount.ToString()))
|
🗄 بایگانی گروهی همهی منتشرشدهها (@JalaliDate.ToPersianDigits(publishedCount.ToString()))
|
||||||
</button>
|
</button>
|
||||||
<span class="muted" style="font-size:12px; margin-inline-start:8px;">آگهیهای منتشرشده روی سایت را که از جمعآوری ساخته شدهاند یکجا حذف میکند.</span>
|
<span class="muted" style="font-size:12px; margin-inline-start:8px;">از سایت پنهان میکند ولی هیچچیز حذف نمیشود (آرشیو برای تحلیل).</span>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,21 +47,24 @@ public class IngestedModel : PageModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bulk-delete everything that was published from ingestion: the aggregated Shift/Job/Talent
|
/// ARCHIVE (never delete) everything published from ingestion: the aggregated Shift/Job/Talent
|
||||||
/// posts on the site AND the approved (Normalized) raw items that produced them. Done in a
|
/// posts are flipped to Archived (hidden from the site but kept for analytics); the raw crawl
|
||||||
/// transaction; the linked raw rows are removed first since they hold FKs to the posts.
|
/// rows are retained untouched as the permanent archive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> OnPostDeletePublishedAsync()
|
public async Task<IActionResult> OnPostArchivePublishedAsync()
|
||||||
{
|
{
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
var shifts = await _db.Shifts
|
||||||
var raws = await _db.RawListings.Where(r => r.Status == RawListingStatus.Normalized).ExecuteDeleteAsync();
|
.Where(s => s.Source == ShiftSource.Aggregated && s.Status != ShiftStatus.Archived)
|
||||||
var shifts = await _db.Shifts.Where(s => s.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
|
.ExecuteUpdateAsync(u => u.SetProperty(s => s.Status, ShiftStatus.Archived));
|
||||||
var jobs = await _db.JobOpenings.Where(j => j.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
|
var jobs = await _db.JobOpenings
|
||||||
var talent = await _db.TalentListings.Where(t => t.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
|
.Where(j => j.Source == ShiftSource.Aggregated && j.Status != ShiftStatus.Archived)
|
||||||
await tx.CommitAsync();
|
.ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Archived));
|
||||||
|
var talent = await _db.TalentListings
|
||||||
|
.Where(t => t.Source == ShiftSource.Aggregated && t.Status != ShiftStatus.Archived)
|
||||||
|
.ExecuteUpdateAsync(u => u.SetProperty(t => t.Status, ShiftStatus.Archived));
|
||||||
|
|
||||||
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
||||||
Message = $"حذف شد: {P(shifts)} شیفت، {P(jobs)} استخدام، {P(talent)} آمادهبهکار و {P(raws)} آیتم جمعآوری.";
|
Message = $"بایگانی شد (از سایت پنهان، در پایگاهداده نگهداری شد): {P(shifts)} شیفت، {P(jobs)} استخدام، {P(talent)} آمادهبهکار.";
|
||||||
return RedirectToPage(new { Status });
|
return RedirectToPage(new { Status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,11 +134,11 @@ 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).
|
// Re-parse the raw text to recover all contact channels (phones/email/socials) + tags.
|
||||||
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
||||||
var parsedContacts = _parser
|
var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
|
||||||
.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync())
|
var parsedContacts = reparsed.Contacts
|
||||||
.Contacts.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
|
.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
|
||||||
.ToList();
|
.ToList();
|
||||||
// Include the admin-typed phone if it isn't already captured.
|
// Include the admin-typed phone if it isn't already captured.
|
||||||
if (!string.IsNullOrWhiteSpace(Phone))
|
if (!string.IsNullOrWhiteSpace(Phone))
|
||||||
@@ -167,6 +167,7 @@ public class ReviewModel : PageModel
|
|||||||
Source = ShiftSource.Aggregated,
|
Source = ShiftSource.Aggregated,
|
||||||
SourceUrl = Raw.SourceUrl,
|
SourceUrl = Raw.SourceUrl,
|
||||||
Contacts = parsedContacts,
|
Contacts = parsedContacts,
|
||||||
|
Tags = string.Join(" ", reparsed.Tags.Distinct()),
|
||||||
};
|
};
|
||||||
_db.TalentListings.Add(talent);
|
_db.TalentListings.Add(talent);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
? (Model.Role?.Name ?? "آماده به کار")
|
? (Model.Role?.Name ?? "آماده به کار")
|
||||||
: Model.PersonName!;
|
: Model.PersonName!;
|
||||||
var area = Model.District?.Name ?? Model.AreaNote;
|
var area = Model.District?.Name ?? Model.AreaNote;
|
||||||
|
var q = ViewData["q"] as string;
|
||||||
|
var tags = (Model.Tags ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Where(t => t != Model.Role?.Name && t != Model.City?.Name).Distinct().Take(6).ToList();
|
||||||
}
|
}
|
||||||
<a class="card card-pad shift-card" asp-page="/Talent/Details" asp-route-id="@Model.Id">
|
<a class="card card-pad shift-card" asp-page="/Talent/Details" asp-route-id="@Model.Id">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<span class="facility">@heading</span>
|
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(heading, q)</span>
|
||||||
<span class="badge badge-talent">آماده به کار</span>
|
<span class="badge badge-talent">آماده به کار</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -37,6 +40,15 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
|
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
|
||||||
|
@if (tags.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="tag-chips">
|
||||||
|
@foreach (var tg in tags)
|
||||||
|
{
|
||||||
|
<span class="tag-chip">@JobsMedical.Web.Services.SearchHighlight.Mark(tg, q)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@comp</span>
|
<span class="pay">@comp</span>
|
||||||
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
|
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "آماده به کار — کادر درمان";
|
ViewData["Title"] = "آماده به کار — کادر درمان";
|
||||||
ViewData["Description"] = "فهرست کادر درمان آماده همکاری (پزشک، پرستار، ماما، تکنسین و…) در تهران و سایر شهرها — مرکز درمانی میتواند مستقیم تماس بگیرد.";
|
ViewData["Description"] = "فهرست کادر درمان آماده همکاری (پزشک، پرستار، ماما، تکنسین و…) در تهران و سایر شهرها — مرکز درمانی میتواند مستقیم تماس بگیرد.";
|
||||||
|
ViewData["q"] = Model.Q; // drives result highlighting in cards
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
@@ -20,6 +21,14 @@
|
|||||||
<aside class="card card-pad filter-card">
|
<aside class="card card-pad filter-card">
|
||||||
<h3>فیلترها</h3>
|
<h3>فیلترها</h3>
|
||||||
<form method="get" id="filterForm">
|
<form method="get" id="filterForm">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>جستجوی عمیق</label>
|
||||||
|
<div style="display:flex; gap:6px;">
|
||||||
|
<input type="search" name="Q" value="@Model.Q" placeholder="مثلاً mmt پروانهدار تهران" style="flex:1;" />
|
||||||
|
<button type="submit" class="btn btn-accent" style="padding:0 14px;">🔎</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="font-size:11px; margin:4px 0 0;">روی متن، تگها، نقش، شهر و نام جستجو میکند.</p>
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label>شهر</label>
|
<label>شهر</label>
|
||||||
<select name="CityId" onchange="this.form.submit()">
|
<select name="CityId" onchange="this.form.submit()">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class IndexModel : PageModel
|
|||||||
[BindProperty(SupportsGet = true)] public int? DistrictId { get; set; }
|
[BindProperty(SupportsGet = true)] public int? DistrictId { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
|
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
|
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public string? Q { get; set; } // deep search
|
||||||
|
|
||||||
public List<TalentListing> Results { get; private set; } = new();
|
public List<TalentListing> Results { get; private set; } = new();
|
||||||
public List<City> Cities { get; private set; } = new();
|
public List<City> Cities { get; private set; } = new();
|
||||||
@@ -42,6 +43,20 @@ public class IndexModel : PageModel
|
|||||||
if (GenderFilter is Gender g && g != Gender.Any)
|
if (GenderFilter is Gender g && g != Gender.Any)
|
||||||
q = q.Where(t => t.Gender == Gender.Any || t.Gender == g);
|
q = q.Where(t => t.Gender == Gender.Any || t.Gender == g);
|
||||||
|
|
||||||
|
// Deep search: every term must match somewhere (tags, role, city, person, area, description).
|
||||||
|
if (!string.IsNullOrWhiteSpace(Q))
|
||||||
|
foreach (var term in Q.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var like = $"%{term}%";
|
||||||
|
q = q.Where(t =>
|
||||||
|
EF.Functions.ILike(t.Tags ?? "", like) ||
|
||||||
|
EF.Functions.ILike(t.Description ?? "", like) ||
|
||||||
|
EF.Functions.ILike(t.PersonName ?? "", like) ||
|
||||||
|
EF.Functions.ILike(t.AreaNote ?? "", like) ||
|
||||||
|
EF.Functions.ILike(t.Role.Name, like) ||
|
||||||
|
EF.Functions.ILike(t.City.Name, like));
|
||||||
|
}
|
||||||
|
|
||||||
Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync();
|
Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class ParsedListing
|
|||||||
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<ParsedContact> Contacts { get; set; } = new(); // phones, email, socials…
|
||||||
|
public List<string> Tags { get; set; } = new(); // cert/skill keywords for search
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +171,12 @@ 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}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tags (certs/skills for deep search): mmt, icu, پروانهدار, اتاق عمل … ---
|
||||||
|
p.Tags = ExtractTags(text);
|
||||||
|
if (p.RoleNames.Count > 0) p.Tags.AddRange(p.RoleNames);
|
||||||
|
if (p.IsLicensed && !p.Tags.Contains("پروانهدار")) p.Tags.Add("پروانهدار");
|
||||||
|
p.Tags = p.Tags.Distinct().ToList();
|
||||||
|
|
||||||
// --- Contacts (phones, email, socials — one ad may have several) ---
|
// --- Contacts (phones, email, socials — one ad may have several) ---
|
||||||
p.Contacts = ExtractContacts(raw ?? text);
|
p.Contacts = ExtractContacts(raw ?? text);
|
||||||
p.Phone = p.Contacts.FirstOrDefault(c => c.Type is ContactType.Mobile or ContactType.Phone)?.Value;
|
p.Phone = p.Contacts.FirstOrDefault(c => c.Type is ContactType.Mobile or ContactType.Phone)?.Value;
|
||||||
@@ -355,6 +362,40 @@ public class HeuristicListingParser : IListingParser
|
|||||||
return list.Take(8).ToList();
|
return list.Take(8).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical tag → trigger words found in the post.
|
||||||
|
private static readonly (string Tag, string[] Needles)[] TagDict =
|
||||||
|
{
|
||||||
|
("mmt", new[] { "mmt", "ام ام تی", "امامتی" }),
|
||||||
|
("ICU", new[] { "icu", "آی سی یو", "آیسییو" }),
|
||||||
|
("CCU", new[] { "ccu", "سی سی یو", "سیسییو" }),
|
||||||
|
("NICU", new[] { "nicu", "ان آی سی یو", "نوزادان" }),
|
||||||
|
("BLS", new[] { "bls" }),
|
||||||
|
("ACLS", new[] { "acls" }),
|
||||||
|
("دیالیز", new[] { "دیالیز" }),
|
||||||
|
("اتاق عمل", new[] { "اتاق عمل", "اسکراب" }),
|
||||||
|
("بیهوشی", new[] { "بیهوشی" }),
|
||||||
|
("تریاژ", new[] { "تریاژ" }),
|
||||||
|
("تزریقات", new[] { "تزریقات", "تزریق" }),
|
||||||
|
("پانسمان", new[] { "پانسمان", "زخم" }),
|
||||||
|
("سونوگرافی", new[] { "سونوگرافی" }),
|
||||||
|
("رادیولوژی", new[] { "رادیولوژی" }),
|
||||||
|
("اورژانس", new[] { "اورژانس", "فوریت" }),
|
||||||
|
("مسئول فنی", new[] { "مسئول فنی" }),
|
||||||
|
("طرح", new[] { "طرح" }),
|
||||||
|
("سالمند", new[] { "سالمند" }),
|
||||||
|
("کودک", new[] { "کودک", "اطفال" }),
|
||||||
|
("همراه بیمار", new[] { "همراه بیمار" }),
|
||||||
|
("پروانهدار", new[] { "پروانه" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static List<string> ExtractTags(string text)
|
||||||
|
{
|
||||||
|
var tags = new List<string>();
|
||||||
|
foreach (var (tag, needles) in TagDict)
|
||||||
|
if (ContainsAny(text, needles)) tags.Add(tag);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
private static string UrlHandle(string url)
|
private static string UrlHandle(string url)
|
||||||
{
|
{
|
||||||
var u = url.Split('?')[0].TrimEnd('/');
|
var u = url.Split('?')[0].TrimEnd('/');
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ public class IngestionService
|
|||||||
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
|
Contacts = BuildContacts(d, parsed), // fresh instances per listing
|
||||||
|
Tags = BuildTags(parsed, role, city),
|
||||||
});
|
});
|
||||||
raw.Status = RawListingStatus.Normalized;
|
raw.Status = RawListingStatus.Normalized;
|
||||||
return;
|
return;
|
||||||
@@ -260,6 +261,13 @@ public class IngestionService
|
|||||||
raw.Status = RawListingStatus.Normalized;
|
raw.Status = RawListingStatus.Normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Space-separated searchable tags: parsed cert/skill tags + this listing's role + city.</summary>
|
||||||
|
private static string BuildTags(ParsedListing parsed, Role role, City city)
|
||||||
|
{
|
||||||
|
var tags = new List<string>(parsed.Tags) { role.Name, city.Name };
|
||||||
|
return string.Join(" ", tags.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Fresh ContactMethod rows for one talent listing (parser contacts + AI phone).</summary>
|
/// <summary>Fresh ContactMethod rows for one talent listing (parser contacts + AI phone).</summary>
|
||||||
private static List<ContactMethod> BuildContacts(AiStructured? d, ParsedListing parsed)
|
private static List<ContactMethod> BuildContacts(AiStructured? d, ParsedListing parsed)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.AspNetCore.Html;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>Wraps query terms in <mark> for result highlighting (HTML-safe).</summary>
|
||||||
|
public static class SearchHighlight
|
||||||
|
{
|
||||||
|
public static HtmlString Mark(string? text, string? query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return HtmlString.Empty;
|
||||||
|
var encoded = WebUtility.HtmlEncode(text);
|
||||||
|
if (string.IsNullOrWhiteSpace(query)) return new HtmlString(encoded);
|
||||||
|
|
||||||
|
var terms = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(t => t.Length >= 2)
|
||||||
|
.Select(t => Regex.Escape(WebUtility.HtmlEncode(t)))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (terms.Count == 0) return new HtmlString(encoded);
|
||||||
|
|
||||||
|
var pattern = string.Join("|", terms);
|
||||||
|
var marked = Regex.Replace(encoded, pattern, m => $"<mark>{m.Value}</mark>", RegexOptions.IgnoreCase);
|
||||||
|
return new HtmlString(marked);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -268,6 +268,10 @@ label { font-size: 13px; }
|
|||||||
.badge-distance { background: #fff1e8; color: #c5670f; }
|
.badge-distance { background: #fff1e8; color: #c5670f; }
|
||||||
.badge-job { background: #eaf3ff; color: #2563eb; }
|
.badge-job { background: #eaf3ff; color: #2563eb; }
|
||||||
.badge-talent { background: #fdece8; color: var(--accent, #e25c43); }
|
.badge-talent { background: #fdece8; color: var(--accent, #e25c43); }
|
||||||
|
.tag-chips { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 2px; }
|
||||||
|
.tag-chip { font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 999px;
|
||||||
|
background: var(--primary-soft); color: var(--primary-dark); }
|
||||||
|
mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px; }
|
||||||
.badge-gender { background: #f3eefb; color: #6b3fa0; }
|
.badge-gender { background: #f3eefb; color: #6b3fa0; }
|
||||||
|
|
||||||
/* ---------- Filters layout ---------- */
|
/* ---------- Filters layout ---------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user