[Demo] Add admin demo-mode toggle + generic website ingest source
- 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:
@@ -5,100 +5,71 @@ namespace JobsMedical.Web.Data;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds reference data (cities, roles, districts) always, and a believable Tehran demo board
|
/// 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.
|
/// (facilities/shifts/jobs/raw listings, marked <see cref="Facility.IsDemo"/>) when demo mode is on.
|
||||||
/// In production we pass false so real employers populate listings — no fake data goes public.
|
/// Demo seed/clear is idempotent and can be toggled at runtime from the admin panel.
|
||||||
/// Idempotent: reference seeds only when empty; demo seeds only when no facilities exist.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SeedData
|
public static class SeedData
|
||||||
{
|
{
|
||||||
public static async Task EnsureSeededAsync(AppDbContext db, bool includeDemo = true)
|
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;
|
if (await db.Cities.AnyAsync()) return;
|
||||||
|
|
||||||
var tehran = new City { Name = "تهران", Province = "تهران", IsActive = true };
|
var tehran = new City { Name = "تهران", Province = "تهران", IsActive = true };
|
||||||
var cities = new[]
|
db.Cities.AddRange(tehran,
|
||||||
{
|
|
||||||
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 },
|
||||||
new City { Name = "اصفهان", Province = "اصفهان", IsActive = false },
|
new City { Name = "اصفهان", Province = "اصفهان", IsActive = false },
|
||||||
new City { Name = "شیراز", Province = "فارس", IsActive = false },
|
new City { Name = "شیراز", Province = "فارس", IsActive = false });
|
||||||
};
|
await db.SaveChangesAsync();
|
||||||
db.Cities.AddRange(cities);
|
|
||||||
await db.SaveChangesAsync(); // need tehran.Id before districts reference it
|
|
||||||
|
|
||||||
var roles = new[]
|
db.Roles.AddRange(
|
||||||
{
|
|
||||||
new Role { Name = "پزشک عمومی", Category = "پزشک", SortOrder = 1 },
|
new Role { Name = "پزشک عمومی", Category = "پزشک", SortOrder = 1 },
|
||||||
new Role { Name = "پزشک متخصص", Category = "پزشک", SortOrder = 2 },
|
new Role { Name = "پزشک متخصص", Category = "پزشک", SortOrder = 2 },
|
||||||
new Role { Name = "پرستار", Category = "پرستار", SortOrder = 3 },
|
new Role { Name = "پرستار", Category = "پرستار", SortOrder = 3 },
|
||||||
new Role { Name = "ماما", Category = "ماما", SortOrder = 4 },
|
new Role { Name = "ماما", Category = "ماما", SortOrder = 4 },
|
||||||
new Role { Name = "تکنسین اتاق عمل", Category = "تکنسین", SortOrder = 5 },
|
new Role { Name = "تکنسین اتاق عمل", Category = "تکنسین", SortOrder = 5 },
|
||||||
new Role { Name = "تکنسین فوریتهای پزشکی", Category = "تکنسین", SortOrder = 6 },
|
new Role { Name = "تکنسین فوریتهای پزشکی", Category = "تکنسین", SortOrder = 6 },
|
||||||
new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 },
|
new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 });
|
||||||
};
|
|
||||||
db.Roles.AddRange(roles);
|
|
||||||
|
|
||||||
// Tehran neighborhoods (محله/منطقه) for the within-city filter.
|
foreach (var n in new[] { "سعادتآباد", "شهرک غرب", "ولیعصر / پارکوی", "نارمک",
|
||||||
var saadatAbad = new District { Name = "سعادتآباد", CityId = tehran.Id };
|
"تهرانپارس", "گیشا / برج میلاد", "ونک", "تجریش" })
|
||||||
var shahrakGharb = new District { Name = "شهرک غرب", CityId = tehran.Id };
|
db.Districts.Add(new District { Name = n, 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 });
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Demo data (Tehran sample board): development only -----
|
// ---------- Demo board (toggleable) ----------
|
||||||
if (!includeDemo) return;
|
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[]
|
var facilities = new[]
|
||||||
{
|
{
|
||||||
new Facility { Name = "بیمارستان میلاد", Type = FacilityType.Hospital, CityId = tehran.Id,
|
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 },
|
||||||
DistrictId = gisha.Id,
|
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 },
|
||||||
Address = "تهران، بزرگراه همت، روبهروی برج میلاد", Phone = "021-82032000",
|
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 },
|
||||||
Lat = 35.7448, Lng = 51.3753, IsVerified = 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,
|
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 },
|
||||||
DistrictId = valiasr.Id,
|
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 },
|
||||||
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 },
|
|
||||||
};
|
};
|
||||||
db.Facilities.AddRange(facilities);
|
db.Facilities.AddRange(facilities);
|
||||||
await db.SaveChangesAsync();
|
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 today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
var rng = new Random(20260602); // deterministic seed for reproducible sample data
|
var rng = new Random(20260602);
|
||||||
var shifts = new List<Shift>();
|
var rolePool = new[] { roles[0], roles[0], roles[0], roles[2], roles[2], roles[1], roles[3], roles[4], roles[5], roles[6] };
|
||||||
|
|
||||||
// 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 templates = new[]
|
var templates = new[]
|
||||||
{
|
{
|
||||||
(ShiftType.Day, new TimeOnly(8, 0), new TimeOnly(14, 0), "شیفت صبح", 1_500_000L),
|
(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.Night, new TimeOnly(20, 0), new TimeOnly(8, 0), "شیفت شب", 2_500_000L),
|
||||||
(ShiftType.OnCall, new TimeOnly(8, 0), new TimeOnly(8, 0), "آنکال", 0L),
|
(ShiftType.OnCall, new TimeOnly(8, 0), new TimeOnly(8, 0), "آنکال", 0L),
|
||||||
};
|
};
|
||||||
|
var shifts = new List<Shift>();
|
||||||
foreach (var f in facilities)
|
foreach (var f in facilities)
|
||||||
{
|
|
||||||
for (var d = 0; d < 14; d++)
|
for (var d = 0; d < 14; d++)
|
||||||
{
|
{
|
||||||
var date = today.AddDays(d);
|
var date = today.AddDays(d);
|
||||||
var count = rng.Next(0, 3); // 0–2 shifts per facility per day
|
for (var i = 0; i < rng.Next(0, 3); i++)
|
||||||
for (var i = 0; i < count; i++)
|
|
||||||
{
|
{
|
||||||
var t = templates[rng.Next(templates.Length)];
|
var t = templates[rng.Next(templates.Length)];
|
||||||
var role = rolePool[rng.Next(rolePool.Length)];
|
var role = rolePool[rng.Next(rolePool.Length)];
|
||||||
|
var payType = PayType.PerShift; long? amount = t.Item5; int? share = null;
|
||||||
// Vary the compensation model: fixed, profit-share, both (choose), or negotiable.
|
|
||||||
var payType = PayType.PerShift;
|
|
||||||
long? amount = t.Item5;
|
|
||||||
int? share = null;
|
|
||||||
if (t.Item1 == ShiftType.OnCall) { payType = PayType.Negotiable; amount = 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 0: payType = PayType.Negotiable; amount = null; break; // توافقی
|
case 2: share = rng.Next(0, 2) == 0 ? 40 : 50; 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; // مبلغ مقطوع
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
shifts.Add(new Shift
|
shifts.Add(new Shift
|
||||||
{
|
{
|
||||||
FacilityId = f.Id,
|
FacilityId = f.Id, RoleId = role.Id, Date = date, StartTime = t.Item2, EndTime = t.Item3,
|
||||||
RoleId = role.Id,
|
ShiftType = t.Item1, SpecialtyRequired = role.Name,
|
||||||
Date = date,
|
|
||||||
StartTime = t.Item2,
|
|
||||||
EndTime = t.Item3,
|
|
||||||
ShiftType = t.Item1,
|
|
||||||
SpecialtyRequired = role.Name,
|
|
||||||
Description = $"{t.Item4} - نیازمند {role.Name} مسلط به امور درمانگاه/اورژانس",
|
Description = $"{t.Item4} - نیازمند {role.Name} مسلط به امور درمانگاه/اورژانس",
|
||||||
PayType = payType,
|
PayType = payType, PayAmount = amount, SharePercent = share,
|
||||||
PayAmount = amount,
|
|
||||||
SharePercent = share,
|
|
||||||
GenderRequirement = role.Name == "ماما" ? Gender.Female
|
GenderRequirement = role.Name == "ماما" ? Gender.Female
|
||||||
: rng.Next(0, 4) == 0 ? (Gender)rng.Next(1, 3) : Gender.Any,
|
: rng.Next(0, 4) == 0 ? (Gender)rng.Next(1, 3) : Gender.Any,
|
||||||
Status = ShiftStatus.Open,
|
Status = ShiftStatus.Open, Source = ShiftSource.Admin,
|
||||||
Source = ShiftSource.Admin,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
db.Shifts.AddRange(shifts);
|
db.Shifts.AddRange(shifts);
|
||||||
|
|
||||||
// Permanent hiring openings (استخدام) — the hiring side of the marketplace.
|
|
||||||
db.JobOpenings.AddRange(
|
db.JobOpenings.AddRange(
|
||||||
new JobOpening { FacilityId = facilities[0].Id, RoleId = roles[2].Id,
|
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 },
|
||||||
Title = "استخدام پرستار بخش اورژانس", EmploymentType = EmploymentType.FullTime,
|
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 },
|
||||||
SalaryMin = 18_000_000, SalaryMax = 25_000_000,
|
new JobOpening { FacilityId = facilities[2].Id, RoleId = roles[3].Id, Title = "ماما جهت کلینیک زنان", EmploymentType = EmploymentType.PartTime, GenderRequirement = Gender.Female, Description = "همکاری پارهوقت ماما در کلینیک تخصصی زنان و زایمان.", Status = ShiftStatus.Open },
|
||||||
Description = "استخدام تماموقت پرستار جهت بخش اورژانس با سابقه کار.",
|
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 },
|
||||||
Requirements = "حداقل ۲ سال سابقه، مسلط به ICU", 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 });
|
||||||
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();
|
await db.SaveChangesAsync();
|
||||||
|
return facilities.Length;
|
||||||
|
}
|
||||||
|
|
||||||
// A couple of raw listings waiting in the admin normalization queue.
|
/// <summary>Remove all demo facilities (cascades their shifts/jobs). Returns rows removed.</summary>
|
||||||
db.RawListings.AddRange(
|
public static async Task<int> ClearDemoAsync(AppDbContext db)
|
||||||
new RawListing
|
=> await db.Facilities.Where(f => f.IsDemo).ExecuteDeleteAsync();
|
||||||
{
|
|
||||||
SourceChannel = "کانال شیفت پزشکان تهران",
|
|
||||||
RawText = "نیازمند پزشک عمومی جهت شیفت شب درمانگاه در منطقه غرب تهران، کارانه توافقی. تماس: ۰۹۱۲xxxxxxx",
|
|
||||||
Status = RawListingStatus.New,
|
|
||||||
},
|
|
||||||
new RawListing
|
|
||||||
{
|
|
||||||
SourceChannel = "Divar - استخدام پزشک",
|
|
||||||
RawText = "بیمارستان خصوصی جهت تکمیل کادر درمان به پزشک عمومی برای شیفتهای روز نیازمند است.",
|
|
||||||
Status = RawListingStatus.New,
|
|
||||||
},
|
|
||||||
new RawListing
|
|
||||||
{
|
|
||||||
SourceChannel = "کانال درمانگاههای تهران",
|
|
||||||
RawText = "درمانگاه شبانهروزی نیازمند پزشک عمومی برای شیفت عصر، پرداخت ۵۰٪ سهم درآمد ویزیت. سعادتآباد.",
|
|
||||||
Status = RawListingStatus.New,
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1062
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")
|
b.Property<bool>("BaleEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("DemoMode")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("DivarCity")
|
b.Property<string>("DivarCity")
|
||||||
.HasMaxLength(60)
|
.HasMaxLength(60)
|
||||||
.HasColumnType("character varying(60)");
|
.HasColumnType("character varying(60)");
|
||||||
@@ -133,6 +136,13 @@ namespace JobsMedical.Web.Migrations
|
|||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
.HasColumnType("character varying(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.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("AppSettings");
|
b.ToTable("AppSettings");
|
||||||
@@ -297,6 +307,9 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Property<int?>("DistrictId")
|
b.Property<int?>("DistrictId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDemo")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsVerified")
|
b.Property<bool>("IsVerified")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ public class AppSetting
|
|||||||
public bool BaleEnabled { get; set; } = false;
|
public bool BaleEnabled { get; set; } = false;
|
||||||
[MaxLength(200)] public string? BaleBotToken { get; set; }
|
[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;
|
public bool DivarEnabled { get; set; } = false;
|
||||||
[MaxLength(60)] public string? DivarCity { get; set; } = "tehran";
|
[MaxLength(60)] public string? DivarCity { get; set; } = "tehran";
|
||||||
/// <summary>Divar search terms, one per line or comma-separated.</summary>
|
/// <summary>Divar search terms, one per line or comma-separated.</summary>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ public class Facility
|
|||||||
public int? DistrictId { get; set; } // محله/منطقه
|
public int? DistrictId { get; set; } // محله/منطقه
|
||||||
public District? District { 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)]
|
[MaxLength(500)]
|
||||||
public string? Address { get; set; } // آدرس
|
public string? Address { get; set; } // آدرس
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container section" style="max-width:680px;">
|
<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> }
|
@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;">
|
<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;">
|
<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>
|
<p class="muted" style="font-size:12px; margin:4px 0 0;">آگهیهای تکراری بهصورت خودکار رد میشوند؛ هر اجرا فقط آگهیهای جدید را میآورد.</p>
|
||||||
</div>
|
</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;" />
|
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||||
|
|
||||||
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوهنگار</h3>
|
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوهنگار</h3>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
using JobsMedical.Web.Models;
|
using JobsMedical.Web.Models;
|
||||||
using JobsMedical.Web.Services;
|
using JobsMedical.Web.Services;
|
||||||
using JobsMedical.Web.Services.Scraping;
|
using JobsMedical.Web.Services.Scraping;
|
||||||
@@ -12,10 +13,12 @@ public class SettingsModel : PageModel
|
|||||||
{
|
{
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
private readonly ISmsSender _sms;
|
private readonly ISmsSender _sms;
|
||||||
public SettingsModel(SettingsService settings, ISmsSender sms)
|
private readonly AppDbContext _db;
|
||||||
|
public SettingsModel(SettingsService settings, ISmsSender sms, AppDbContext db)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_sms = sms;
|
_sms = sms;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[BindProperty] public IngestionMode Mode { get; set; }
|
[BindProperty] public IngestionMode Mode { get; set; }
|
||||||
@@ -48,8 +51,12 @@ public class SettingsModel : PageModel
|
|||||||
[BindProperty] public string? VapidPrivateKey { get; set; }
|
[BindProperty] public string? VapidPrivateKey { get; set; }
|
||||||
[BindProperty] public string? VapidSubject { get; set; }
|
[BindProperty] public string? VapidSubject { get; set; }
|
||||||
[BindProperty] public string? TestPhone { 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? Saved { get; set; }
|
||||||
[TempData] public string? SmsTest { get; set; }
|
[TempData] public string? SmsTest { get; set; }
|
||||||
|
[TempData] public string? DemoMsg { get; set; }
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
@@ -78,6 +85,9 @@ public class SettingsModel : PageModel
|
|||||||
SmsTemplate = s.SmsTemplate;
|
SmsTemplate = s.SmsTemplate;
|
||||||
SmsSender = s.SmsSender;
|
SmsSender = s.SmsSender;
|
||||||
NeshanMapKey = s.NeshanMapKey;
|
NeshanMapKey = s.NeshanMapKey;
|
||||||
|
DemoMode = s.DemoMode;
|
||||||
|
WebsitesEnabled = s.WebsitesEnabled;
|
||||||
|
WebsiteUrls = s.WebsiteUrls;
|
||||||
PushEnabled = s.PushEnabled;
|
PushEnabled = s.PushEnabled;
|
||||||
VapidPublicKey = s.VapidPublicKey;
|
VapidPublicKey = s.VapidPublicKey;
|
||||||
VapidPrivateKey = s.VapidPrivateKey;
|
VapidPrivateKey = s.VapidPrivateKey;
|
||||||
@@ -112,6 +122,9 @@ public class SettingsModel : PageModel
|
|||||||
SmsTemplate = SmsTemplate,
|
SmsTemplate = SmsTemplate,
|
||||||
SmsSender = SmsSender,
|
SmsSender = SmsSender,
|
||||||
NeshanMapKey = NeshanMapKey,
|
NeshanMapKey = NeshanMapKey,
|
||||||
|
DemoMode = DemoMode,
|
||||||
|
WebsitesEnabled = WebsitesEnabled,
|
||||||
|
WebsiteUrls = WebsiteUrls,
|
||||||
PushEnabled = PushEnabled,
|
PushEnabled = PushEnabled,
|
||||||
VapidPublicKey = VapidPublicKey,
|
VapidPublicKey = VapidPublicKey,
|
||||||
VapidPrivateKey = VapidPrivateKey,
|
VapidPrivateKey = VapidPrivateKey,
|
||||||
@@ -121,6 +134,20 @@ public class SettingsModel : PageModel
|
|||||||
return RedirectToPage();
|
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()
|
public async Task<IActionResult> OnPostTestSmsAsync()
|
||||||
{
|
{
|
||||||
var s = await _settings.GetAsync();
|
var s = await _settings.GetAsync();
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
|||||||
JobsMedical.Web.Services.Scraping.DivarListingSource>();
|
JobsMedical.Web.Services.Scraping.DivarListingSource>();
|
||||||
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||||
JobsMedical.Web.Services.Scraping.MedjobsListingSource>();
|
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.ListingArchiver>();
|
||||||
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
|
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
|
||||||
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
|
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
|
||||||
@@ -79,8 +81,11 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
// Production seeds reference data only (no demo facilities/shifts); dev seeds the full board.
|
await SeedData.SeedReferenceAsync(db); // cities/roles/districts always
|
||||||
await SeedData.EnsureSeededAsync(db, app.Environment.IsDevelopment());
|
// 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.
|
// Archive any listings that went stale while the app was down.
|
||||||
await scope.ServiceProvider
|
await scope.ServiceProvider
|
||||||
.GetRequiredService<JobsMedical.Web.Services.Scraping.ListingArchiver>()
|
.GetRequiredService<JobsMedical.Web.Services.Scraping.ListingArchiver>()
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ public class SettingsService
|
|||||||
s.TelegramChannels = incoming.TelegramChannels?.Trim();
|
s.TelegramChannels = incoming.TelegramChannels?.Trim();
|
||||||
s.BaleEnabled = incoming.BaleEnabled;
|
s.BaleEnabled = incoming.BaleEnabled;
|
||||||
s.BaleBotToken = incoming.BaleBotToken?.Trim();
|
s.BaleBotToken = incoming.BaleBotToken?.Trim();
|
||||||
|
s.DemoMode = incoming.DemoMode;
|
||||||
|
s.WebsitesEnabled = incoming.WebsitesEnabled;
|
||||||
|
s.WebsiteUrls = incoming.WebsiteUrls?.Trim();
|
||||||
s.DivarEnabled = incoming.DivarEnabled;
|
s.DivarEnabled = incoming.DivarEnabled;
|
||||||
s.DivarCity = string.IsNullOrWhiteSpace(incoming.DivarCity) ? "tehran" : incoming.DivarCity.Trim();
|
s.DivarCity = string.IsNullOrWhiteSpace(incoming.DivarCity) ? "tehran" : incoming.DivarCity.Trim();
|
||||||
s.DivarQueries = incoming.DivarQueries?.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user