[Demo] Add admin demo-mode toggle + generic website ingest source
CI/CD / CI · dotnet build (push) Failing after 1m40s
CI/CD / Deploy · hamkadr (push) Has been skipped

- AppSetting: DemoMode, WebsitesEnabled, WebsiteUrls
- Facility.IsDemo flag; SeedData split into SeedReferenceAsync (always)
  + SeedDemoAsync/ClearDemoAsync (idempotent, toggleable at runtime)
- WebsiteListingSource: scrape any admin-configured URL (og:title + content)
- Admin Settings: seed/clear demo card, demo-mode checkbox, website source
  fields; Program.cs seeds demo when DemoMode on (or in Development)
- EF migration DemoModeAndWebsites

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 13:43:07 +03:30
parent eae38373b9
commit 0c0449c2b9
11 changed files with 1341 additions and 149 deletions
+57 -146
View File
@@ -5,100 +5,71 @@ namespace JobsMedical.Web.Data;
/// <summary>
/// Seeds reference data (cities, roles, districts) always, and a believable Tehran demo board
/// (facilities/shifts/jobs/raw listings) only when <paramref name="includeDemo"/> is true.
/// In production we pass false so real employers populate listings — no fake data goes public.
/// Idempotent: reference seeds only when empty; demo seeds only when no facilities exist.
/// (facilities/shifts/jobs/raw listings, marked <see cref="Facility.IsDemo"/>) when demo mode is on.
/// Demo seed/clear is idempotent and can be toggled at runtime from the admin panel.
/// </summary>
public static class SeedData
{
public static async Task EnsureSeededAsync(AppDbContext db, bool includeDemo = true)
{
await SeedReferenceAsync(db);
if (includeDemo) await SeedDemoAsync(db);
}
// ---------- Reference data (always) ----------
public static async Task SeedReferenceAsync(AppDbContext db)
{
if (await db.Cities.AnyAsync()) return;
var tehran = new City { Name = "تهران", Province = "تهران", IsActive = true };
var cities = new[]
{
tehran,
db.Cities.AddRange(tehran,
new City { Name = "کرج", Province = "البرز", IsActive = false },
new City { Name = "مشهد", Province = "خراسان رضوی", IsActive = false },
new City { Name = "اصفهان", Province = "اصفهان", IsActive = false },
new City { Name = "شیراز", Province = "فارس", IsActive = false },
};
db.Cities.AddRange(cities);
await db.SaveChangesAsync(); // need tehran.Id before districts reference it
new City { Name = "شیراز", Province = "فارس", IsActive = false });
await db.SaveChangesAsync();
var roles = new[]
{
db.Roles.AddRange(
new Role { Name = "پزشک عمومی", Category = "پزشک", SortOrder = 1 },
new Role { Name = "پزشک متخصص", Category = "پزشک", SortOrder = 2 },
new Role { Name = "پرستار", Category = "پرستار", SortOrder = 3 },
new Role { Name = "ماما", Category = "ماما", SortOrder = 4 },
new Role { Name = "تکنسین اتاق عمل", Category = "تکنسین", SortOrder = 5 },
new Role { Name = "تکنسین فوریت‌های پزشکی", Category = "تکنسین", SortOrder = 6 },
new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 },
};
db.Roles.AddRange(roles);
new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 });
// Tehran neighborhoods (محله/منطقه) for the within-city filter.
var saadatAbad = new District { Name = "سعادت‌آباد", CityId = tehran.Id };
var shahrakGharb = new District { Name = "شهرک غرب", CityId = tehran.Id };
var valiasr = new District { Name = "ولیعصر / پارک‌وی", CityId = tehran.Id };
var narmak = new District { Name = "نارمک", CityId = tehran.Id };
var tehranpars = new District { Name = "تهرانپارس", CityId = tehran.Id };
var gisha = new District { Name = "گیشا / برج میلاد", CityId = tehran.Id };
db.Districts.AddRange(saadatAbad, shahrakGharb, valiasr, narmak, tehranpars, gisha,
new District { Name = "ونک", CityId = tehran.Id },
new District { Name = "تجریش", CityId = tehran.Id });
foreach (var n in new[] { "سعادت‌آباد", "شهرک غرب", "ولیعصر / پارک‌وی", "نارمک",
"تهرانپارس", "گیشا / برج میلاد", "ونک", "تجریش" })
db.Districts.Add(new District { Name = n, CityId = tehran.Id });
await db.SaveChangesAsync();
}
// ----- Demo data (Tehran sample board): development only -----
if (!includeDemo) return;
// ---------- Demo board (toggleable) ----------
public static async Task<int> SeedDemoAsync(AppDbContext db)
{
if (await db.Facilities.AnyAsync(f => f.IsDemo)) return 0; // already seeded
var tehran = await db.Cities.FirstAsync(c => c.Name == "تهران");
var roles = await db.Roles.OrderBy(r => r.SortOrder).ToListAsync();
if (roles.Count < 7) return 0;
var districts = await db.Districts.Where(d => d.CityId == tehran.Id).ToListAsync();
int Dist(string n) => districts.FirstOrDefault(d => d.Name == n)?.Id ?? districts.First().Id;
var facilities = new[]
{
new Facility { Name = "بیمارستان میلاد", Type = FacilityType.Hospital, CityId = tehran.Id,
DistrictId = gisha.Id,
Address = "تهران، بزرگراه همت، روبه‌روی برج میلاد", Phone = "021-82032000",
Lat = 35.7448, Lng = 51.3753, IsVerified = true },
new Facility { Name = "بیمارستان دی", Type = FacilityType.Hospital, CityId = tehran.Id,
DistrictId = valiasr.Id,
Address = "تهران، خیابان ولیعصر، بالاتر از پارک‌وی", Phone = "021-23601",
Lat = 35.7986, Lng = 51.4087, IsVerified = true },
new Facility { Name = "کلینیک تخصصی پارسیان", Type = FacilityType.Clinic, CityId = tehran.Id,
DistrictId = saadatAbad.Id,
Address = "تهران، سعادت‌آباد، میدان کاج", Phone = "021-22360000",
Lat = 35.7872, Lng = 51.3760, IsVerified = false },
new Facility { Name = "درمانگاه شبانه‌روزی البرز", Type = FacilityType.Polyclinic, CityId = tehran.Id,
DistrictId = narmak.Id,
Address = "تهران، نارمک، میدان هلال احمر", Phone = "021-77900000",
Lat = 35.7448, Lng = 51.5085, IsVerified = true },
new Facility { Name = "بیمارستان آتیه", Type = FacilityType.Hospital, CityId = tehran.Id,
DistrictId = shahrakGharb.Id,
Address = "تهران، شهرک غرب، بلوار فرحزادی", Phone = "021-82721",
Lat = 35.7570, Lng = 51.3680, IsVerified = true },
new Facility { Name = "کلینیک درمانی مهر", Type = FacilityType.Clinic, CityId = tehran.Id,
DistrictId = tehranpars.Id,
Address = "تهران، تهرانپارس، فلکه دوم", Phone = "021-77700000",
Lat = 35.7350, Lng = 51.5400, IsVerified = false },
new Facility { Name = "بیمارستان میلاد", Type = FacilityType.Hospital, CityId = tehran.Id, DistrictId = Dist("گیشا / برج میلاد"), Address = "تهران، بزرگراه همت، روبه‌روی برج میلاد", Phone = "021-82032000", Lat = 35.7448, Lng = 51.3753, IsVerified = true, IsDemo = true },
new Facility { Name = "بیمارستان دی", Type = FacilityType.Hospital, CityId = tehran.Id, DistrictId = Dist("ولیعصر / پارک‌وی"), Address = "تهران، خیابان ولیعصر، بالاتر از پارک‌وی", Phone = "021-23601", Lat = 35.7986, Lng = 51.4087, IsVerified = true, IsDemo = true },
new Facility { Name = "کلینیک تخصصی پارسیان", Type = FacilityType.Clinic, CityId = tehran.Id, DistrictId = Dist("سعادت‌آباد"), Address = "تهران، سعادت‌آباد، میدان کاج", Phone = "021-22360000", Lat = 35.7872, Lng = 51.3760, IsVerified = false, IsDemo = true },
new Facility { Name = "درمانگاه شبانه‌روزی البرز", Type = FacilityType.Polyclinic, CityId = tehran.Id, DistrictId = Dist("نارمک"), Address = "تهران، نارمک، میدان هلال احمر", Phone = "021-77900000", Lat = 35.7448, Lng = 51.5085, IsVerified = true, IsDemo = true },
new Facility { Name = "بیمارستان آتیه", Type = FacilityType.Hospital, CityId = tehran.Id, DistrictId = Dist("شهرک غرب"), Address = "تهران، شهرک غرب، بلوار فرحزادی", Phone = "021-82721", Lat = 35.7570, Lng = 51.3680, IsVerified = true, IsDemo = true },
new Facility { Name = "کلینیک درمانی مهر", Type = FacilityType.Clinic, CityId = tehran.Id, DistrictId = Dist("تهرانپارس"), Address = "تهران، تهرانپارس، فلکه دوم", Phone = "021-77700000", Lat = 35.7350, Lng = 51.5400, IsVerified = false, IsDemo = true },
};
db.Facilities.AddRange(facilities);
await db.SaveChangesAsync();
// Build ~2 weeks of shifts starting today, a few per facility per day, across roles.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var rng = new Random(20260602); // deterministic seed for reproducible sample data
var shifts = new List<Shift>();
// Weighted role pool — GP and nurse most common, others sprinkled in.
var rolePool = new[]
{
roles[0], roles[0], roles[0], // پزشک عمومی (most common)
roles[2], roles[2], // پرستار
roles[1], // پزشک متخصص
roles[3], // ماما
roles[4], roles[5], roles[6], // تکنسین‌ها
};
var rng = new Random(20260602);
var rolePool = new[] { roles[0], roles[0], roles[0], roles[2], roles[2], roles[1], roles[3], roles[4], roles[5], roles[6] };
var templates = new[]
{
(ShiftType.Day, new TimeOnly(8, 0), new TimeOnly(14, 0), "شیفت صبح", 1_500_000L),
@@ -106,108 +77,48 @@ public static class SeedData
(ShiftType.Night, new TimeOnly(20, 0), new TimeOnly(8, 0), "شیفت شب", 2_500_000L),
(ShiftType.OnCall, new TimeOnly(8, 0), new TimeOnly(8, 0), "آنکال", 0L),
};
var shifts = new List<Shift>();
foreach (var f in facilities)
{
for (var d = 0; d < 14; d++)
{
var date = today.AddDays(d);
var count = rng.Next(0, 3); // 02 shifts per facility per day
for (var i = 0; i < count; i++)
for (var i = 0; i < rng.Next(0, 3); i++)
{
var t = templates[rng.Next(templates.Length)];
var role = rolePool[rng.Next(rolePool.Length)];
// Vary the compensation model: fixed, profit-share, both (choose), or negotiable.
var payType = PayType.PerShift;
long? amount = t.Item5;
int? share = null;
var payType = PayType.PerShift; long? amount = t.Item5; int? share = null;
if (t.Item1 == ShiftType.OnCall) { payType = PayType.Negotiable; amount = null; }
else
else switch (rng.Next(0, 5))
{
switch (rng.Next(0, 5))
{
case 0: payType = PayType.Negotiable; amount = null; break; // توافقی
case 1: payType = PayType.Percentage; amount = null; // درصدی
share = rng.Next(0, 2) == 0 ? 50 : 60; break;
case 2: share = rng.Next(0, 2) == 0 ? 40 : 50; break; // مبلغ یا درصد (به انتخاب)
default: break; // مبلغ مقطوع
}
case 0: payType = PayType.Negotiable; amount = null; break;
case 1: payType = PayType.Percentage; amount = null; share = rng.Next(0, 2) == 0 ? 50 : 60; break;
case 2: share = rng.Next(0, 2) == 0 ? 40 : 50; break;
}
shifts.Add(new Shift
{
FacilityId = f.Id,
RoleId = role.Id,
Date = date,
StartTime = t.Item2,
EndTime = t.Item3,
ShiftType = t.Item1,
SpecialtyRequired = role.Name,
FacilityId = f.Id, RoleId = role.Id, Date = date, StartTime = t.Item2, EndTime = t.Item3,
ShiftType = t.Item1, SpecialtyRequired = role.Name,
Description = $"{t.Item4} - نیازمند {role.Name} مسلط به امور درمانگاه/اورژانس",
PayType = payType,
PayAmount = amount,
SharePercent = share,
PayType = payType, PayAmount = amount, SharePercent = share,
GenderRequirement = role.Name == "ماما" ? Gender.Female
: rng.Next(0, 4) == 0 ? (Gender)rng.Next(1, 3) : Gender.Any,
Status = ShiftStatus.Open,
Source = ShiftSource.Admin,
Status = ShiftStatus.Open, Source = ShiftSource.Admin,
});
}
}
}
db.Shifts.AddRange(shifts);
// Permanent hiring openings (استخدام) — the hiring side of the marketplace.
db.JobOpenings.AddRange(
new JobOpening { FacilityId = facilities[0].Id, RoleId = roles[2].Id,
Title = "استخدام پرستار بخش اورژانس", EmploymentType = EmploymentType.FullTime,
SalaryMin = 18_000_000, SalaryMax = 25_000_000,
Description = "استخدام تمام‌وقت پرستار جهت بخش اورژانس با سابقه کار.",
Requirements = "حداقل ۲ سال سابقه، مسلط به ICU", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[1].Id, RoleId = roles[0].Id,
Title = "پزشک عمومی مقیم", EmploymentType = EmploymentType.Contract,
SalaryMin = 40_000_000, SalaryMax = 55_000_000,
Description = "پزشک عمومی مقیم جهت بیمارستان، شیفت‌های چرخشی.",
Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[2].Id, RoleId = roles[3].Id,
Title = "ماما جهت کلینیک زنان", EmploymentType = EmploymentType.PartTime,
SalaryMin = null, SalaryMax = null, GenderRequirement = Gender.Female,
Description = "همکاری پاره‌وقت ماما در کلینیک تخصصی زنان و زایمان.",
Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[4].Id, RoleId = roles[4].Id,
Title = "تکنسین اتاق عمل", EmploymentType = EmploymentType.FullTime,
SalaryMin = 16_000_000, SalaryMax = 22_000_000,
Description = "تکنسین اتاق عمل جهت بیمارستان، تمام‌وقت با بیمه.",
Requirements = "مدرک تکنسین اتاق عمل، آشنا به ابزار جراحی", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[3].Id, RoleId = roles[0].Id,
Title = "پزشک عمومی طرح", EmploymentType = EmploymentType.Plan,
SalaryMin = 30_000_000, SalaryMax = 30_000_000,
Description = "جذب پزشک عمومی جهت گذراندن طرح در درمانگاه شبانه‌روزی.",
Status = ShiftStatus.Open });
await db.SaveChangesAsync();
// A couple of raw listings waiting in the admin normalization queue.
db.RawListings.AddRange(
new RawListing
{
SourceChannel = "کانال شیفت پزشکان تهران",
RawText = "نیازمند پزشک عمومی جهت شیفت شب درمانگاه در منطقه غرب تهران، کارانه توافقی. تماس: ۰۹۱۲xxxxxxx",
Status = RawListingStatus.New,
},
new RawListing
{
SourceChannel = "Divar - استخدام پزشک",
RawText = "بیمارستان خصوصی جهت تکمیل کادر درمان به پزشک عمومی برای شیفت‌های روز نیازمند است.",
Status = RawListingStatus.New,
},
new RawListing
{
SourceChannel = "کانال درمانگاه‌های تهران",
RawText = "درمانگاه شبانه‌روزی نیازمند پزشک عمومی برای شیفت عصر، پرداخت ۵۰٪ سهم درآمد ویزیت. سعادت‌آباد.",
Status = RawListingStatus.New,
});
new JobOpening { FacilityId = facilities[0].Id, RoleId = roles[2].Id, Title = "استخدام پرستار بخش اورژانس", EmploymentType = EmploymentType.FullTime, SalaryMin = 18_000_000, SalaryMax = 25_000_000, Description = "استخدام تمام‌وقت پرستار جهت بخش اورژانس با سابقه کار.", Requirements = "حداقل ۲ سال سابقه، مسلط به ICU", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[1].Id, RoleId = roles[0].Id, Title = "پزشک عمومی مقیم", EmploymentType = EmploymentType.Contract, SalaryMin = 40_000_000, SalaryMax = 55_000_000, Description = "پزشک عمومی مقیم جهت بیمارستان، شیفت‌های چرخشی.", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[2].Id, RoleId = roles[3].Id, Title = "ماما جهت کلینیک زنان", EmploymentType = EmploymentType.PartTime, GenderRequirement = Gender.Female, Description = "همکاری پاره‌وقت ماما در کلینیک تخصصی زنان و زایمان.", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[4].Id, RoleId = roles[4].Id, Title = "تکنسین اتاق عمل", EmploymentType = EmploymentType.FullTime, SalaryMin = 16_000_000, SalaryMax = 22_000_000, Description = "تکنسین اتاق عمل جهت بیمارستان، تمام‌وقت با بیمه.", Requirements = "مدرک تکنسین اتاق عمل", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[3].Id, RoleId = roles[0].Id, Title = "پزشک عمومی طرح", EmploymentType = EmploymentType.Plan, SalaryMin = 30_000_000, SalaryMax = 30_000_000, Description = "جذب پزشک عمومی جهت گذراندن طرح در درمانگاه شبانه‌روزی.", Status = ShiftStatus.Open });
await db.SaveChangesAsync();
return facilities.Length;
}
/// <summary>Remove all demo facilities (cascades their shifts/jobs). Returns rows removed.</summary>
public static async Task<int> ClearDemoAsync(AppDbContext db)
=> await db.Facilities.Where(f => f.IsDemo).ExecuteDeleteAsync();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class DemoModeAndWebsites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsDemo",
table: "Facilities",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "DemoMode",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "WebsiteUrls",
table: "AppSettings",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "WebsitesEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsDemo",
table: "Facilities");
migrationBuilder.DropColumn(
name: "DemoMode",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "WebsiteUrls",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "WebsitesEnabled",
table: "AppSettings");
}
}
}
@@ -66,6 +66,9 @@ namespace JobsMedical.Web.Migrations
b.Property<bool>("BaleEnabled")
.HasColumnType("boolean");
b.Property<bool>("DemoMode")
.HasColumnType("boolean");
b.Property<string>("DivarCity")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
@@ -133,6 +136,13 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("WebsiteUrls")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<bool>("WebsitesEnabled")
.HasColumnType("boolean");
b.HasKey("Id");
b.ToTable("AppSettings");
@@ -297,6 +307,9 @@ namespace JobsMedical.Web.Migrations
b.Property<int?>("DistrictId")
.HasColumnType("integer");
b.Property<bool>("IsDemo")
.HasColumnType("boolean");
b.Property<bool>("IsVerified")
.HasColumnType("boolean");
+7
View File
@@ -44,6 +44,13 @@ public class AppSetting
public bool BaleEnabled { get; set; } = false;
[MaxLength(200)] public string? BaleBotToken { get; set; }
/// <summary>Demo mode — keep the sample Tehran board seeded/visible (for showcasing).</summary>
public bool DemoMode { get; set; } = false;
public bool WebsitesEnabled { get; set; } = false;
/// <summary>Generic web pages to scrape, one URL per line.</summary>
[MaxLength(4000)] public string? WebsiteUrls { get; set; }
public bool DivarEnabled { get; set; } = false;
[MaxLength(60)] public string? DivarCity { get; set; } = "tehran";
/// <summary>Divar search terms, one per line or comma-separated.</summary>
+3
View File
@@ -18,6 +18,9 @@ public class Facility
public int? DistrictId { get; set; } // محله/منطقه
public District? District { get; set; }
/// <summary>Seeded sample facility (demo mode) — lets admins seed/clear demo data cleanly.</summary>
public bool IsDemo { get; set; }
[MaxLength(500)]
public string? Address { get; set; } // آدرس
@@ -12,6 +12,15 @@
</div>
<div class="container section" style="max-width:680px;">
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
<div class="card card-pad" style="margin-bottom:14px;">
<h3 style="margin-top:0;">حالت نمایشی (Demo)</h3>
<p class="muted" style="font-size:13px; margin-top:0;">داده‌های نمونه‌ی تهران را برای نمایش/دمو روی سایت قرار بده یا حذف کن. (تیک «حالت نمایشی» پایین را هم بزن تا پس از هر استقرار دوباره ساخته شود.)</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<form method="post"><button asp-page-handler="SeedDemo" class="btn btn-outline">ساخت داده نمونه</button></form>
<form method="post" onsubmit="return confirm('همه داده‌های نمونه حذف شوند؟');"><button asp-page-handler="ClearDemo" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">حذف داده نمونه</button></form>
</div>
</div>
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
<form method="post" class="card card-pad" style="margin-bottom:14px; display:flex; gap:8px; align-items:end; flex-wrap:wrap;">
<div class="filter-group" style="margin:0; flex:1; min-width:160px;">
@@ -123,6 +132,27 @@
<p class="muted" style="font-size:12px; margin:4px 0 0;">آگهی‌های تکراری به‌صورت خودکار رد می‌شوند؛ هر اجرا فقط آگهی‌های جدید را می‌آورد.</p>
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="WebsitesEnabled" value="true" style="width:auto;" checked="@Model.WebsitesEnabled" />
وب‌سایت‌ها (آدرس‌های دلخواه)
</label>
<label style="margin-top:6px;">آدرس صفحه‌ها (هر خط یک URL)</label>
<textarea name="WebsiteUrls" rows="3" dir="ltr" placeholder="https://example.ir/jobs/123">@Model.WebsiteUrls</textarea>
<p class="muted" style="font-size:12px; margin:4px 0 0;">موتور هر آدرس را می‌خواند و متن آگهی را استخراج می‌کند (عنوان og + بدنه محتوا). برای هر صفحه شغلی، آرشیو کانال یا آگهی طبقه‌بندی.</p>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3 style="margin-top:0;">حالت نمایشی (Demo)</h3>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="DemoMode" value="true" style="width:auto;" checked="@Model.DemoMode" />
حالت نمایشی فعال باشد — داده‌های نمونه پس از هر استقرار به‌صورت خودکار ساخته شوند
</label>
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای ساخت/حذف فوری داده‌های نمونه از کارت بالای همین صفحه استفاده کن.</p>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوه‌نگار</h3>
@@ -1,3 +1,4 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using JobsMedical.Web.Services.Scraping;
@@ -12,10 +13,12 @@ public class SettingsModel : PageModel
{
private readonly SettingsService _settings;
private readonly ISmsSender _sms;
public SettingsModel(SettingsService settings, ISmsSender sms)
private readonly AppDbContext _db;
public SettingsModel(SettingsService settings, ISmsSender sms, AppDbContext db)
{
_settings = settings;
_sms = sms;
_db = db;
}
[BindProperty] public IngestionMode Mode { get; set; }
@@ -48,8 +51,12 @@ public class SettingsModel : PageModel
[BindProperty] public string? VapidPrivateKey { get; set; }
[BindProperty] public string? VapidSubject { get; set; }
[BindProperty] public string? TestPhone { get; set; }
[BindProperty] public bool DemoMode { get; set; }
[BindProperty] public bool WebsitesEnabled { get; set; }
[BindProperty] public string? WebsiteUrls { get; set; }
[TempData] public string? Saved { get; set; }
[TempData] public string? SmsTest { get; set; }
[TempData] public string? DemoMsg { get; set; }
public async Task OnGetAsync()
{
@@ -78,6 +85,9 @@ public class SettingsModel : PageModel
SmsTemplate = s.SmsTemplate;
SmsSender = s.SmsSender;
NeshanMapKey = s.NeshanMapKey;
DemoMode = s.DemoMode;
WebsitesEnabled = s.WebsitesEnabled;
WebsiteUrls = s.WebsiteUrls;
PushEnabled = s.PushEnabled;
VapidPublicKey = s.VapidPublicKey;
VapidPrivateKey = s.VapidPrivateKey;
@@ -112,6 +122,9 @@ public class SettingsModel : PageModel
SmsTemplate = SmsTemplate,
SmsSender = SmsSender,
NeshanMapKey = NeshanMapKey,
DemoMode = DemoMode,
WebsitesEnabled = WebsitesEnabled,
WebsiteUrls = WebsiteUrls,
PushEnabled = PushEnabled,
VapidPublicKey = VapidPublicKey,
VapidPrivateKey = VapidPrivateKey,
@@ -121,6 +134,20 @@ public class SettingsModel : PageModel
return RedirectToPage();
}
public async Task<IActionResult> OnPostSeedDemoAsync()
{
var n = await SeedData.SeedDemoAsync(_db);
DemoMsg = n > 0 ? $"داده‌های نمونه ثبت شد ({n} مرکز + شیفت/استخدام)." : "داده‌های نمونه از قبل موجود است.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostClearDemoAsync()
{
var n = await SeedData.ClearDemoAsync(_db);
DemoMsg = $"داده‌های نمونه حذف شد ({n} مرکز و آگهی‌های وابسته).";
return RedirectToPage();
}
public async Task<IActionResult> OnPostTestSmsAsync()
{
var s = await _settings.GetAsync();
+7 -2
View File
@@ -50,6 +50,8 @@ builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.DivarListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.MedjobsListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.WebsiteListingSource>();
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
@@ -79,8 +81,11 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
// Production seeds reference data only (no demo facilities/shifts); dev seeds the full board.
await SeedData.EnsureSeededAsync(db, app.Environment.IsDevelopment());
await SeedData.SeedReferenceAsync(db); // cities/roles/districts always
// Demo board in Development, or whenever the admin has turned Demo Mode on.
var st = await scope.ServiceProvider
.GetRequiredService<JobsMedical.Web.Services.Scraping.SettingsService>().GetAsync();
if (app.Environment.IsDevelopment() || st.DemoMode) await SeedData.SeedDemoAsync(db);
// Archive any listings that went stale while the app was down.
await scope.ServiceProvider
.GetRequiredService<JobsMedical.Web.Services.Scraping.ListingArchiver>()
@@ -41,6 +41,9 @@ public class SettingsService
s.TelegramChannels = incoming.TelegramChannels?.Trim();
s.BaleEnabled = incoming.BaleEnabled;
s.BaleBotToken = incoming.BaleBotToken?.Trim();
s.DemoMode = incoming.DemoMode;
s.WebsitesEnabled = incoming.WebsitesEnabled;
s.WebsiteUrls = incoming.WebsiteUrls?.Trim();
s.DivarEnabled = incoming.DivarEnabled;
s.DivarCity = string.IsNullOrWhiteSpace(incoming.DivarCity) ? "tehran" : incoming.DivarCity.Trim();
s.DivarQueries = incoming.DivarQueries?.Trim();
@@ -0,0 +1,69 @@
using System.Net;
using System.Text.RegularExpressions;
using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services.Scraping;
/// <summary>
/// Generic website source: fetches each admin-configured URL and extracts readable text
/// (JobPosting/Product JSON-LD description, common content containers, or og:title+description).
/// Lets the admin point the engine at any job page / channel archive / classifieds listing.
/// </summary>
public class WebsiteListingSource : IListingSource
{
private readonly IHttpClientFactory _http;
private readonly ILogger<WebsiteListingSource> _log;
public WebsiteListingSource(IHttpClientFactory http, ILogger<WebsiteListingSource> log)
{
_http = http;
_log = log;
}
public string Name => "وب‌سایت‌ها";
public async Task<IReadOnlyList<ScrapedItem>> FetchAsync(AppSetting s, CancellationToken ct = default)
{
var urls = AppSetting.SplitList(s.WebsiteUrls);
if (!s.WebsitesEnabled || urls.Count == 0) return Array.Empty<ScrapedItem>();
var client = _http.CreateClient("scrape");
var items = new List<ScrapedItem>();
foreach (var url in urls.Where(u => u.StartsWith("http")))
{
try
{
var html = await client.GetStringAsync(url, ct);
var text = Extract(html);
if (text.Length >= 25) items.Add(new ScrapedItem($"وب‌سایت ({Host(url)})", text, url));
}
catch (Exception ex) { _log.LogWarning(ex, "Website fetch failed for {Url}", url); }
}
return items;
}
private static string Host(string url) => Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.Host : "web";
private static string Extract(string html)
{
string? title = Meta(html, "og:title");
if (title is not null) { var bar = title.IndexOf('|'); if (bar > 10) title = title[..bar].Trim(); }
string? body = Between(html, "rtcl-description") ?? Between(html, "entry-content")
?? Between(html, "job-description") ?? Meta(html, "og:description");
var text = HtmlUtil.ToPlainText(string.Join("\n", new[] { title, body }.Where(x => !string.IsNullOrWhiteSpace(x))));
return text.Length > 1800 ? text[..1800] : text;
}
private static string? Meta(string html, string prop)
{
var m = Regex.Match(html, $"<meta[^>]+property=[\"']{Regex.Escape(prop)}[\"'][^>]+content=[\"']([^\"']*)[\"']");
return m.Success ? WebUtility.HtmlDecode(m.Groups[1].Value) : null;
}
private static string? Between(string html, string cls)
{
var m = Regex.Match(html, $"<(?:div|article)[^>]+class=[\"'][^\"']*{Regex.Escape(cls)}[^\"']*[\"'][^>]*>(.*?)</(?:div|article)>",
RegexOptions.Singleline);
return m.Success ? m.Groups[1].Value : null;
}
}