Compare commits

..

74 Commits

Author SHA1 Message Date
soroush.asadi f0e0b82375 Fix service worker serving the homepage when clicking a job/applicant card
CI/CD / CI · dotnet build (push) Successful in 38s
CI/CD / Deploy · hamkadr (push) Successful in 1m55s
Navigations now always hit the network (fresh, never stale/archived) and fall back only to an offline notice, never the cached homepage. HTML is no longer cached so 410s cannot poison the cache; static assets stay cache-first. CACHE bumped to v2 to force-replace the broken worker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:03:37 +03:30
soroush.asadi 923a3fb90e Stop the CDN serving a stale homepage (archived listings showing as live cards)
CI/CD / CI · dotnet build (push) Successful in 2m13s
CI/CD / Deploy · hamkadr (push) Successful in 4m57s
The homepage query already filters Status==Open, but the wcdn CDN was caching the rendered HTML.
A listing archived by the post-crawl cleanup AFTER caching still appeared as a card on the stale
copy — clicking it hit the correct 410. Force HTML revalidation (no-cache) so listing pages are
always fresh; mark logged-in pages private/no-store and Vary on Cookie so the CDN never mixes
anonymous and authenticated copies. Static assets keep their long cache headers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:41:09 +03:30
soroush.asadi aaeb37e1af Make dedupe stricter to avoid wrongly archiving distinct placeholder-facility ads
CI/CD / CI · dotnet build (push) Successful in 46s
CI/CD / Deploy · hamkadr (push) Successful in 58s
The job/talent duplicate-detector compared only the first 100–120 chars of the normalized
description. Since ~82% of jobs share the «نامشخص» placeholder facility, two genuinely different
ads that merely open the same way could collapse — wrongly archiving a valid listing (→ a 410 like
/Jobs/Details/7032). Compare a 400-char slice instead, so only near-identical full texts dedupe;
true reposts/fan-out (identical text) are still caught.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:26:25 +03:30
soroush.asadi a97c556770 Show two rows of applicants on the homepage
CI/CD / CI · dotnet build (push) Successful in 3m49s
CI/CD / Deploy · hamkadr (push) Successful in 5m7s
Bump the homepage «کادر درمان آماده به کار» section from 3 to 6 latest talent listings, so it fills
two rows of the grid-3 instead of one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:05:41 +03:30
soroush.asadi 5fcdb8599f Add pagination to the Jobs / Shifts / Talent list pages
CI/CD / CI · dotnet build (push) Successful in 2m50s
CI/CD / Deploy · hamkadr (push) Successful in 5m42s
The list pages loaded EVERY matching listing into one page (/Jobs was a ~2.6MB page with 1000+
cards) — no pagination at all. Add server-side paging (24/page, DB Skip/Take; near-me still sorts
all by distance then paginates in memory). The header count now shows the true total, and a shared
_Pager partial renders prev/next + a windowed page list that preserves all active filters in the URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:27:41 +03:30
soroush.asadi ccc5a954dd Fold secondary nav links into a «بیشتر» dropdown
CI/CD / CI · dotnet build (push) Successful in 5m51s
CI/CD / Deploy · hamkadr (push) Successful in 3m9s
Move مراکز درمانی + تقویم هفتگی into a native <details> «بیشتر» dropdown, leaving the bar with just
the primary items: خانه · استخدام · شیفت‌ها · آماده به کار · بیشتر ▾ · 🔎. Desktop shows a floating
menu; in the mobile burger panel it expands inline. Closes on outside click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:05:29 +03:30
soroush.asadi e3750b7d43 Declutter nav: browse items in the bar, personal items in the profile menu
CI/CD / CI · dotnet build (push) Successful in 2m38s
CI/CD / Deploy · hamkadr (push) Has been cancelled
The top nav had grown to 9 flat, unordered items (browse + personal + utility mixed). Reorganize:
- Main nav = browse only, in a logical order: خانه · استخدام · شیفت‌ها · آماده به کار · مراکز درمانی · تقویم · 🔎 جستجو.
- Personal items ( پیشنهادها, ❤️ پسندیده‌ها) move into the profile dropdown for logged-in users
  (پیشنهادها still shows in the bar for anonymous visitors, who have no profile menu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:00:02 +03:30
soroush.asadi fce13aaeb0 Fix dark/low-contrast text on the homepage recommendations banner
CI/CD / CI · dotnet build (push) Successful in 3m58s
CI/CD / Deploy · hamkadr (push) Successful in 3m29s
The teal «پیشنهادهای ویژه شما» banner is an <a> that had inline color:inherit, which overrode the
.rec-banner white text with the dark body color — making the subtitle nearly unreadable. Use white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:51:16 +03:30
soroush.asadi 9fc83b231b Show real exception details to admins on the error page (diagnostics)
CI/CD / CI · dotnet build (push) Successful in 42s
CI/CD / Deploy · hamkadr (push) Successful in 1m4s
Production hides the exception behind a generic 500, so a logged-in Admin couldn''t see why a page
(e.g. /Admin/Settings) failed. Surface the exception type/message/inner/stack on the /Error page ONLY
when the current user is in the Admin role; everyone else still sees the generic message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:14:20 +03:30
soroush.asadi 2d4ea3a762 Fix «از X تا توافقی» salary display when only the minimum is known
CI/CD / CI · dotnet build (push) Successful in 3m50s
CI/CD / Deploy · hamkadr (push) Successful in 2m27s
The pay extractor now fills SalaryMin (e.g. «۳۱ م» -> 31M) but leaves SalaryMax null, which rendered
as «از ۳۱,۰۰۰,۰۰۰ تا توافقی ماهانه». Show «از ۳۱,۰۰۰,۰۰۰ تومان ماهانه» (from-only) in that case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:40:23 +03:30
soroush.asadi c1c914df9f Add per-user Like (پسندیدن) with a liked page and counts
CI/CD / CI · dotnet build (push) Successful in 2m54s
CI/CD / Deploy · hamkadr (push) Successful in 2m48s
Logged-in users can like a listing (job/shift/talent); dislike is removed per request — only likes.
- Like model (polymorphic by TargetType+TargetId) + EF migration; unique per (user, listing).
- POST /like toggles the like (auth required) and returns {liked, count}.
- Detail pages: the old ♡ Save / ✕ Dismiss buttons are replaced by a single heart Like button that
  shows the live count and toggles in place; clicking while logged out redirects to login.
- New «❤️ پسندیده‌ها» page (/Me/Liked) lists everything the user liked (open listings only), with a
  nav entry shown only when authenticated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:25:10 +03:30
soroush.asadi 39c866f4c7 Fix useless bare-divar.ir links + hide empty homepage shifts section
CI/CD / CI · dotnet build (push) Successful in 4m6s
CI/CD / Deploy · hamkadr (push) Successful in 3m35s
- Divar listings with no extractable post token were given SourceUrl «https://divar.ir» — a link
  that just opens Divar''s homepage, not the ad. Store null instead, and guard the contact-modal
  fallback to require a real path (so existing bare-domain links stop being offered too).
- Homepage «جدیدترین شیفت‌ها»: only render when there are real open shifts. Almost all aggregated
  ads are ongoing hiring (jobs), not dated shifts, so the section was showing a fabricated shift
  date (the «۱۸ خرداد» on the welcome page). Now it hides when empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:50:21 +03:30
soroush.asadi fdeefb7625 Move recommendations to a dedicated page + consolidate preferences there
CI/CD / CI · dotnet build (push) Successful in 49s
CI/CD / Deploy · hamkadr (push) Successful in 1m17s
The personalized «پیشنهادهای ویژه شما» feed lived on the homepage and its settings on a separate
/Preferences page. New /Recommendations page combines both — the recommendation cards plus the
preference controls (role/city/shift-type/pay/gender) that drive them, so the settings sit next to
their result. Saving prefs reloads the feed in place.

- Homepage: recommendation section replaced with a CTA card linking to /Recommendations; the model
  no longer loads recommendations.
- Nav: « پیشنهادها» entry added.
- /Preferences now redirects to /Recommendations (old links/bookmarks keep working).
- Page is NoIndex (personalized to the visitor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:41:17 +03:30
soroush.asadi 1f628d971e Default aggregated ads to Job, not Shift (stop fabricating shift dates/times)
CI/CD / CI · dotnet build (push) Successful in 1m54s
CI/CD / Deploy · hamkadr (push) Successful in 2m19s
A generic hiring ad like «پرستار درمانگاه» was published as a dated SHIFT with an invented date
(«فردا») and default hours («۰۸:۰۰–۱۴:۰۰») the source never stated — because classification defaulted
to Shift. Now a dated Shift is only produced when the text carries an explicit shift signal
(شیفت/آنکال/کشیک/نوبت); everything else is an ongoing hiring post → Job (no date to invent). Fixed in
both the parser default and the Publish branch (so an AI mislabel can''t force a shift either).

ReclassifyMisclassifiedShiftsAsync (in the post-ingest auto-cleanup) converts the existing signal-less
aggregated shifts into jobs in place — copies the content to a JobOpening and archives the old shift
(its URL 410s). After one pass it''s a no-op since new ads no longer become shifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:08:47 +03:30
soroush.asadi b3e7123d74 Extract Iranian salary shorthand (X تومان = millions) + pay backfill
CI/CD / CI · dotnet build (push) Successful in 2m15s
CI/CD / Deploy · hamkadr (push) Successful in 1m58s
Parser: most jobs read «توافقی» because the amount extractor only saw 6–10 digit numbers, missing
the way Iranian ads actually state pay — «۱۵ تومان»، «۴۰ تا ۵۰ تومان»، «۲۰ میلیون»، «۲۰م» all mean
MILLIONS of toman. Add colloquial detection (1–3 digit number + تومان/م/میلیون → ×1,000,000, lower
bound of a range), guarded so it never matches dates/hours or a long literal-toman figure. Also: a
stated amount now wins over «توافقی» (ads often say a number AND «… بقیه توافقی»).

Backfill: BackfillPayAsync re-parses existing aggregated jobs/talent that have no salary and fills
it in place (no AI, no ID/URL change) — wired into the post-ingest auto-cleanup and exposed as an
admin button. Existing «توافقی» listings with a stated number get their salary; genuinely-negotiable
ads stay توافقی. Also improves the baseSalary in JobPosting rich results.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:21:32 +03:30
soroush.asadi 219207ad68 SEO polish: facility structured data + trim homepage description
CI/CD / CI · dotnet build (push) Successful in 1m26s
CI/CD / Deploy · hamkadr (push) Successful in 1m57s
- Add SeoJsonLd.MedicalOrganization (Hospital/MedicalClinic schema with address, geo coords, and
  aggregateRating) and emit it on facility detail pages — only for real named facilities (not the
  «نامشخص» placeholder) — so Google can show a rich place result. Facility pages previously had no
  JSON-LD at all.
- Trim the homepage meta description from 193 to ~135 chars so Google doesn''t truncate it.

(Shift-detail canonical was a non-issue: the layout correctly omits canonical only on noindex pages,
which is what the audited past shift was.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:03:28 +03:30
soroush.asadi 410fc86c60 Fix maps not rendering: Neshan SDK URL was a 404
CI/CD / CI · dotnet build (push) Successful in 1m28s
CI/CD / Deploy · hamkadr (push) Successful in 1m24s
The map script loaded https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js,
which Neshan has removed (returns 404) — so window.L never defined, the init bailed, and NO map
rendered anywhere (detail pages + the facility-register picker). Switch to Neshan''s current SDK
(.../1.4.0/leaflet.js + leaflet.css, both 200). The init API is unchanged (new L.Map with the
maptype option), so no other code changes needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:13:29 +03:30
soroush.asadi b223d3af2d Collapse the sprawling role taxonomy (dedupe/compound/typo merge)
CI/CD / CI · dotnet build (push) Successful in 2m46s
CI/CD / Deploy · hamkadr (push) Successful in 2m5s
The dynamic taxonomy minted ~150 roles incl. exact triplicates («پرستار کودک» x3), multi-role
compounds («پرستار و بهیار»، «ماما / پرستار»، «پزشک و پرستار و بهیار»), and typos («بیهیار»، «بیار»).

Creation hardening: ResolveOrCreateRole now collapses a compound to its FIRST base role when that
segment is a known role (so «پرستار و بهیار»→«پرستار», but specialty names like «قلب و عروق»/«پوست
و مو» are left whole), and new aliases fold typos/synonyms (بیهیار/بیار→بهیار، فیزیوتراپ→فیزیوتراپیست،
نسخه پیچ→تکنسین داروخانه، پرستار بچه/اطفال→پرستار کودک).

Cleanup: MergeDuplicateRolesAsync (+ admin button) maps every role to a canonical form and merges
same-canonical roles into one keeper, repointing all shifts/jobs/talent/preferences/alerts/profiles
first (mirrors the manual /Admin/Roles merge). Combined with the no-fan-out change this should cut
the dropdown to a clean base set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:35:43 +03:30
soroush.asadi 2b7ac96472 Fix cramped job cards on facility detail page
CI/CD / CI · dotnet build (push) Successful in 1m33s
CI/CD / Deploy · hamkadr (push) Successful in 1m20s
The facility detail page used .layout-2 (sidebar-first, 270px + 1fr), but its MAIN content (the
shift/job cards) is the first child — so it was forced into the 270px column while the facility-info
sidebar took the wide 1fr, squeezing job cards into a one-word-per-line strip. Switch to
.detail-grid (content 1fr first, sidebar 340px second), matching the shift/job detail pages, so the
cards get the wide column. Became visible once facilities started carrying many openings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:32:16 +03:30
soroush.asadi 0334cac3dc Make source-link listings reachable + skip uncontactable applicants
CI/CD / CI · dotnet build (push) Successful in 1m33s
CI/CD / Deploy · hamkadr (push) Has been cancelled
(1) The contact modal only offered a click-through link for Divar sources, so medboom/
iranestekhdam/channel listings with no inline phone looked uncontactable. Offer the source link
for ANY source («مشاهده آگهی در منبع»), for talent, shifts, and jobs alike — rescuing the dead
applicant cards that actually have a source URL.
(2) At publish, skip an applicant («آماده به کار») that has NO contact path at all — no phone, no
contact channel, and no source URL. Such a card cannot reach anyone. Existing ones drop out when
the talent reprocess button rebuilds the board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:28:04 +03:30
soroush.asadi 98fc01be8e Reject filler/verb words as applicant names
CI/CD / CI · dotnet build (push) Successful in 1m42s
CI/CD / Deploy · hamkadr (push) Successful in 2m25s
The person-name extractor was grabbing the word after a title even when it was a verb/filler/
availability/role word, producing garbage headings like «خانم هستم»، «دکتر ام»، «دکتر داروساز
آماده». Stop collecting at a NameNoise word (هستم/ام/آماده/جویای/role words…), so a real name
(«دکتر سپیده علیزاده») still works but these fall back to the role heading. New ingests only;
existing rebuild via the talent reprocess button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:20:07 +03:30
soroush.asadi 33450a37ea Filter out home childcare / babysitter ads (not کادر درمان)
CI/CD / CI · dotnet build (push) Successful in 2m0s
CI/CD / Deploy · hamkadr (push) Successful in 3m10s
Divar «پرستار کودک/خانم شبانه‌روزی» ads are often a family hiring an in-home babysitter («پدر
کودک ۴ ساله هستم … نگهداری و مراقبت تمام‌وقت»), not clinical nursing. Add ChildcareMarkers
(نگهداری/بچه‌داری/«پدر|مادر کودک»/پرستار بچه …) and discard such ads as out of scope, alongside the
existing housekeeping filter. Clinical pediatric roles («بخش اطفال/کودکان/NICU») are unaffected.
New ingests are filtered at crawl; run «بایگانیِ درجا» to re-screen existing rows that have the
full text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:04:57 +03:30
soroush.asadi 17da713a35 Stop job/shift role fan-out: one aggregated ad = one listing
CI/CD / CI · dotnet build (push) Successful in 40s
CI/CD / Deploy · hamkadr (push) Successful in 2m18s
A single ad naming several role-ish words («استخدام بهیار جهت دستیار پزشک و تزریقات») was
fanning out into one listing PER extracted role — 5 near-duplicate cards with different and even
typo roles (پزشک عمومی، پرستار، دستیار پزشک، بهیار، «بیهیار»). Publish now creates ONE listing
with the primary (guard-corrected) role; other role words stay findable via the full description.
DedupeJobsAsync no longer keys on role, so existing fan-out copies collapse — preferring to keep a
non-«پزشک عمومی» copy, then the newest. Run the «حذفِ تکراری» + «اصلاح نقش» buttons to clean the
already-published fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:47:19 +03:30
soroush.asadi 92802d0da0 Show a Persian added-X-ago timestamp on listing cards
CI/CD / CI · dotnet build (push) Successful in 46s
CI/CD / Deploy · hamkadr (push) Successful in 58s
Add JalaliDate.TimeAgo(utc) returning «همین حالا»/«۲ ساعت پیش»/«۳ روز پیش»/«۲ هفته پیش»/«۴ ماه
پیش»/«۱ سال پیش», and display it (🕒) on the talent, job, and shift cards from their CreatedAt so
users can see how recent each listing is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:35:30 +03:30
soroush.asadi c778b87e79 Capture the full Divar ad description, not just the search-row summary
CI/CD / CI · dotnet build (push) Successful in 1m28s
CI/CD / Deploy · hamkadr (push) Successful in 2m22s
Divar listings showed only a one-line summary («پرستار کودک ۳ روز … — پرداخت توافقی — … در
شادمان») because the scraper stored the search-result row text and only pulled phone + coords from
the post detail. Now FetchDetailAsync also extracts the full ad body (the longest free-text string
in the detail JSON, skipping Divar safety boilerplate that mentions «دیوار») and appends it, so the
listing carries the rich description users see on Divar. Applies to new crawls; existing rows keep
their short text until re-ingested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:04:30 +03:30
soroush.asadi b1d0d0d4fd Fix empty hrefs on nav and homepage «مشاهده همه» links
CI/CD / CI · dotnet build (push) Successful in 1m49s
CI/CD / Deploy · hamkadr (push) Successful in 3m40s
The SEO routes added a required slug («شیفت/{roleSlug}»), which made asp-page=/Shifts/Index
and /Jobs/Index generate an EMPTY href whenever no slug was supplied — so the nav «شیفت‌ها/
استخدام» and the homepage «مشاهده همه» links did nothing (Talent, which has no custom route, worked).
Fix: make the slug optional ({roleSlug?}) so URL generation succeeds, and point the nav + homepage
view-all links at the plain /Shifts and /Jobs routes as a guaranteed fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:54:59 +03:30
soroush.asadi cdb58eeb86 Paginate the admin review queue (and flagged list)
CI/CD / CI · dotnet build (push) Successful in 1m59s
CI/CD / Deploy · hamkadr (push) Successful in 3m3s
The «صف بررسی» loaded every New/Flagged RawListing at once — endless scroll once a crawl fills
it. Page both at 20/row with «قبلی/بعدی» controls (independent q & f query params); the header
now shows the true totals, not the page size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:42:36 +03:30
soroush.asadi 7bbb4e385e Add in-place role-fix for existing «پزشک عمومی»-mislabeled listings
CI/CD / CI · dotnet build (push) Successful in 45s
CI/CD / Deploy · hamkadr (push) Successful in 2m5s
RecorrectDoctorRolesAsync (+ admin button «اصلاح نقش»): re-runs the keyword parser + doctor-role
guard over the stored text of existing aggregated listings currently labeled «پزشک عمومی», and
corrects RoleId + the generic title in place when the text actually names a more specific role
(dentist, «متخصص», lab, …). No AI call, no delete/recreate — IDs and indexed URLs unchanged, only
GP-labeled rows touched. Cleans up the dentist/ENT/«متخصص غدد» mislabels already published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:06:22 +03:30
soroush.asadi fbf8deaa8c Generalize doctor-role correction: trust the keyword parser over the AI default
CI/CD / CI · dotnet build (push) Successful in 53s
CI/CD / Deploy · hamkadr (push) Successful in 2m39s
Replace the per-role (dentist/specialist) patches with one rule: «پزشک عمومی» is the AI fallback,
so whenever the keyword parser already extracted a more specific role from the same text, use that
(dentist, lab, OR tech, mislabeled nurse, …). Falls back to «پزشک متخصص» when the text says
specialist but the parser found nothing more specific. Only ever overrides the weak GP default, so
genuine GP ads are untouched. Applies to new ingests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:01:58 +03:30
soroush.asadi d39546389e Correct dentist ads the AI labeled as general physician
CI/CD / CI · dotnet build (push) Successful in 1m57s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Extends the doctor-role guard: the AI defaults unclear (and even clearly non-GP) doctor ads to
«پزشک عمومی», so a dentist ad («دعوت به همکاری دندانپزشک») published as «استخدام پزشک عمومی».
When the chosen role is a generic doctor but the ad text says «دندانپزشک», correct it to
دندانپزشک (specialist correction stays for «متخصص/فوق تخصص/فلوشیپ»). Applies to new ingests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:59:24 +03:30
soroush.asadi 5c04658faf Unify recommendations across shifts AND jobs
CI/CD / CI · dotnet build (push) Successful in 38s
CI/CD / Deploy · hamkadr (push) Successful in 3m35s
Recommendations only scored open shifts, but almost all roles — doctors especially — exist as
استخدام (jobs), not dated shifts, and the only shifts are a handful of nurse shifts. So a visitor
who prefers «پزشک» got nurse-shift recommendations (scored by city/freshness) because there were
no doctor shifts to surface.

Now the engine scores BOTH shifts and job openings: role/city/facility/pay/freshness apply to
each, behavioral affinities are derived from shift AND job interest events, and the merged top-N
is returned. Recommendation can now carry a Shift or a JobOpening; the card renders either
(job → /Jobs/Details with employment type + salary; shift → unchanged with hour-bar). Cold start
interleaves the freshest of both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:47:15 +03:30
soroush.asadi 845d0c9013 Show job counts, not shifts-only, on public pages
CI/CD / CI · dotnet build (push) Successful in 1m55s
CI/CD / Deploy · hamkadr (push) Successful in 1m18s
The platform has ~1600 open استخدام but only ~4 dated شیفت (the VPN-free sources are hiring
boards, not shift channels), so the shifts-only counters read misleadingly low:
- Homepage stat pill «۴ شیفت باز» -> «موقعیت استخدام» (open job count).
- Facility cards «۰ شیفت باز» -> «N آگهی فعال» = open shifts + open (fresh) jobs, so a facility
  that is hiring no longer reads zero.
Also hide the «نامشخص / ثبت نشده» placeholder from the facilities list and sort active
facilities (then verified, then name) first, so real hiring centers surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:21:50 +03:30
soroush.asadi 3e65c88765 Strip generic facility descriptors so distinctive names dont false-merge
CI/CD / CI · dotnet build (push) Successful in 49s
CI/CD / Deploy · hamkadr (push) Successful in 1m1s
FacilityMatcher treated «شبانه روزی»/«خیریه»/«دولتی»/«خصوصی» as part of a name, so a real
facility merged into a generic one when they shared a descriptor — «درمانگاه شبانه‌روزی اسفند»
collapsed into the existing «پلی کلینیک شبانه روزی», losing «اسفند». Add these descriptors to
the stripped type-words so matching compares the distinctive core («اسفند») instead. Side
benefit: bare descriptor-only names («پلی کلینیک شبانه روزی») now resolve to junk and get
folded into the placeholder by the cleanup, rather than masquerading as a real facility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:00:00 +03:30
soroush.asadi 1c580e0f7a Fix role + contact mislabels seen on a live iranestekhdam ad
CI/CD / CI · dotnet build (push) Successful in 33s
CI/CD / Deploy · hamkadr (push) Successful in 44s
(1) Specialist guard: the AI sometimes labels a clearly-specialist ad («پزشک متخصص گوش و
حلق و بینی»، «فلوشیپ»، «فوق تخصص») as «پزشک عمومی», so an ENT post published as
«استخدام پزشک عمومی». When the primary role is GP but the ad text names a specialist, swap
it to «پزشک متخصص» (the subspecialty stays as a tag).

(2) Phone type: the landline regex 0\d{2,3} also matched 09xx MOBILE numbers and labeled them
«تلفن ثابت». Iranian landline area codes are 0[1-8]xx (021/026/…), never 09 — restrict it so
mobiles are no longer mislabeled as landlines.

Both apply to new ingests; existing mislabeled rows correct on turnover/reprocess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:29:43 +03:30
soroush.asadi b48e7dbc65 Auto-clean the board after every crawl (no manual cleanup clicks)
CI/CD / CI · dotnet build (push) Successful in 2m34s
CI/CD / Deploy · hamkadr (push) Successful in 2m4s
RunAsync now calls a new RunPostIngestCleanupAsync at the end of each crawl: archive
out-of-scope/duplicate listings, merge duplicate + fold junk facilities, and backfill missing
Tehran coords. All in-place, reversible for listings, guarded for facilities, and pure DB+CPU
(no AI/network) so it is cheap to run every ingest. The cleanup counts are appended to the
run-log detail. This keeps legacy + freshly-arrived junk from accumulating without the admin
having to click the cleanup buttons after each run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:19:11 +03:30
soroush.asadi bb8c6c3be5 Add medboom.ir as an ingestion source (doctor/dentist-heavy, VPN-free)
CI/CD / CI · dotnet build (push) Successful in 31s
CI/CD / Deploy · hamkadr (push) Successful in 3m15s
New MedboomListingSource: a WordPress medical-classifieds board crawled like medjobs
(wp-sitemap.xml -> posts-post-N.xml, newest first), filtered to clinical-role slugs and
Tehran-only for launch. medboom skews toward doctors/dentists/pharmacists and carries both
hiring and availability posts, so it directly broadens the role mix the nurse-heavy Divar
content lacks. Iranian-hosted -> no proxy/VPN needed (relevant now that Telegram is off).

Wired like the other sources: AppSetting toggles (MedboomEnabled/MaxAds/UseProxy) + EF
migration, SettingsService persistence, admin Settings UI, DI registration. Off by default.
Validated against live data: Tehran clinical ads at named clinics (pharmacy/dental/etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:18:56 +03:30
soroush.asadi 7740d9f8d7 iranestekhdam: restrict to Tehran for launch
CI/CD / CI · dotnet build (push) Successful in 2m0s
CI/CD / Deploy · hamkadr (push) Successful in 1m7s
Keep only ads located in Tehran: pre-drop slugs naming other major cities to save fetches,
then authoritatively keep ads whose text states «تهران» (the og:description reliably says
«شهر تهران»). Pool 5x candidates so the Tehran filter still yields a full batch. Validated
against live data: ~16/18 clinical candidates are Tehran. Nationwide expansion later becomes
a per-source city setting once the engine is proven.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:56:25 +03:30
soroush.asadi f118db55ef Add iranestekhdam.ir as an ingestion source (clinical job ads at named facilities)
CI/CD / CI · dotnet build (push) Successful in 1m43s
CI/CD / Deploy · hamkadr (push) Successful in 1m55s
New IranEstekhdamListingSource: reads the site monthly ad sitemaps
(sitemap-ads.xml -> sitemap-ads-YYYY-M.xml), keeps only ad URLs whose Persian slug names a
clinical role (veterinary/non-clinical excluded), then extracts each ad title + description
(+ phone). These are employer ads at NAMED facilities, so they directly improve the
unknown-facility problem the classifieds content has.

Wired in like Medjobs: AppSetting toggles (IranEstekhdamEnabled/MaxAds/UseProxy) + EF
migration, SettingsService persistence, admin Settings UI, and DI registration. Off by
default; the medical-gate validator + AI auditor + junk filters screen results downstream.

Note: e-estekhdam / jobinja / jobvision are JS-rendered SPAs whose ad lists are not in static
HTML, so they need API reverse-engineering (a separate effort), not this static-scrape path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:39:39 +03:30
soroush.asadi da55f82c6c Fix facility junk-fold: match the real placeholder by «نامشخص» marker
CI/CD / CI · dotnet build (push) Successful in 30s
CI/CD / Deploy · hamkadr (push) Successful in 1m0s
The junk-removal half of the facility cleanup silently no-op'd because it looked up the
shared placeholder by the exact UnknownFacilityName constant («نامشخص / ثبت نشده»), but
production data uses an older wording («مرکز درمانی (نامشخص)»), so the lookup returned null
and the whole junk pass was skipped (only the duplicate-merge half ran).

Now resolve the placeholder by the «نامشخص» marker and pick the bucket with the most
listings (the real one), and exclude it from the merge pass by id. Re-running the cleanup
will fold «بیمارستان هستم», «... از مدجابز», bare type-word facilities, etc. into it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:17:24 +03:30
soroush.asadi 88eca92333 Facility data hygiene: merge duplicates, drop junk-named facilities
CI/CD / CI · dotnet build (push) Successful in 1m51s
CI/CD / Deploy · hamkadr (push) Successful in 2m17s
Cleans up the crawl-generated facility table that surfaced garbage on /Facilities
(«بیمارستان هستم», «... از مدجابز», bare «کلینیک», «سازمان برنامه جنوبی» x3):

- FacilityMatcher.IsJunkName: shared detector for non-names — bare type words, cores
  made only of filler/verb tokens, and leaked crawl-source/placeholder text. Added
  داروخانه/آسایشگاه to the generic type words so bare ones are caught and dedupe better.
- HeuristicListingParser.ExtractFacilityName now rejects junk candidates (and emoji), so
  new ingests fall back to the shared placeholder instead of forging a fake facility.
- IngestionService.MergeAndCleanFacilitiesAsync (+ admin button): folds junk facilities
  into the placeholder and merges Persian-fuzzy duplicates into one keeper, repointing
  their shifts/jobs first. Hard guard: only purely crawl-generated, unmanaged facilities
  are removed — employer-owned and verified facilities are never touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:40:29 +03:30
soroush.asadi 8be275596b Make the listing purge SEO-standard: archive (not delete) + 410 Gone
CI/CD / CI · dotnet build (push) Successful in 49s
CI/CD / Deploy · hamkadr (push) Successful in 2m13s
Per the project archive-not-delete convention, the in-place purge now sets out-of-scope
and duplicate aggregated jobs/shifts to ShiftStatus.Archived instead of hard-deleting:
- The row is retained for analysis and the change is reversible.
- The listing drops out of every public screen and the sitemap (which filter Status == Open).
- Its detail page now returns 410 Gone (the standard permanent-removal signal) so search
  engines deindex it cleanly, instead of leaving the off-topic page live at 200 or hard-404ing.
Dedupe of job reposts archives the older copies the same way. Coordinate backfill now also
skips non-Open rows. Valid listings are untouched, so IDs/URLs stay stable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:25:51 +03:30
soroush.asadi e2011d335e Ingestion data-quality + map fixes: AI salary, geocode coverage, in-place backfill & purge
CI/CD / CI · dotnet build (push) Successful in 30s
CI/CD / Deploy · hamkadr (push) Successful in 1m11s
- Jobs now keep the AI-extracted salary (d.PayAmount ?? parsed.PayAmount); they
  previously used only the parser figure, so every aggregated opening showed «توافقی».
- Geocoder also scans the ad body, so Tehran ads that name a neighbourhood only in
  free text («… در سهروردی») get an approximate map point.
- New BackfillCoordsAsync (+ admin button): fills missing coords on existing aggregated
  listings from their stored text, in place — no ID/URL churn, SEO-safe.
- New PurgeInvalidAggregatedAsync + DedupeJobsAsync (+ admin button): in-place removal of
  out-of-scope (domestic/promo/spam) aggregated jobs/shifts and duplicate job reposts,
  keeping valid listings' IDs.
- Jobs detail page always renders the location card (matches Shifts) instead of hiding it
  when coords are missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:09:39 +03:30
soroush.asadi a16a805869 Hide facility/location when it's the «نامشخص» placeholder — omit, don't print it
CI/CD / CI · dotnet build (push) Successful in 1m6s
CI/CD / Deploy · hamkadr (push) Successful in 1m33s
When a listing's facility is the unknown placeholder, don't show «مرکز درمانی
(نامشخص)» anywhere — just leave the location out. Gated on HasRealEmployer:
- cards (shift/job/recommendation): the 🏥 facility line is omitted
- shift detail: H1 drops the «— نامشخص» suffix; title/description use city only;
  «شیفت‌های دیگر این مرکز» hidden; report label generic
- job detail: subtitle drops 🏥, keeps 📍 city; title/description city-only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:57:36 +03:30
soroush.asadi baa617daa9 Strip «آماده به کار» from role names + reject domestic-helper ads
CI/CD / CI · dotnet build (push) Successful in 2m3s
CI/CD / Deploy · hamkadr (push) Successful in 3m14s
Re-check of live applicants found two gaps:
- «کمک بهیار آماده به کار» — the availability phrase glued onto the role. StripRoleModifiers
  now removes «آماده به کار / آماده همکاری / جویای کار / جهت همکاری» phrases before
  token-stripping, so the role collapses to «کمک بهیار».
- «خانم امورسبک منزل» — light-housework domestic helpers (not کادر درمان). Validator
  now discards ads with «امور منزل / نظافت منزل / خدمتکار / مستخدم …» markers.

Both take effect for existing data on the next applicant reprocess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:58:06 +03:30
soroush.asadi 7e17e7ccb3 Stop leaking the shared placeholder facility's phone onto unrelated shifts/jobs
CI/CD / CI · dotnet build (push) Successful in 1m32s
CI/CD / Deploy · hamkadr (push) Successful in 1m21s
Shift/Job 426-style pages showed 09910540686 — the «نامشخص / ثبت نشده» placeholder
facility's phone, set once and shown on every unnamed-facility listing (and in the
contact modal), even though it isn't that ad's number. Now the facility phone/Bale
is only used as a fallback when the facility is a REAL named employer
(SeoJsonLd.HasRealEmployer); otherwise fall back to the Divar source link (if any)
or «شماره ثبت نشده». Fixed in the /contact modal endpoint and both detail-page
inline reveals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:50:12 +03:30
soroush.asadi f1a00cb955 Remove the call CTA from listing cards — contact only on the detail page
CI/CD / CI · dotnet build (push) Successful in 2m3s
CI/CD / Deploy · hamkadr (push) Successful in 46s
Cards had a 📞 contact-trigger that opened the call modal straight from the list.
Per request, calling should happen only on the post's detail page. Reverted each
card's CTA to a plain «جزئیات»/«مشاهده و تماس» button that just navigates to the
detail page (the whole card is already a link to it); the contact modal/trigger
now lives only on the shift/job/talent detail pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:44:07 +03:30
soroush.asadi cdca4ad264 Admin: role merge tool + usage list (taxonomy hygiene)
CI/CD / CI · dotnet build (push) Successful in 2m38s
CI/CD / Deploy · hamkadr (push) Successful in 2m7s
New /Admin/Roles screen lists every role with its shift/job/talent usage and lets
an admin merge a duplicate role into another — reassigns all listings (the Restrict
FKs) plus preferences/alerts/profiles to the target, then deletes the source — or
toggle a role's visibility. Linked from the admin panel nav (🏷️ نقش‌ها). Lets you
clean up dynamic-ingestion sprawl («کمک‌یار»→«کمک بهیار») without DB surgery.

Improvement 7 of the backlog (data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:21:23 +03:30
soroush.asadi 5e1b2ee979 ItemList JSON-LD on Jobs/Shifts list & landing pages
CI/CD / CI · dotnet build (push) Successful in 2m43s
CI/CD / Deploy · hamkadr (push) Successful in 1m24s
Mark up the result list as a schema.org ItemList (ordered listing URLs) so Google
reads the landing/list pages as a curated collection. Emitted alongside the
breadcrumb JSON-LD when there are results.

Improvement 6 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:15:12 +03:30
soroush.asadi 3edd21d2b6 Breadcrumbs: visible trail + BreadcrumbList JSON-LD
CI/CD / CI · dotnet build (push) Successful in 2m8s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Add SeoJsonLd.Breadcrumb + Crumb record + _Breadcrumbs partial, and wire a trail
into the Jobs/Shifts list (landing) and detail pages: خانه › استخدام/شیفت › {نقش}
› {شهر|عنوان}. The role crumb links to the role landing page (more internal
links), and Google can show the breadcrumb path in results. Detail pages emit it
alongside the existing JobPosting JSON-LD.

Improvement 5 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:12:38 +03:30
soroush.asadi 142136ebc9 Landing pages: unique intro paragraph (avoid thin content)
CI/CD / CI · dotnet build (push) Successful in 2m0s
CI/CD / Deploy · hamkadr (push) Successful in 2m33s
Role/city landing pages were heading + list only — thin-content risk that hurts
ranking. Add a short, unique-per-page intro (built from the dynamic heading) on
the Jobs/Shifts landing pages, with internal-link guidance. Generic /Jobs and
/Shifts stay as-is.

Improvement 4 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:07:32 +03:30
soroush.asadi 9bc3fdec79 Google for Jobs: only emit JobPosting JSON-LD for a real named employer
CI/CD / CI · dotnet build (push) Successful in 43s
CI/CD / Deploy · hamkadr (push) Successful in 1m16s
JobPosting requires a valid hiringOrganization; emitting «نامشخص / ثبت نشده» (the
placeholder for aggregated ads with no named center) makes Google reject the
posting and can flag invalid structured data across the site. Add
SeoJsonLd.HasRealEmployer and gate the JobPosting/ShiftPosting <script> on it, so
only listings with a genuine employer get marked up (those are the Jobs-eligible
ones anyway).

Improvement 3 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:03:14 +03:30
soroush.asadi a432fce858 Internal links to SEO landing pages (role quick-links on list pages)
CI/CD / CI · dotnet build (push) Successful in 46s
CI/CD / Deploy · hamkadr (push) Successful in 56s
The /استخدام/{role}/{city} and /شیفت/{role} landing pages were only reachable via
the sitemap — no internal links, which is weak for ranking. Add a role quick-link
chip strip to the Jobs and Shifts list pages linking to the per-role landing URLs.
Since those list pages ARE the landing pages, this also cross-links every landing
page to all the others, building an internal-link mesh that passes authority and
aids crawl far more than the sitemap alone.

Improvement 2 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:59:01 +03:30
soroush.asadi 8d0a403b36 Near-duplicate applicant detection (collapse source reposts)
CI/CD / CI · dotnet build (push) Successful in 1m57s
CI/CD / Deploy · hamkadr (push) Successful in 1m9s
Exact ContentHash dedup misses the same ad reposted with slightly different text
(e.g. the ~18 repeated «کمک‌یار آقا»). DedupeTalentAsync collapses open aggregated
applicants by two high-precision signals — identical phone, or identical
(role, city, normalized description core with digits/«… پیش» time-phrases
stripped) — keeping the newest of each group. Runs at the end of both RunAsync
and ReprocessAsync; removed count surfaces in the run log.

Improvement 1 of the data-quality/SEO backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:54:26 +03:30
soroush.asadi 21befd5b1e Display timestamps in Tehran time, not UTC
CI/CD / CI · dotnet build (push) Successful in 1m35s
CI/CD / Deploy · hamkadr (push) Successful in 3m1s
The server clock is correct (UTC); the app rendered UTC wall-clock directly, so
the run log showed ~3.5h behind Tehran. Add JalaliDate.ToTehran (flat UTC+3:30 —
Iran dropped DST in 2022) + DateTimeLabel, and convert the UTC-stored timestamp
displays (ingestion run log, RawListing FetchedAt, report CreatedAt). Shift
start/end inputs are TimeOnly, left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:16:57 +03:30
soroush.asadi fb7bfad9ce Reprocess: SEO-safe applicants-only default (don't churn indexed shift/job URLs)
CI/CD / CI · dotnet build (push) Successful in 2m11s
CI/CD / Deploy · hamkadr (push) Successful in 2m10s
Reprocess deletes+rebuilds aggregated listings, which changes their IDs. Shift/Job
detail pages are indexed and in the sitemap, so churning them would 404 ranked
URLs. «آماده به کار» pages are NoIndex + Disallow, so rebuilding them has zero SEO
impact — and that's where all the duplicate/sprawl problems were.

ReprocessAsync(talentOnly: true) now only deletes/rebuilds TalentListings and
skips non-talent raws (leaving shift/job listings + their RawListing links
untouched). Admin button relabelled «پردازش مجددِ آماده به کارها (امن برای SEO)».
Shifts/jobs self-clean via normal ingestion turnover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:08:20 +03:30
soroush.asadi e582597b20 Geocoding fallback: use the registered AI model when the table can't resolve
CI/CD / CI · dotnet build (push) Successful in 1m15s
CI/CD / Deploy · hamkadr (push) Successful in 1m1s
Where deterministic geocoding gives up (neighborhood not in the TehranGeo table),
fall back to the registered AI model: the auditor now also returns approximate
lat/lng for a recognized Tehran neighborhood (folded into the existing single
audit call — no extra requests), and Publish uses it only after the source ad and
the local table, and only when it falls inside greater Tehran (InTehran bbox
guard rejects hallucinated points). Coords order: Divar point → TehranGeo → AI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:48:42 +03:30
soroush.asadi 85a5191c45 AI qualify round 2: strip gender/seniority from roles, aide synonyms, more tag noise
CI/CD / CI · dotnet build (push) Successful in 1m17s
CI/CD / Deploy · hamkadr (push) Successful in 1m39s
Re-checked live data and found cases the first pass missed:
- Gender baked into roles («پرستار آقا», «کمک بهیار آقا») → StripRoleModifiers
  removes آقا/خانم/مرد/زن/کارآموز/ارشد… from role names (none of the real roles
  contain these), collapsing the sprawl; gender still lives on the Gender field.
- «کمک‌یار» vs «کمک بهیار» forking → alias maps them to one role.
- Personality words («خوش‌اخلاق», «دلسوز», «منظم»…) added to the tag stop-list.
- Prompt: gender goes to the gender field, not the role.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:41:06 +03:30
soroush.asadi 993c34758f Geocode neighborhood names to an approximate location (no source coords)
CI/CD / CI · dotnet build (push) Successful in 2m4s
CI/CD / Deploy · hamkadr (push) Successful in 1m54s
Many Medjobs/Telegram ads name a Tehran neighborhood («ونک», «تهرانپارس»…) but
carry no coordinates. New TehranGeo geocoder maps ~45 neighborhood names to a
rough center; Publish falls back to it (from the resolved district / AI district
/ area note) when the source ad has no point. Shown via the existing «محدودهٔ
تقریبی» circle + disclaimer — never a precise pin. Tehran-only; extends the
existing approx-coords feature so non-Divar listings can show a map too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:31:27 +03:30
soroush.asadi 4ab6ce29c9 Approximate-location map on aggregated listings (Divar coords)
CI/CD / CI · dotnet build (push) Successful in 1m59s
CI/CD / Deploy · hamkadr (push) Successful in 1m49s
We captured Divar's privacy-fuzzed coords on RawListing but discarded them for
the listings that need them: unnamed-facility shifts/jobs dropped them (to avoid
piling on the shared placeholder) and applicants had no coordinate field at all.

- Add Lat/Lng to Shift, JobOpening, TalentListing (migration ListingApproxCoords).
- Publish stores the source ad's approx coords on each aggregated listing.
- Detail pages render the map from the listing's own coords (fallback: facility),
  and aggregated coords show as a shaded «محدودهٔ تقریبی» circle (not a precise
  pin) via _NeshanMap data-approx, with a disclaimer. Applicants get a map card
  (they had none) + the page now loads the Neshan key.

Only Divar provides coords; the map needs NeshanMapKey set in admin settings.
Existing rows get coords once reprocessed (RawListing already has them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:10:05 +03:30
soroush.asadi 704b68be16 Search typeahead: show total found count in the dropdown
CI/CD / CI · dotnet build (push) Successful in 33s
CI/CD / Deploy · hamkadr (push) Successful in 1m29s
The /search/suggest endpoint now returns { items, total } — each filtered query
is reused for both the Take(5) preview and a CountAsync — and the dropdown's
footer link reads «مشاهده همه N نتیجه برای «q»» (Persian digits) instead of a
bare «همه نتایج». The /Search page already showed counts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:30:08 +03:30
soroush.asadi d62929ca0d AI qualify: de-dupe applicants, base roles, closed categories, tag hygiene + reprocess-stored action
CI/CD / CI · dotnet build (push) Successful in 2m35s
CI/CD / Deploy · hamkadr (push) Successful in 1m23s
Qualified live applicants and found three problems, all fixed:
- Duplicate cards: one ad fanned out into «پرستار» + «پرستار کودک» (same person).
  Applicants now publish ONE listing (no role fan-out); secondary roles → tags.
- Role sprawl: modifiers became roles. Prompt now returns the BASE profession
  and pushes age-group/ward/seniority to tags; new roles only for a genuinely
  new base profession (تکنسین داروخانه ✓, پرستار کودک ✗).
- Tag/category noise: categories pinned to the 5 fixed groups (+سایر, never
  invented); BuildTags drops pay/contact/location/fragment words.

Reprocess action: IngestionService.ReprocessAsync re-runs the current pipeline
over every stored RawListing WITHOUT re-fetching (keeps the raw text, so nothing
is lost to sources only exposing recent posts), deleting the old aggregated
posts and republishing cleanly. Admin dashboard button «پردازش مجددِ آیتم‌های
ذخیره‌شده» runs it on a background scope; result lands in the run-log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:24:20 +03:30
soroush.asadi 4c0b29addf Contact reveal modal: click phone/contact on cards and detail pages
CI/CD / CI · dotnet build (push) Successful in 2m26s
CI/CD / Deploy · hamkadr (push) Successful in 58s
Adds a lazy-loaded contact modal. Any element with data-contact-type +
data-contact-id (the «📞 تماس» button on shift/job/talent/recommendation cards,
and the contact CTA on the three detail pages) opens a modal that fetches the
listing's numbers from a new GET /contact endpoint and renders them with click-
to-call links. Numbers are loaded only on click, so they never sit in list-page
HTML (privacy / anti-scrape). The endpoint logs the same Apply interest signal
for shift/job that the old inline-reveal POST did, and falls back to the
facility phone (or Divar source link for talent) when an ad has no own contacts.

Verified locally: GET /contact?type=shift&id=1 → {title, contacts:[{value:
'021-82032000', href:'tel:...'}]}, and the modal opens and renders on the shift
detail page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:04:08 +03:30
soroush.asadi 0cf5b30dd8 SEO landing pages: dynamic role+city titles, pretty URLs, sitemap combos
CI/CD / CI · dotnet build (push) Successful in 4m5s
CI/CD / Deploy · hamkadr (push) Successful in 3m36s
Google Search Console shows all top queries are «استخدام [نقش] [شهر]», but the
filtered index pages all shared the generic title «موقعیت‌های استخدامی» and
weren't in the sitemap, so nothing ranked for those exact searches.

- Jobs/Shifts/Talent index pages now set a dynamic <title>/<h1>/meta from the
  active role+city (e.g. «استخدام پزشک عمومی در تهران»).
- Pretty SEO routes /استخدام/{role}/{city?} and /شیفت/{role}/{city?} (via
  AddPageRoute) resolve slugs → filters; unknown slug → 404. The layout already
  derives the canonical from the path, so each pretty URL is its own canonical
  and the query-string forms canonicalize to /Jobs (no duplicate content).
- sitemap.xml now lists role-only and role×city landing URLs for every combo
  with live listings (URL-encoded), so Google discovers them.
- New SeoSlug helper (Persian-tolerant: ي/ك, ZWNJ, hyphen/space).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:03:57 +03:30
soroush.asadi 38031cb189 Per-ad contacts for shifts/jobs, stale-applicant filter, review source link
CI/CD / CI · dotnet build (push) Successful in 1m3s
CI/CD / Deploy · hamkadr (push) Successful in 1m18s
Phone fix: shifts/jobs showed Facility.Phone, but unnamed ads all share one
placeholder facility, so every such listing displayed the same stale number
while the ad's real phone sat unused in the description. ContactMethod is now
attachable to a Shift/JobOpening (not just talent); ingestion stores the ad's
own number(s) on each listing and the detail pages render them (new
_ContactList partial), falling back to the facility phone only when the ad had
none. Migration ShiftJobContacts (nullable owner FKs) — auto-applies on deploy.

Stale applicants: skip «آماده به کار» posts older than 7 days at ingest, by the
source's real timestamp (Telegram <time>, Bale date) or a Persian time-ago
phrase in the text (Divar «۲ هفته پیش»). Recorded as Discarded; shifts/jobs
are not aged out.

Admin: Review page now shows a «مشاهده آگهی در منبع» link (RawListing.SourceUrl)
so the source post can be checked before publishing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:28:12 +03:30
soroush.asadi b71d8b362b Recommendation card: lead with the role, not the facility name
CI/CD / CI · dotnet build (push) Successful in 47s
CI/CD / Deploy · hamkadr (push) Successful in 1m11s
Same inversion as the shift card — the «پیشنهادهای ویژه شما» box headlined
Facility.Name («مرکز درمانی (نامشخص)»). Role is now the headline; facility
moves to the second line with 🏥 alongside the city.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:52:40 +03:30
soroush.asadi 337b510540 Shift card: lead with the role, not the facility name
CI/CD / CI · dotnet build (push) Successful in 6m7s
CI/CD / Deploy · hamkadr (push) Successful in 1m8s
The headline showed Facility.Name (often «مرکز درمانی (نامشخص)» for ingested
shifts) while the actual role was a tiny badge. Match _JobCard/_TalentCard:
role becomes the headline; facility moves to the second line with 🏥.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:10:33 +03:30
soroush.asadi efbf998caf Admin/Ingested: per-source breakdown (published vs total crawled)
CI/CD / CI · dotnet build (push) Successful in 2m42s
CI/CD / Deploy · hamkadr (push) Successful in 2m39s
Group RawListings by SourceChannel, fold per-channel/per-host labels into
source families (تلگرام/x → تلگرام, وب‌سایت (host) → وب‌سایت), and show a
published-vs-total table so it's clear which sources are actually producing
(e.g. why everything is coming from دیوار when Telegram's proxy is down).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:46:01 +03:30
soroush.asadi a03dcb1157 Divar geo-coords to facility map + medical gate + RawListing FK/geo migrations 2
CI/CD / CI · dotnet build (push) Successful in 1m15s
CI/CD / Deploy · hamkadr (push) Successful in 3m59s
2026-06-09 22:01:04 +03:30
soroush.asadi 380243b669 Divar geo-coords to facility map + medical gate + RawListing FK/geo migrations
CI/CD / CI · dotnet build (push) Successful in 2m6s
CI/CD / Deploy · hamkadr (push) Successful in 2m3s
2026-06-09 21:38:55 +03:30
soroush.asadi cf5e0011c4 AI ingestion: dynamic role/category creation + tags, hardcoded read-only prompt
CI/CD / CI · dotnet build (push) Successful in 2m19s
CI/CD / Deploy · hamkadr (push) Successful in 2m12s
- Unknown roles from the AI are now resolved-or-CREATED (Persian-normalized dedupe) instead of dropped/fallback; new role gets the AI's category, assigned to the applicant.
- AI output gains category + tags; AI-detected skills/requirements (ICU, MMT, پروانه‌دار…) now fold into the applicant's searchable Tags.
- System prompt is hardcoded in AppSetting.DefaultPrompt and used directly by the auditor; admin sees it read-only (cannot edit/break it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:04:24 +03:30
soroush.asadi 59fb30ac77 AI auditor: surface the real connection error instead of swallowing it
The Test-AI button called AuditAsync, which caught every exception and returned
null, and used EnsureSuccessStatusCode() (discarding the response body). So a
failing AI service only ever produced a generic 'no response' message with no
detail — impossible to diagnose.

- Add IAiAuditor.TestAsync: runs the real call and returns a detailed Persian
  diagnostic — HTTP status + response body on non-2xx, raw body when the shape
  isn't OpenAI-compatible, and network/proxy/timeout specifics on exceptions.
- AuditAsync now logs the actual HTTP status + response body (and proxy state)
  instead of a bare warning, so server logs show why a call failed.
- ExtractContent / ParseVerdict no longer throw on unexpected JSON; they return
  null so the caller can show the raw body.
- Settings 'Test AI' button uses TestAsync; result box renders multi-line and
  switches to alert-error styling when the test fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:30:12 +03:30
soroush.asadi 753a14286f Mobile hero search: compact magnify button + dropdown under the input
CI/CD / CI · dotnet build (push) Successful in 4m17s
CI/CD / Deploy · hamkadr (push) Successful in 4m30s
- Submit button is now a 44x44 magnify icon inside the search pill on mobile instead of a full-width stacked button (desktop keeps the جستجو text).
- Anchor the typeahead dropdown to the search pill so results appear directly under the input rather than below the popular-search chips; full pill width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 07:39:23 +03:30
soroush.asadi 62e9bf1353 Nav: replace inline search box with a «🔎 جستجو» link to /Search
CI/CD / CI · dotnet build (push) Successful in 2m39s
CI/CD / Deploy · hamkadr (push) Successful in 2m36s
The hero is now the primary search; the navbar just links to the search
page (cleaner header, less clutter on mobile). Typeahead remains on the
hero (form[data-suggest]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:26:37 +03:30
soroush.asadi c92744fb50 Mobile: smaller hero/heading typography so titles aren't oversized
Reduce hero h1/p, page/section headings, stat pills and the hero search
font sizes on phones (<=560px); tighter hero padding. Desktop unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:21:32 +03:30
91 changed files with 15930 additions and 626 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.0",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+23
View File
@@ -34,6 +34,7 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
public DbSet<IngestionRun> IngestionRuns => Set<IngestionRun>();
public DbSet<Review> Reviews => Set<Review>();
public DbSet<Like> Likes => Set<Like>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -156,9 +157,22 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
b.Entity<TalentListing>().HasIndex(t => t.Status);
b.Entity<TalentListing>().HasIndex(t => new { t.CityId, t.RoleId });
// A ContactMethod belongs to exactly one of talent / shift / job (all optional FKs).
b.Entity<ContactMethod>()
.HasOne(c => c.TalentListing).WithMany(t => t.Contacts)
.HasForeignKey(c => c.TalentListingId).OnDelete(DeleteBehavior.Cascade);
b.Entity<ContactMethod>()
.HasOne(c => c.Shift).WithMany(s => s.Contacts)
.HasForeignKey(c => c.ShiftId).OnDelete(DeleteBehavior.Cascade);
b.Entity<ContactMethod>()
.HasOne(c => c.JobOpening).WithMany(j => j.Contacts)
.HasForeignKey(c => c.JobOpeningId).OnDelete(DeleteBehavior.Cascade);
// One like per user per listing; fast count by target.
b.Entity<Like>().HasIndex(l => new { l.UserId, l.TargetType, l.TargetId }).IsUnique();
b.Entity<Like>().HasIndex(l => new { l.TargetType, l.TargetId });
b.Entity<Like>().HasOne(l => l.User).WithMany()
.HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
@@ -171,5 +185,14 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
// Dedupe ingested listings by content hash.
b.Entity<RawListing>().HasIndex(r => r.ContentHash);
b.Entity<RawListing>().HasIndex(r => r.Status);
// A RawListing only LINKS to the post it produced — it must outlive that post (it's the
// dedupe cache). So deleting a Shift/Talent NULLs the back-reference rather than orphaning a
// dangling FK or blocking the delete. LinkedTalentId previously had no FK at all (orphan risk).
b.Entity<RawListing>()
.HasOne(r => r.LinkedShift).WithMany()
.HasForeignKey(r => r.LinkedShiftId).OnDelete(DeleteBehavior.SetNull);
b.Entity<RawListing>()
.HasOne(r => r.LinkedTalent).WithMany()
.HasForeignKey(r => r.LinkedTalentId).OnDelete(DeleteBehavior.SetNull);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class RawListingLinkFks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings");
// LinkedTalentId never had an FK before, so existing rows may point at deleted talent.
// Null those orphans first, otherwise AddForeignKey below fails on a populated DB.
migrationBuilder.Sql(
"UPDATE \"RawListings\" r SET \"LinkedTalentId\" = NULL " +
"WHERE r.\"LinkedTalentId\" IS NOT NULL " +
"AND NOT EXISTS (SELECT 1 FROM \"TalentListings\" t WHERE t.\"Id\" = r.\"LinkedTalentId\");");
migrationBuilder.CreateIndex(
name: "IX_RawListings_LinkedTalentId",
table: "RawListings",
column: "LinkedTalentId");
migrationBuilder.AddForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings",
column: "LinkedShiftId",
principalTable: "Shifts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_RawListings_TalentListings_LinkedTalentId",
table: "RawListings",
column: "LinkedTalentId",
principalTable: "TalentListings",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings");
migrationBuilder.DropForeignKey(
name: "FK_RawListings_TalentListings_LinkedTalentId",
table: "RawListings");
migrationBuilder.DropIndex(
name: "IX_RawListings_LinkedTalentId",
table: "RawListings");
migrationBuilder.AddForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings",
column: "LinkedShiftId",
principalTable: "Shifts",
principalColumn: "Id");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class RawListingGeo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "RawListings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "RawListings",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Lat",
table: "RawListings");
migrationBuilder.DropColumn(
name: "Lng",
table: "RawListings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class ShiftJobContacts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "TalentListingId",
table: "ContactMethods",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AddColumn<int>(
name: "JobOpeningId",
table: "ContactMethods",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShiftId",
table: "ContactMethods",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ContactMethods_JobOpeningId",
table: "ContactMethods",
column: "JobOpeningId");
migrationBuilder.CreateIndex(
name: "IX_ContactMethods_ShiftId",
table: "ContactMethods",
column: "ShiftId");
migrationBuilder.AddForeignKey(
name: "FK_ContactMethods_JobOpenings_JobOpeningId",
table: "ContactMethods",
column: "JobOpeningId",
principalTable: "JobOpenings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ContactMethods_Shifts_ShiftId",
table: "ContactMethods",
column: "ShiftId",
principalTable: "Shifts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ContactMethods_JobOpenings_JobOpeningId",
table: "ContactMethods");
migrationBuilder.DropForeignKey(
name: "FK_ContactMethods_Shifts_ShiftId",
table: "ContactMethods");
migrationBuilder.DropIndex(
name: "IX_ContactMethods_JobOpeningId",
table: "ContactMethods");
migrationBuilder.DropIndex(
name: "IX_ContactMethods_ShiftId",
table: "ContactMethods");
migrationBuilder.DropColumn(
name: "JobOpeningId",
table: "ContactMethods");
migrationBuilder.DropColumn(
name: "ShiftId",
table: "ContactMethods");
migrationBuilder.AlterColumn<int>(
name: "TalentListingId",
table: "ContactMethods",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class ListingApproxCoords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "TalentListings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "TalentListings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "Shifts",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "Shifts",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "JobOpenings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "JobOpenings",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Lat",
table: "TalentListings");
migrationBuilder.DropColumn(
name: "Lng",
table: "TalentListings");
migrationBuilder.DropColumn(
name: "Lat",
table: "Shifts");
migrationBuilder.DropColumn(
name: "Lng",
table: "Shifts");
migrationBuilder.DropColumn(
name: "Lat",
table: "JobOpenings");
migrationBuilder.DropColumn(
name: "Lng",
table: "JobOpenings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class IranEstekhdamSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IranEstekhdamEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "IranEstekhdamMaxAds",
table: "AppSettings",
type: "integer",
nullable: false,
defaultValue: 40);
migrationBuilder.AddColumn<bool>(
name: "IranEstekhdamUseProxy",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IranEstekhdamEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "IranEstekhdamMaxAds",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "IranEstekhdamUseProxy",
table: "AppSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class MedboomSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MedboomEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "MedboomMaxAds",
table: "AppSettings",
type: "integer",
nullable: false,
defaultValue: 40);
migrationBuilder.AddColumn<bool>(
name: "MedboomUseProxy",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MedboomEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "MedboomMaxAds",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "MedboomUseProxy",
table: "AppSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class Likes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Likes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
TargetType = table.Column<int>(type: "integer", nullable: false),
TargetId = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Likes", x => x.Id);
table.ForeignKey(
name: "FK_Likes_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Likes_TargetType_TargetId",
table: "Likes",
columns: new[] { "TargetType", "TargetId" });
migrationBuilder.CreateIndex(
name: "IX_Likes_UserId_TargetType_TargetId",
table: "Likes",
columns: new[] { "UserId", "TargetType", "TargetId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Likes");
}
}
}
@@ -103,6 +103,24 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("IranEstekhdamEnabled")
.HasColumnType("boolean");
b.Property<int>("IranEstekhdamMaxAds")
.HasColumnType("integer");
b.Property<bool>("IranEstekhdamUseProxy")
.HasColumnType("boolean");
b.Property<bool>("MedboomEnabled")
.HasColumnType("boolean");
b.Property<int>("MedboomMaxAds")
.HasColumnType("integer");
b.Property<bool>("MedboomUseProxy")
.HasColumnType("boolean");
b.Property<bool>("MedjobsEnabled")
.HasColumnType("boolean");
@@ -293,10 +311,16 @@ namespace JobsMedical.Web.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("JobOpeningId")
.HasColumnType("integer");
b.Property<int?>("ShiftId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int>("TalentListingId")
b.Property<int?>("TalentListingId")
.HasColumnType("integer");
b.Property<int>("Type")
@@ -309,6 +333,10 @@ namespace JobsMedical.Web.Migrations
b.HasKey("Id");
b.HasIndex("JobOpeningId");
b.HasIndex("ShiftId");
b.HasIndex("TalentListingId");
b.ToTable("ContactMethods");
@@ -654,6 +682,12 @@ namespace JobsMedical.Web.Migrations
b.Property<int>("GenderRequirement")
.HasColumnType("integer");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<string>("Requirements")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
@@ -693,6 +727,36 @@ namespace JobsMedical.Web.Migrations
b.ToTable("JobOpenings");
});
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("TargetId")
.HasColumnType("integer");
b.Property<int>("TargetType")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TargetType", "TargetId");
b.HasIndex("UserId", "TargetType", "TargetId")
.IsUnique();
b.ToTable("Likes");
});
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
{
b.Property<long>("Id")
@@ -748,12 +812,18 @@ namespace JobsMedical.Web.Migrations
b.Property<DateTime>("FetchedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<int?>("LinkedShiftId")
.HasColumnType("integer");
b.Property<int?>("LinkedTalentId")
.HasColumnType("integer");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<string>("ParsedJson")
.HasColumnType("text");
@@ -783,6 +853,8 @@ namespace JobsMedical.Web.Migrations
b.HasIndex("LinkedShiftId");
b.HasIndex("LinkedTalentId");
b.HasIndex("Status");
b.ToTable("RawListings");
@@ -924,6 +996,12 @@ namespace JobsMedical.Web.Migrations
b.Property<int>("GenderRequirement")
.HasColumnType("integer");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<long?>("PayAmount")
.HasColumnType("bigint");
@@ -1002,6 +1080,12 @@ namespace JobsMedical.Web.Migrations
b.Property<bool>("IsLicensed")
.HasColumnType("boolean");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<long?>("PayAmount")
.HasColumnType("bigint");
@@ -1253,11 +1337,24 @@ namespace JobsMedical.Web.Migrations
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
{
b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
.WithMany("Contacts")
.HasForeignKey("JobOpeningId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("JobsMedical.Web.Models.Shift", "Shift")
.WithMany("Contacts")
.HasForeignKey("ShiftId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("JobsMedical.Web.Models.TalentListing", "TalentListing")
.WithMany("Contacts")
.HasForeignKey("TalentListingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("JobOpening");
b.Navigation("Shift");
b.Navigation("TalentListing");
});
@@ -1400,6 +1497,17 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
{
b.HasOne("JobsMedical.Web.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
{
b.HasOne("JobsMedical.Web.Models.User", "User")
@@ -1415,9 +1523,17 @@ namespace JobsMedical.Web.Migrations
{
b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
.WithMany()
.HasForeignKey("LinkedShiftId");
.HasForeignKey("LinkedShiftId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("JobsMedical.Web.Models.TalentListing", "LinkedTalent")
.WithMany()
.HasForeignKey("LinkedTalentId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LinkedShift");
b.Navigation("LinkedTalent");
});
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
@@ -1534,6 +1650,11 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Shifts");
});
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
{
b.Navigation("Contacts");
});
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
{
b.Navigation("Shifts");
@@ -1542,6 +1663,8 @@ namespace JobsMedical.Web.Migrations
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
{
b.Navigation("Applications");
b.Navigation("Contacts");
});
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
+54 -17
View File
@@ -81,6 +81,18 @@ public class AppSetting
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
public int MedjobsMaxAds { get; set; } = 40;
/// <summary>Scrape iranestekhdam.ir clinical job ads (crawled via its monthly ad sitemaps;
/// employer ads at named facilities, filtered to clinical-role slugs).</summary>
public bool IranEstekhdamEnabled { get; set; } = false;
public int IranEstekhdamMaxAds { get; set; } = 40;
public bool IranEstekhdamUseProxy { get; set; } = false;
/// <summary>Scrape medboom.ir clinical ads (WordPress board; doctor/dentist-heavy, hiring +
/// availability; crawled via its WP sitemap, Tehran-only for launch).</summary>
public bool MedboomEnabled { get; set; } = false;
public int MedboomMaxAds { get; set; } = 40;
public bool MedboomUseProxy { get; set; } = false;
// --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). ---
public bool SmsEnabled { get; set; } = false;
[MaxLength(200)] public string? SmsApiKey { get; set; }
@@ -138,23 +150,48 @@ public class AppSetting
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
/// <summary>The fixed, code-owned system prompt the AI follows. It is hardcoded (shown read-only
/// in admin) so it can't drift or be broken by an edit. The authoritative output-key schema is
/// appended automatically by <c>OpenAiCompatibleAuditor</c>, so this text stays behavioral.</summary>
public const string DefaultPrompt = """
تو دستیار بررسی آگهیهای کاری حوزه درمان برای پلتفرم «همکادر» هستی.
هر آگهی خام را بخوان و تصمیم بگیر:
- approve: آگهی واقعی و مرتبط با کادر درمان است و اطلاعات کافی دارد.
- reject: تبلیغ، اسپم، نامرتبط، یا فاقد اطلاعات حداقلی است.
- review: مرتبط است اما ناقص/مبهم و نیاز به بررسی انسانی دارد.
سه نوع آگهی داریم:
- shift: مرکز درمانی برای یک شیفت نیرو میخواهد.
- job: مرکز درمانی برای استخدام دائم نیرو میخواهد.
- talent: خودِ کادر درمان اعلام «آماده به کار / آماده همکاری» کرده است.
نقش، شهر/محله، نوع شیفت/همکاری، مبلغ یا درصد سهم، عنوان، نام مرکز، و شماره تماس را در صورت وجود استخراج کن.
برای talent: نام فرد، سال سابقه و پروانهدار بودن را هم استخراج کن.
فقط با یک شیء JSON پاسخ بده با کلیدهای:
decision (approve|reject|review)، confidence (0-100)، reason (فارسی کوتاه)،
kind (shift|job|talent)، role، city، district، shiftType (day|evening|night|oncall)،
employmentType (fulltime|parttime|contract|plan)، payAmount (عدد تومان یا null)،
sharePercent (0-100 یا null)، title، facilityName، phone،
personName، yearsExperience (عدد یا null)، isLicensed (true|false).
تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
(۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافهای ننویس.
نوع (kind):
shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو میخواهد.
job = مرکز درمانی استخدام دائم/قراردادی دارد.
talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی میکند
(سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهمترین فیلد است).
نقش (role) و گروه (category):
اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
نقش را به «حرفهٔ پایه» بنویس، نه با پیشوند/پسوندِ توصیفی. گروهِ سنی، بخش، سطح، یا جنسیت را در
نقش نیاور و بهجایش در tags (و جنسیت را در فیلد gender) بگذار:
«پرستار کودک» نقش «پرستار» + تگ «کودک»
«پرستار آقا» نقش «پرستار» + جنسیت «آقا»
«پرستار اورژانس» نقش «پرستار» + تگ «اورژانس»
«کارآموز تکنسین داروخانه» نقش «تکنسین داروخانه» + تگ «کارآموز»
فقط وقتی نقشِ جدید بساز که یک «حرفهٔ پایهٔ متفاوت» باشد که در فهرست نیست (مثل «تکنسین داروخانه»،
«کارشناس رادیولوژی»، «شنواییسنج»). نقش جدید را کوتاه و رسمی بنویس، نه جمله.
category را فقط یکی از این پنج گروه بگذار: پزشک | پرستار | ماما | تکنسین | دندانپزشک.
اگر نقش در هیچکدام نگنجید، category = «سایر». هرگز گروهِ جدید نساز.
مهارتها/الزامات (tags): فقط کلیدواژههای بالینی و مرتبط را بهصورت آرایه برگردان مهارت،
بخش، گواهی، گروه سنی، سطح، یا شرط (مثل "ICU"، "NICU"، "دیالیز"، "اتاق عمل"، "کودک"، "سالمند",
"MMT"، "CPR"، "پروانه‌دار"، "خانم"، "آقا"). هرگز مبلغ/پرداخت/توافقی، شماره تماس، شهر/محله، یا
جملهٔ ناقص را بهعنوان تگ نگذار. اگر چیزی نبود [].
شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار.
تصمیم (decision):
approve = آگهیِ واقعیِ مرتبط با کادر درمان تهران با اطلاعات کافی.
reject = اسپم/تبلیغ/نامرتبط/خارج از کادر درمانِ تهران.
review = مرتبط ولی مبهم/ناقص.
confidence را ۰ تا ۱۰۰ بده و reason را کوتاه و فارسی بنویس.
برای talent: personName، yearsExperience، isLicensed (پروانهدار) و phone (ارقام لاتین)
را در صورت ذکر پر کن. هر فیلدِ نامشخص = null.
""";
}
+13 -5
View File
@@ -3,16 +3,24 @@ 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.).
/// One contact channel for a listing — an applicant («آماده به کار»), a <see cref="Shift"/>, or a
/// <see cref="JobOpening"/>. 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.). Exactly one owner FK is set.
/// </summary>
public class ContactMethod
{
public int Id { get; set; }
public int TalentListingId { get; set; }
public TalentListing TalentListing { get; set; } = null!;
// Owner — exactly one of these is non-null.
public int? TalentListingId { get; set; }
public TalentListing? TalentListing { get; set; }
public int? ShiftId { get; set; }
public Shift? Shift { get; set; }
public int? JobOpeningId { get; set; }
public JobOpening? JobOpening { get; set; }
public ContactType Type { get; set; }
+3
View File
@@ -119,6 +119,9 @@ public enum ContactType
Other = 8 // سایر
}
/// <summary>What a <see cref="Like"/> points at.</summary>
public enum LikeTargetType { Shift = 0, Job = 1, Talent = 2 }
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
+8
View File
@@ -40,8 +40,16 @@ public class JobOpening
[MaxLength(500)]
public string? SourceUrl { get; set; }
// APPROXIMATE coords from the source ad (Divar) for aggregated openings without a facility address.
public double? Lat { get; set; }
public double? Lng { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Contact channels harvested from the source ad (aggregated openings). When empty, the
/// detail page falls back to the facility's phone.</summary>
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
// Transient: distance (km) when "near me" is active. Not persisted.
[NotMapped] public double? DistanceKm { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
namespace JobsMedical.Web.Models;
/// <summary>
/// A logged-in user's «پسندیدن» of a listing (shift / job / talent). One row per (user, listing);
/// toggling removes it. Polymorphic by <see cref="TargetType"/> + <see cref="TargetId"/> so one table
/// covers all three listing kinds. The count of rows for a target is the public "likes" number.
/// </summary>
public class Like
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
public LikeTargetType TargetType { get; set; }
public int TargetId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+6
View File
@@ -25,10 +25,16 @@ public class RawListing
public Shift? LinkedShift { get; set; }
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساخته‌شده از این متن
public TalentListing? LinkedTalent { get; set; }
[MaxLength(500)]
public string? SourceUrl { get; set; }
/// <summary>Approximate coordinates harvested from the source (e.g. Divar's fuzzed map center).
/// Carried through the review queue so a manual publish can still place the facility on the map.</summary>
public double? Lat { get; set; }
public double? Lng { get; set; }
/// <summary>SHA-256 of the normalized text — used to dedupe across ingestion runs.</summary>
[MaxLength(64)]
public string? ContentHash { get; set; }
+9
View File
@@ -40,10 +40,19 @@ public class Shift
[MaxLength(500)]
public string? SourceUrl { get; set; } // لینک منبع در صورت جمع‌آوری از کانال
// APPROXIMATE coords from the source ad (Divar's privacy-fuzzed center) for aggregated shifts
// whose facility has no address. Shown as a «محدودهٔ تقریبی» circle, never a precise pin.
public double? Lat { get; set; }
public double? Lng { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Application> Applications { get; set; } = new List<Application>();
/// <summary>Contact channels harvested from the source ad (aggregated shifts). When empty, the
/// detail page falls back to the facility's phone.</summary>
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
// Transient: distance (km) from the visitor when "near me" is active. Not persisted.
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public double? DistanceKm { get; set; }
@@ -59,6 +59,11 @@ public class TalentListing
[MaxLength(500)]
public string? SourceUrl { get; set; }
// APPROXIMATE coords from the source ad (Divar) — an applicant has no facility, so this is the
// only location we have. Shown as a «محدودهٔ تقریبی» circle (the area they're available in).
public double? Lat { get; set; }
public double? Lng { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Transient: distance (km) when "near me" is active. Not persisted.
+95 -4
View File
@@ -9,8 +9,8 @@
<h1>پنل مدیریت — جمع‌آوری و صف آگهی‌ها</h1>
<p class="muted">
آگهی‌های جمع‌آوری‌شده از منابع را بررسی، ساختارمند و منتشر کن.
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در صف،
@JalaliDate.ToPersianDigits(Model.Flagged.Count.ToString()) پرچم‌خورده)
(@JalaliDate.ToPersianDigits(Model.QueueTotal.ToString()) در صف،
@JalaliDate.ToPersianDigits(Model.FlaggedTotal.ToString()) پرچم‌خورده)
· <a asp-page="/Admin/Overview">داشبورد</a>
· <a asp-page="/Admin/Users">کاربران</a>
· <a asp-page="/Admin/Facilities">مراکز</a>
@@ -40,6 +40,77 @@
<p class="muted" style="font-size:11px; margin:8px 0 0;">
موتور: واکشی ← حذف تکراری ← تجزیه ← اعتبارسنجی ← صف بررسی.
</p>
<form method="post" onsubmit="return confirm('⚠ همه‌ی آیتم‌های جمع‌آوری‌شده (کش) و همه‌ی آگهی‌های منتشرشده از جمع‌آوری حذف می‌شوند (آگهی‌های ثبت‌شده توسط مراکز دست‌نخورده می‌مانند)، سپس همه‌چیز با هوش مصنوعی دوباره جمع‌آوری و افزوده می‌شود. این کار بازگشت‌ناپذیر است. ادامه می‌دهی؟');">
<button type="submit" asp-page-handler="PurgeAndReingest" class="btn btn-outline btn-block" style="margin-top:8px; color:var(--danger); border-color:var(--danger);">
🔄 پاک‌سازی کش و جمع‌آوری مجدد با هوش مصنوعی
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
کش حذف تکراری و آگهی‌های جمع‌آوری‌شده پاک و از نو با AI پردازش می‌شوند. (آگهی‌های مراکز حذف نمی‌شوند.)
</p>
<form method="post" onsubmit="return confirm('آگهی‌های «آماده به کار» از روی متنِ خامِ ذخیره‌شده (بدون واکشی) دوباره با هوش مصنوعی پردازش می‌شوند — برای پاک‌سازی (حذف موارد تکراری، اصلاح نقش/گروه/تگ، افزودن موقعیت تقریبی). شیفت/استخدام دست‌نخورده می‌مانند (برای حفظ SEO). هیچ آیتمی از دست نمی‌رود. در پس‌زمینه اجرا می‌شود. ادامه؟');">
<button type="submit" asp-page-handler="ReprocessStored" class="btn btn-primary btn-block" style="margin-top:10px;">
🧹 پردازش مجددِ «آماده به کار»‌ها (امن برای SEO)
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
توصیه‌شده برای پاک‌سازیِ آماده‌به‌کارها: متنِ خام نگه داشته می‌شود و فقط با منطقِ جدید (یک‌نفر=یک‌آگهی، نقش پایه، گروه ثابت، تگ تمیز، موقعیت تقریبی) بازساخته می‌شوند. صفحاتِ «آماده به کار» ایندکس نمی‌شوند، پس آدرسِ ایندکس‌شده‌ای تغییر نمی‌کند؛ شیفت/استخدام به‌مرور با ایمیجستِ تازه پاک می‌شوند.
</p>
<form method="post" onsubmit="return confirm('برای آگهی‌های جمع‌آوری‌شدهٔ تهران که موقعیت روی نقشه ندارند، از روی متنِ آگهی محلهٔ تقریبی پیدا و مختصات تنظیم می‌شود. شناسه و آدرس صفحات تغییر نمی‌کند (امن برای SEO). ادامه؟');">
<button type="submit" asp-page-handler="BackfillCoords" class="btn btn-primary btn-block" style="margin-top:10px;">
📍 تکمیل موقعیتِ نقشه برای آگهی‌های موجود
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
شیفت/استخدام/آماده‌به‌کارِ جمع‌آوری‌شده‌ای که مختصات ندارند، از روی محلهٔ ذکرشده در متنِ آگهی روی نقشه قرار می‌گیرند (محدودهٔ تقریبی). فقط مختصاتِ خالی پر می‌شود؛ موقعیتِ واقعیِ مراکز دست‌نخورده می‌ماند.
</p>
<form method="post">
<button type="submit" asp-page-handler="BackfillPay" class="btn btn-primary btn-block" style="margin-top:10px;">
💰 استخراجِ حقوق برای آگهی‌های «توافقی»
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
آگهی‌هایی که حقوقشان «توافقی» است ولی در متن مبلغ دارند (مثل «۴۰ تا ۵۰ تومان» = میلیون)، مبلغشان استخراج و ثبت می‌شود (درجا، بدون تغییر شناسه/آدرس).
</p>
<form method="post" onsubmit="return confirm('آگهی‌های جمع‌آوری‌شدهٔ شیفت/استخدام که اکنون خارج از حوزه‌اند (خدمات منزل/نظافت، تبلیغاتی/آموزشی، اسپم) و استخدام‌های تکراری «بایگانی» می‌شوند: از سایت پنهان می‌شوند ولی ردیفشان نگه داشته می‌شود (قابل بازگشت). آگهی‌های معتبر و شناسه/آدرسشان دست‌نخورده می‌ماند. ادامه؟');">
<button type="submit" asp-page-handler="PurgeInvalid" class="btn btn-outline btn-block" style="margin-top:10px; color:var(--danger); border-color:var(--danger);">
🧽 بایگانیِ درجای آگهی‌های خارج از حوزه و تکراری (شیفت/استخدام)
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
فقط آگهی‌هایی که با صافیِ فعلی «خارج از حوزه» تشخیص داده می‌شوند (نه صرفاً ناقص) و استخدام‌های تکراری بایگانی می‌شوند (وضعیت «بایگانی»، نه حذف). آگهی‌های معتبر دست‌نخورده‌اند، پس آدرسِ ایندکس‌شده‌شان تغییر نمی‌کند؛ صفحهٔ موارد بایگانی‌شده ۴۱۰ Gone می‌دهد تا گوگل تمیز حذفشان کند.
</p>
<form method="post" onsubmit="return confirm('مراکز درمانیِ تکراری ادغام و مراکزِ بی‌نام/نامعتبر (مثل «بیمارستان هستم» یا «از مدجابز») حذف می‌شوند؛ آگهی‌هایشان به مرکزِ معتبر یا «نامشخص» منتقل می‌شود. مراکزِ ثبت‌شده توسط کارفرما یا تأییدشده دست‌نخورده می‌مانند. ادامه؟');">
<button type="submit" asp-page-handler="CleanFacilities" class="btn btn-primary btn-block" style="margin-top:10px;">
🏥 ادغام مراکز تکراری و حذف مراکز بی‌نام
</button>
</form>
<form method="post">
<button type="submit" asp-page-handler="RecorrectRoles" class="btn btn-primary btn-block" style="margin-top:10px;">
🩺 اصلاح نقشِ آگهی‌های «پزشک عمومی» (دندانپزشک/متخصص و …)
</button>
</form>
<form method="post" onsubmit="return confirm('نقش‌های تکراری/ترکیبی/غلط‌املایی (مثل «پرستار کودک» سه‌تایی، «پرستار و بهیار»، «بیهیار») در نقش‌های اصلی ادغام و حذف می‌شوند؛ آگهی‌هایشان به نقشِ معتبر منتقل می‌شود. ادامه؟');">
<button type="submit" asp-page-handler="MergeRoles" class="btn btn-primary btn-block" style="margin-top:10px;">
🏷️ ادغام نقش‌های تکراری/ترکیبی/غلط‌املایی
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
نقش‌های هم‌معنا (تکراری، ترکیبی مثل «پرستار و بهیار»، یا غلط‌املایی مثل «بیهیار») در یک نقشِ پایه ادغام می‌شوند تا فهرستِ نقش‌ها تمیز شود. مدیریتِ دستی در <a asp-page="/Admin/Roles">نقش‌ها</a>.
</p>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
آگهی‌هایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح می‌شوند (درجا، بدون تغییر شناسه/آدرس).
</p>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
مراکز تکراری (با تطبیقِ فارسی) در یک رکورد ادغام و مراکزِ بدونِ نامِ واقعی به «نامشخص» منتقل می‌شوند. آگهی‌ها حفظ می‌شوند؛ فقط مراکزِ جمع‌آوری‌شده و مدیریت‌نشده پاک می‌شوند.
</p>
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
@@ -85,7 +156,7 @@
@foreach (var run in Model.Runs)
{
<tr style="border-top:1px solid var(--line);" title="@run.Detail">
<td style="padding:6px 8px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(run.RunAt)) @run.RunAt.ToString("HH:mm")</td>
<td style="padding:6px 8px;">@JalaliDate.DateTimeLabel(run.RunAt)</td>
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Fetched.ToString())</td>
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Queued.ToString())</td>
<td style="padding:6px 8px; color:var(--primary-dark); font-weight:700;">@JalaliDate.ToPersianDigits(run.Published.ToString())</td>
@@ -110,9 +181,19 @@
{
<partial name="_RawListingRow" model="r" />
}
@if (Model.QueuePages > 1)
{
<div class="row" style="display:flex; gap:10px; justify-content:center; align-items:center; margin-top:14px;">
@if (Model.QueuePage > 1)
{ <a class="btn btn-outline" asp-route-q="@(Model.QueuePage - 1)" asp-route-f="@Model.FlaggedPage">→ قبلی</a> }
<span class="muted">صفحه @JalaliDate.ToPersianDigits(Model.QueuePage.ToString()) از @JalaliDate.ToPersianDigits(Model.QueuePages.ToString())</span>
@if (Model.QueuePage < Model.QueuePages)
{ <a class="btn btn-outline" asp-route-q="@(Model.QueuePage + 1)" asp-route-f="@Model.FlaggedPage">بعدی ←</a> }
</div>
}
}
@if (Model.Flagged.Count > 0)
@if (Model.FlaggedTotal > 0)
{
<h2 style="font-size:20px; margin-top:28px;">پرچم‌خورده (ناقص/مشکوک)</h2>
<p class="muted" style="font-size:13px;">اعتبارسنجی این‌ها را کامل ندانست؛ در صورت صحت می‌توانی منتشرشان کنی.</p>
@@ -120,6 +201,16 @@
{
<partial name="_RawListingRow" model="r" />
}
@if (Model.FlaggedPages > 1)
{
<div class="row" style="display:flex; gap:10px; justify-content:center; align-items:center; margin-top:14px;">
@if (Model.FlaggedPage > 1)
{ <a class="btn btn-outline" asp-route-q="@Model.QueuePage" asp-route-f="@(Model.FlaggedPage - 1)">→ قبلی</a> }
<span class="muted">صفحه @JalaliDate.ToPersianDigits(Model.FlaggedPage.ToString()) از @JalaliDate.ToPersianDigits(Model.FlaggedPages.ToString())</span>
@if (Model.FlaggedPage < Model.FlaggedPages)
{ <a class="btn btn-outline" asp-route-q="@Model.QueuePage" asp-route-f="@(Model.FlaggedPage + 1)">بعدی ←</a> }
</div>
}
}
</div>
</div>
+138 -5
View File
@@ -13,15 +13,26 @@ public class IndexModel : PageModel
{
private readonly AppDbContext _db;
private readonly IngestionService _ingest;
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<IndexModel> _log;
public IndexModel(AppDbContext db, IngestionService ingest)
public IndexModel(AppDbContext db, IngestionService ingest, IServiceScopeFactory scopes, ILogger<IndexModel> log)
{
_db = db;
_ingest = ingest;
_scopes = scopes;
_log = log;
}
public List<RawListing> Queue { get; private set; } = new();
public List<RawListing> Flagged { get; private set; } = new();
public const int PageSize = 20;
public int QueuePage { get; private set; } = 1;
public int QueueTotal { get; private set; }
public int FlaggedPage { get; private set; } = 1;
public int FlaggedTotal { get; private set; }
public int QueuePages => Math.Max(1, (int)Math.Ceiling(QueueTotal / (double)PageSize));
public int FlaggedPages => Math.Max(1, (int)Math.Ceiling(FlaggedTotal / (double)PageSize));
public IReadOnlyList<string> SourceNames { get; private set; } = new List<string>();
public int PublishedShifts { get; private set; }
public int PublishedJobs { get; private set; }
@@ -32,7 +43,7 @@ public class IndexModel : PageModel
[TempData] public string? IngestMessage { get; set; }
public async Task OnGetAsync() => await LoadAsync();
public async Task OnGetAsync(int q = 1, int f = 1) => await LoadAsync(q, f);
public async Task<IActionResult> OnPostAddAsync()
{
@@ -65,14 +76,136 @@ public class IndexModel : PageModel
return RedirectToPage();
}
private async Task LoadAsync()
/// <summary>
/// DESTRUCTIVE rebuild, in two distinct deletes:
/// 1. The DEDUPE CACHE — ALL RawListings, including any added via «افزودن دستی». These are not
/// published content; they're the crawl/staging rows whose ContentHash blocks re-ingesting
/// the same ad. Wiping them lets everything be re-fetched and re-judged by the AI.
/// 2. AGGREGATED listings only — Shifts/JobOpenings/TalentListings with Source==Aggregated, i.e.
/// produced by ingestion. Employer/admin-posted listings (Source==Direct) are left untouched.
/// Then re-fetch everything and re-run it through the (now AI-enabled) pipeline.
/// RawListings are deleted first so their LinkedShift/LinkedTalent FKs (SetNull) don't dangle;
/// DB cascade clears ContactMethods / Applications / InterestEvents when the posts are deleted.
/// </summary>
public async Task<IActionResult> OnPostPurgeAndReingestAsync()
{
int rawCount, shifts, jobs, talent;
await using (var tx = await _db.Database.BeginTransactionAsync())
{
rawCount = await _db.RawListings.ExecuteDeleteAsync(); // clear dedupe cache
shifts = await _db.Shifts.Where(s => s.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
jobs = await _db.JobOpenings.Where(j => j.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
talent = await _db.TalentListings.Where(t => t.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
await tx.CommitAsync();
}
var s = await _ingest.RunAsync(); // fresh fetch → AI audit → publish/queue
IngestMessage = $"پاک‌سازی شد (حذف: {rawCount} آیتم کش، {shifts} شیفت، {jobs} استخدام، {talent} آماده‌به‌کارِ جمع‌آوری‌شده). " +
$"جمع‌آوری مجدد: {s.TotalPublished} منتشر، {s.TotalQueued} در صف، {s.TotalFlagged} پرچم، {s.TotalSpam} اسپم، {s.TotalDuplicates} تکراری.";
return RedirectToPage();
}
/// <summary>
/// Clean up EXISTING aggregated content by re-running the current pipeline over the stored raw
/// text — no re-fetch, so nothing is lost to sources only exposing recent posts. Long-running
/// (one AI call per item), so it runs on a background scope and returns immediately; the result
/// shows up as a new row in the «تاریخچهٔ اجرا» log when it finishes.
/// </summary>
public IActionResult OnPostReprocessStored()
{
_ = Task.Run(async () =>
{
using var scope = _scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IngestionService>();
var log = scope.ServiceProvider.GetRequiredService<ILogger<IndexModel>>();
// talentOnly: «آماده به کار» is NoIndex/Disallow → rebuilding it doesn't churn any indexed
// URL. Shift/Job detail pages ARE indexed, so they're left to self-clean via turnover.
try { await svc.ReprocessAsync(talentOnly: true); }
catch (Exception ex) { log.LogError(ex, "Background reprocess failed"); }
});
IngestMessage = "پردازش مجدد آیتم‌های ذخیره‌شده در پس‌زمینه آغاز شد. نتیجه پس از اتمام در «تاریخچهٔ اجرا» نمایش داده می‌شود (بسته به تعداد آیتم‌ها و سرعت هوش مصنوعی، چند دقیقه طول می‌کشد).";
return RedirectToPage();
}
/// <summary>
/// Fill missing map coordinates on existing aggregated Tehran listings from their stored ad text
/// (TehranGeo). In place — no AI calls, no re-fetch, and crucially no delete/recreate, so indexed
/// shift/job URLs keep their IDs. Fast (pure DB + string matching), so it runs inline.
/// </summary>
public async Task<IActionResult> OnPostBackfillCoordsAsync()
{
var n = await _ingest.BackfillCoordsAsync();
IngestMessage = $"مختصات تقریبی برای {n} آگهی جمع‌آوری‌شده از روی متن آگهی تکمیل شد (بدون تغییر شناسه یا آدرس صفحه).";
return RedirectToPage();
}
/// <summary>Fill missing salary on existing aggregated listings from the stored text (now reading
/// Iranian «X تومان» = millions shorthand). In place — no AI, no ID/URL change.</summary>
public async Task<IActionResult> OnPostBackfillPayAsync()
{
var n = await _ingest.BackfillPayAsync();
IngestMessage = $"حقوق برای {n} آگهیِ «توافقی» که در متن مبلغ داشت (مثل «۴۰ تا ۵۰ تومان») استخراج و ثبت شد. بدون تغییر شناسه/آدرس.";
return RedirectToPage();
}
/// <summary>
/// In-place cleanup of existing aggregated jobs/shifts: ARCHIVE (hide, keep the row) only the
/// out-of-scope ones (domestic-helper / promotional / spam) per the current validator, plus
/// near-duplicate job reposts. Archived pages drop from lists + sitemap and return 410 Gone.
/// Valid listings keep their IDs/URLs. Reversible, no re-fetch, no AI — runs inline.
/// </summary>
public async Task<IActionResult> OnPostPurgeInvalidAsync()
{
var (archived, deduped) = await _ingest.PurgeInvalidAggregatedAsync();
IngestMessage = $"بایگانیِ درجا: {archived} آگهیِ خارج از حوزه (خدمات منزل/تبلیغاتی/اسپم) و {deduped} استخدامِ تکراری از سایت پنهان شد (وضعیت «بایگانی»؛ ردیف نگه داشته شد و قابل بازگشت است؛ صفحه‌شان ۴۱۰ Gone می‌دهد). آگهی‌های معتبر و شناسه/آدرسشان دست‌نخورده ماند.";
return RedirectToPage();
}
/// <summary>
/// Clean up the crawl-generated facility table: merge Persian-fuzzy duplicate facilities and fold
/// junk-named ones («بیمارستان هستم»، «... از مدجابز»، bare «کلینیک») into the shared placeholder,
/// repointing their listings first. Employer-owned / verified facilities are never touched.
/// </summary>
public async Task<IActionResult> OnPostCleanFacilitiesAsync()
{
var (merged, cleaned) = await _ingest.MergeAndCleanFacilitiesAsync();
IngestMessage = $"پاک‌سازی مراکز: {merged} مرکزِ تکراری ادغام و {cleaned} مرکزِ بی‌نام/نامعتبر حذف شد (آگهی‌هایشان به مرکزِ معتبر یا «نامشخص» منتقل شد). مراکز ثبت‌شده توسط کارفرما/تأییدشده دست‌نخورده ماند.";
return RedirectToPage();
}
/// <summary>Fix existing aggregated listings the AI mislabeled «پزشک عمومی» (dentist/specialist/…)
/// in place from their stored text — no AI, no ID/URL change.</summary>
public async Task<IActionResult> OnPostRecorrectRolesAsync()
{
var n = await _ingest.RecorrectDoctorRolesAsync();
IngestMessage = $"اصلاح نقش: {n} آگهیِ «پزشک عمومی» که در واقع نقش دیگری بود (دندانپزشک، متخصص و …) از روی متن آگهی اصلاح شد. بدون تغییر شناسه یا آدرس صفحه.";
return RedirectToPage();
}
/// <summary>Auto-merge duplicate/compound/typo roles minted by the dynamic taxonomy
/// («پرستار کودک» ×3، «پرستار و بهیار»، «بیهیار»→بهیار), repointing all listings first.</summary>
public async Task<IActionResult> OnPostMergeRolesAsync()
{
var n = await _ingest.MergeDuplicateRolesAsync();
IngestMessage = $"پاک‌سازی نقش‌ها: {n} نقشِ تکراری/ترکیبی/غلط‌املایی در نقش‌های اصلی ادغام شد (آگهی‌هایشان منتقل شد). فهرست نقش‌ها اکنون تمیزتر است.";
return RedirectToPage();
}
private async Task LoadAsync(int q = 1, int f = 1)
{
QueueTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New);
QueuePage = Math.Clamp(q, 1, QueuePages);
Queue = await _db.RawListings
.Where(r => r.Status == RawListingStatus.New)
.OrderByDescending(r => r.Confidence).ThenByDescending(r => r.FetchedAt).ToListAsync();
.OrderByDescending(r => r.Confidence).ThenByDescending(r => r.FetchedAt)
.Skip((QueuePage - 1) * PageSize).Take(PageSize).ToListAsync();
FlaggedTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.Flagged);
FlaggedPage = Math.Clamp(f, 1, FlaggedPages);
Flagged = await _db.RawListings
.Where(r => r.Status == RawListingStatus.Flagged)
.OrderByDescending(r => r.FetchedAt).ToListAsync();
.OrderByDescending(r => r.FetchedAt)
.Skip((FlaggedPage - 1) * PageSize).Take(PageSize).ToListAsync();
SourceNames = _ingest.SourceNames;
PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct);
PublishedJobs = await _db.JobOpenings.CountAsync();
@@ -34,6 +34,32 @@
</form>
}
@if (Model.SourceBreakdown.Count > 0)
{
<div class="card card-pad" style="margin-bottom:14px;">
<strong style="display:block; margin-bottom:8px;">📊 به تفکیک منبع</strong>
<table style="width:100%; border-collapse:collapse; font-size:13.5px;">
<thead>
<tr style="color:var(--muted);">
<th style="text-align:start; padding:4px 0;">منبع</th>
<th style="text-align:start;">منتشرشده</th>
<th style="text-align:start;">کل دریافت</th>
</tr>
</thead>
<tbody>
@foreach (var s in Model.SourceBreakdown)
{
<tr style="border-top:1px solid var(--line);">
<td style="padding:6px 0;"><strong>@s.Source</strong></td>
<td><span class="badge badge-verified">@P(s.Published)</span></td>
<td class="muted">@P(s.Total)</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="ing-filters">
@Html.Raw(Pill("all", "همه", Model.Counts.Values.Sum()))
@Html.Raw(Pill("new", "در صف", C(JobsMedical.Web.Models.RawListingStatus.New)))
@@ -65,7 +91,7 @@
<span style="display:flex; gap:6px; align-items:center;">
<span class="badge @cls">@label</span>
<span class="badge badge-type">اطمینان @P(r.Confidence)٪</span>
<span class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.FetchedAt)) — @JalaliDate.ToPersianDigits(r.FetchedAt.ToString("HH:mm"))</span>
<span class="muted" style="font-size:12px;">@JalaliDate.DateTimeLabel(r.FetchedAt)</span>
</span>
</div>
<p style="margin:8px 0; white-space:pre-wrap; font-size:13.5px;">@(r.RawText.Length > 320 ? r.RawText.Substring(0,320) + "…" : r.RawText)</p>
@@ -19,8 +19,12 @@ public class IngestedModel : PageModel
public List<RawListing> Items { get; private set; } = new();
public int Total { get; private set; }
public Dictionary<RawListingStatus, int> Counts { get; private set; } = new();
public List<SourceStat> SourceBreakdown { get; private set; } = new();
[TempData] public string? Message { get; set; }
/// <summary>Per-source tally: how many crawled vs how many actually published.</summary>
public record SourceStat(string Source, int Total, int Published);
[BindProperty(SupportsGet = true)] public string? Status { get; set; } // new|flagged|published|discarded|all
[BindProperty(SupportsGet = true)] public string? Source { get; set; }
@@ -29,6 +33,22 @@ public class IngestedModel : PageModel
Counts = await _db.RawListings.GroupBy(r => r.Status)
.Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
// Per-source breakdown — group exact SourceChannel rows then fold into source "families"
// (تلگرام/ch → تلگرام, وب‌سایت (host) → وب‌سایت) so the table reads one row per source.
var bySource = await _db.RawListings.GroupBy(r => r.SourceChannel)
.Select(g => new
{
Source = g.Key,
Total = g.Count(),
Published = g.Count(x => x.Status == RawListingStatus.Normalized),
})
.ToListAsync();
SourceBreakdown = bySource
.GroupBy(x => SourceFamily(x.Source))
.Select(g => new SourceStat(g.Key, g.Sum(x => x.Total), g.Sum(x => x.Published)))
.OrderByDescending(s => s.Published).ThenByDescending(s => s.Total)
.ToList();
var q = _db.RawListings.AsNoTracking().AsQueryable();
var st = Status?.ToLowerInvariant() switch
@@ -46,6 +66,15 @@ public class IngestedModel : PageModel
Items = await q.OrderByDescending(r => r.FetchedAt).Take(200).ToListAsync();
}
/// <summary>Collapse a channel label to its source family: "تلگرام/nurses" → "تلگرام",
/// "وب‌سایت (medjobs.ir)" → "وب‌سایت". Divar/Bale/Medjobs already have no suffix.</summary>
private static string SourceFamily(string? channel)
{
if (string.IsNullOrWhiteSpace(channel)) return "نامشخص";
var cut = channel.IndexOfAny(new[] { '/', '(' });
return (cut > 0 ? channel[..cut] : channel).Trim();
}
/// <summary>
/// ARCHIVE (never delete) everything published from ingestion: the aggregated Shift/Job/Talent
/// posts are flipped to Archived (hidden from the site but kept for analytics); the raw crawl
@@ -35,7 +35,7 @@
<span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span>
</div>
<p style="margin:8px 0;">«@r.Reason»</p>
<div class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.CreatedAt)) · گزارش‌دهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")</div>
<div class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(JalaliDate.ToTehran(r.CreatedAt))) · گزارش‌دهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")</div>
<div style="display:flex; gap:8px; margin-top:10px;">
<a class="btn btn-outline" style="padding:6px 12px;" href="@JobsMedical.Web.Pages.Admin.ReportsModel.TargetUrl(r)" target="_blank">مشاهده مورد</a>
@if (r.Status == ReportStatus.Open)
@@ -19,6 +19,16 @@
<div class="card card-pad">
<h3 style="margin-top:0;">متن خام</h3>
<p style="white-space:pre-wrap; margin:0;">@r.RawText</p>
@if (!string.IsNullOrWhiteSpace(r.SourceUrl))
{
<p style="margin:12px 0 0;">
<a class="btn btn-outline" href="@r.SourceUrl" target="_blank" rel="noopener noreferrer">🔗 مشاهده آگهی در منبع (@r.SourceChannel)</a>
</p>
}
else
{
<p class="muted" style="font-size:12px; margin:12px 0 0;">لینک منبع برای این آگهی ثبت نشده است.</p>
}
</div>
@if (Model.Parsed is not null)
@@ -282,13 +282,26 @@ public class ReviewModel : PageModel
if (cityId is null) return null; // no cities seeded — cannot create a facility
// No facility named in the ad → use/create the shared placeholder.
var name = string.IsNullOrWhiteSpace(NewFacilityName) ? UnknownFacilityName : NewFacilityName.Trim();
var isPlaceholder = string.IsNullOrWhiteSpace(NewFacilityName);
var name = isPlaceholder ? UnknownFacilityName : NewFacilityName.Trim();
// Approximate coords carried from the crawl (e.g. Divar). NEVER apply them to the shared
// «نامشخص» placeholder — it's reused across many ads, so a single ad's point would mislead.
bool HasGeo() => !isPlaceholder && Raw?.Lat is not null;
// Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
var all = await _db.Facilities.ToListAsync();
var match = FacilityMatcher.FindBest(all, name, cityId);
if (match is not null) return match.Id;
if (match is not null)
{
if (HasGeo() && match.Lat is null && match.Lng is null) // backfill only, never overwrite
{
match.Lat = Raw!.Lat; match.Lng = Raw.Lng;
await _db.SaveChangesAsync();
}
return match.Id;
}
var facility = new Facility
{
@@ -297,6 +310,8 @@ public class ReviewModel : PageModel
Type = FacilityType.Hospital,
Verification = VerificationStatus.Unverified,
IsVerified = false,
Lat = HasGeo() ? Raw!.Lat : null,
Lng = HasGeo() ? Raw!.Lng : null,
};
_db.Facilities.Add(facility);
await _db.SaveChangesAsync();
@@ -0,0 +1,84 @@
@page
@model JobsMedical.Web.Pages.Admin.RolesModel
@{
ViewData["Title"] = "نقش‌ها و دسته‌بندی";
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
}
<div class="page-head">
<div class="container">
<h1>نقش‌ها و دسته‌بندی</h1>
<p class="muted"><a asp-page="/Admin/Index">← صف بررسی</a> — ادغام نقش‌های تکراری و مدیریت تاکسونومی.</p>
</div>
</div>
<div class="container section">
@if (Model.Message is not null)
{
<div class="alert alert-success">✓ @Model.Message</div>
}
<div class="card card-pad" style="margin-bottom:16px;">
<h3 style="margin-top:0;">ادغام نقش</h3>
<p class="muted" style="font-size:12.5px; margin-top:0;">همهٔ آگهی‌ها و علاقه‌مندی‌های «نقش مبدأ» به «نقش مقصد» منتقل و نقش مبدأ حذف می‌شود. این کار بازگشت‌ناپذیر است.</p>
<form method="post" asp-page-handler="Merge"
onsubmit="return confirm('ادغام انجام شود؟ این کار بازگشت‌ناپذیر است.');"
style="display:flex; gap:8px; flex-wrap:wrap; align-items:end;">
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
<label>نقش مبدأ (حذف می‌شود)</label>
<select name="sourceId" required>
<option value="">— انتخاب —</option>
@foreach (var x in Model.Stats)
{
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
}
</select>
</div>
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
<label>نقش مقصد (می‌ماند)</label>
<select name="targetId" required>
<option value="">— انتخاب —</option>
@foreach (var x in Model.Stats)
{
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
}
</select>
</div>
<button type="submit" class="btn btn-accent">ادغام</button>
</form>
</div>
<table style="width:100%; border-collapse:collapse; font-size:13.5px;">
<thead>
<tr style="color:var(--muted); text-align:start;">
<th style="text-align:start; padding:6px 0;">نقش</th>
<th style="text-align:start;">گروه</th>
<th style="text-align:start;">شیفت</th>
<th style="text-align:start;">استخدام</th>
<th style="text-align:start;">آماده‌به‌کار</th>
<th style="text-align:start;">جمع</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var x in Model.Stats)
{
<tr style="border-top:1px solid var(--line); @(x.Role.IsActive ? "" : "opacity:.5;")">
<td style="padding:8px 0;"><strong>@x.Role.Name</strong> @(x.Role.IsActive ? "" : "(غیرفعال)")</td>
<td class="muted">@x.Role.Category</td>
<td>@P(x.Shifts)</td>
<td>@P(x.Jobs)</td>
<td>@P(x.Talent)</td>
<td><strong>@P(x.Total)</strong></td>
<td style="text-align:end;">
<form method="post" asp-page-handler="Toggle" asp-route-id="@x.Role.Id" style="display:inline;">
<button type="submit" class="btn btn-outline" style="padding:4px 12px; font-size:12px;">
@(x.Role.IsActive ? "غیرفعال‌سازی" : "فعال‌سازی")
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,75 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
/// <summary>Role taxonomy hygiene — dynamic ingestion can mint near-duplicate roles over time
/// («کمک‌یار» vs «کمک بهیار»). This screen lists every role with its usage and lets an admin merge
/// one role into another (reassigning all its listings/preferences) or toggle a role's visibility.</summary>
[Authorize(Roles = "Admin")]
public class RolesModel : PageModel
{
private readonly AppDbContext _db;
public RolesModel(AppDbContext db) => _db = db;
public record RoleStat(Role Role, int Shifts, int Jobs, int Talent)
{
public int Total => Shifts + Jobs + Talent;
}
public List<RoleStat> Stats { get; private set; } = new();
[TempData] public string? Message { get; set; }
public async Task OnGetAsync() => await LoadAsync();
private async Task LoadAsync()
{
var roles = await _db.Roles.OrderBy(r => r.Category).ThenBy(r => r.Name).ToListAsync();
var sc = await _db.Shifts.GroupBy(s => s.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
var jc = await _db.JobOpenings.GroupBy(j => j.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
var tc = await _db.TalentListings.GroupBy(t => t.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
Stats = roles.Select(r => new RoleStat(r, sc.GetValueOrDefault(r.Id), jc.GetValueOrDefault(r.Id), tc.GetValueOrDefault(r.Id)))
.OrderByDescending(x => x.Total).ToList();
}
/// <summary>Move every reference from <paramref name="sourceId"/> to <paramref name="targetId"/>
/// (listings — the Restrict FKs that would otherwise block — plus preferences/alerts/profiles),
/// then delete the now-empty source role.</summary>
public async Task<IActionResult> OnPostMergeAsync(int sourceId, int targetId)
{
if (sourceId == 0 || targetId == 0 || sourceId == targetId)
{ Message = "نقش مبدأ و مقصد را درست انتخاب کن."; return RedirectToPage(); }
var source = await _db.Roles.FindAsync(sourceId);
var target = await _db.Roles.FindAsync(targetId);
if (source is null || target is null) { Message = "نقش پیدا نشد."; return RedirectToPage(); }
var s = await _db.Shifts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
var j = await _db.JobOpenings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
var t = await _db.TalentListings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
// Nullable references too, so a saved preference/alert follows the merge instead of dangling.
await _db.UserPreferences.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
await _db.JobAlerts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
await _db.DoctorProfiles.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
_db.Roles.Remove(source);
await _db.SaveChangesAsync();
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
Message = $"«{source.Name}» در «{target.Name}» ادغام شد — منتقل‌شده: {P(s)} شیفت، {P(j)} استخدام، {P(t)} آماده‌به‌کار.";
return RedirectToPage();
}
/// <summary>Hide a role from filters/forms without deleting it (keeps its listings intact).</summary>
public async Task<IActionResult> OnPostToggleAsync(int id)
{
var role = await _db.Roles.FindAsync(id);
if (role is not null) { role.IsActive = !role.IsActive; await _db.SaveChangesAsync(); }
return RedirectToPage();
}
}
@@ -16,7 +16,11 @@
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
@if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> }
@if (Model.AiTest is not null) { <div class="alert alert-success">@Model.AiTest</div> }
@if (Model.AiTest is not null)
{
<div class="alert @(Model.AiTest.StartsWith("✅") ? "alert-success" : "alert-error")"
style="white-space:pre-wrap; word-break:break-word;">@Model.AiTest</div>
}
<form method="post">
<div class="settings-layout">
@@ -67,9 +71,10 @@
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
</div>
<div class="filter-group">
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework)</label>
<textarea name="AiSystemPrompt" rows="8" dir="rtl">@Model.AiSystemPrompt</textarea>
<p class="muted" style="font-size:12px; margin:4px 0 0;">به مدل بگو چطور تأیید/رد کند و چه فیلدهایی را استخراج کند. خروجی باید JSON باشد.</p>
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
<textarea rows="14" dir="rtl" readonly
style="background:var(--bg); color:var(--muted); cursor:not-allowed;">@JobsMedical.Web.Models.AppSetting.DefaultPrompt</textarea>
<p class="muted" style="font-size:12px; margin:4px 0 0;">این دستور در کد ثابت شده و قابل ویرایش نیست تا دسته‌بندی و استخراج همیشه درست بماند. یک «اسکیمای خروجی JSON» هم به‌صورت خودکار به انتهای آن افزوده می‌شود.</p>
</div>
<label class="toggle-row">
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
@@ -142,6 +147,26 @@
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="IranEstekhdamEnabled" value="true" checked="@Model.IranEstekhdamEnabled" />
<span class="t-body"><span>🏥 ایران‌استخدام (iranestekhdam.ir)</span><span class="t-hint">آگهی‌های استخدامِ مراکز درمانیِ نام‌دار از سایت‌مپِ ماهانه؛ فقط نقش‌های بالینی.</span></span>
</label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="IranEstekhdamMaxAds" min="1" max="500" value="@Model.IranEstekhdamMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="IranEstekhdamUseProxy" value="true" checked="@Model.IranEstekhdamUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="MedboomEnabled" value="true" checked="@Model.MedboomEnabled" />
<span class="t-body"><span>🩺 مدبوم (medboom.ir)</span><span class="t-hint">آگهی‌های علوم پزشکی (بیشتر پزشک/دندانپزشک)، استخدام و آماده‌به‌کار؛ بدون نیاز به فیلترشکن.</span></span>
</label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedboomMaxAds" min="1" max="500" value="@Model.MedboomMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="MedboomUseProxy" value="true" checked="@Model.MedboomUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" />
@@ -32,7 +32,7 @@ public class SettingsModel : PageModel
[BindProperty] public string? AiEndpoint { get; set; }
[BindProperty] public string? AiApiKey { get; set; }
[BindProperty] public string? AiModel { get; set; }
[BindProperty] public string AiSystemPrompt { get; set; } = "";
// AiSystemPrompt is hardcoded (AppSetting.DefaultPrompt) and shown read-only — not bound/editable.
[BindProperty] public bool AiAutoApprove { get; set; }
[BindProperty] public bool AiUseProxy { get; set; }
// Channel scraping sources
@@ -47,6 +47,12 @@ public class SettingsModel : PageModel
[BindProperty] public string? DivarQueries { get; set; }
[BindProperty] public bool MedjobsEnabled { get; set; }
[BindProperty] public int MedjobsMaxAds { get; set; } = 40;
[BindProperty] public bool IranEstekhdamEnabled { get; set; }
[BindProperty] public int IranEstekhdamMaxAds { get; set; } = 40;
[BindProperty] public bool IranEstekhdamUseProxy { get; set; }
[BindProperty] public bool MedboomEnabled { get; set; }
[BindProperty] public int MedboomMaxAds { get; set; } = 40;
[BindProperty] public bool MedboomUseProxy { get; set; }
[BindProperty] public bool SmsEnabled { get; set; }
[BindProperty] public string? SmsApiKey { get; set; }
[BindProperty] public string? SmsTemplate { get; set; }
@@ -82,7 +88,6 @@ public class SettingsModel : PageModel
AiEndpoint = s.AiEndpoint;
AiApiKey = s.AiApiKey;
AiModel = s.AiModel;
AiSystemPrompt = s.AiSystemPrompt;
AiAutoApprove = s.AiAutoApprove;
AiUseProxy = s.AiUseProxy;
AutoIngestEnabled = s.AutoIngestEnabled;
@@ -96,6 +101,12 @@ public class SettingsModel : PageModel
DivarQueries = s.DivarQueries;
MedjobsEnabled = s.MedjobsEnabled;
MedjobsMaxAds = s.MedjobsMaxAds;
IranEstekhdamEnabled = s.IranEstekhdamEnabled;
IranEstekhdamMaxAds = s.IranEstekhdamMaxAds;
IranEstekhdamUseProxy = s.IranEstekhdamUseProxy;
MedboomEnabled = s.MedboomEnabled;
MedboomMaxAds = s.MedboomMaxAds;
MedboomUseProxy = s.MedboomUseProxy;
SmsEnabled = s.SmsEnabled;
SmsApiKey = s.SmsApiKey;
SmsTemplate = s.SmsTemplate;
@@ -127,7 +138,7 @@ public class SettingsModel : PageModel
AiEndpoint = AiEndpoint,
AiApiKey = AiApiKey,
AiModel = AiModel,
AiSystemPrompt = AiSystemPrompt,
// AiSystemPrompt intentionally omitted — AppSetting defaults it to DefaultPrompt (hardcoded).
AiAutoApprove = AiAutoApprove,
AiUseProxy = AiUseProxy,
AutoIngestEnabled = AutoIngestEnabled,
@@ -141,6 +152,12 @@ public class SettingsModel : PageModel
DivarQueries = DivarQueries,
MedjobsEnabled = MedjobsEnabled,
MedjobsMaxAds = MedjobsMaxAds,
IranEstekhdamEnabled = IranEstekhdamEnabled,
IranEstekhdamMaxAds = IranEstekhdamMaxAds,
IranEstekhdamUseProxy = IranEstekhdamUseProxy,
MedboomEnabled = MedboomEnabled,
MedboomMaxAds = MedboomMaxAds,
MedboomUseProxy = MedboomUseProxy,
SmsEnabled = SmsEnabled,
SmsApiKey = SmsApiKey,
SmsTemplate = SmsTemplate,
@@ -212,14 +229,9 @@ public class SettingsModel : PageModel
{ AiTest = "ابتدا «فعال‌سازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
try
{
var r = await _ai.AuditAsync(sample, s);
AiTest = r is null
? "❌ پاسخی از هوش مصنوعی دریافت نشد. کلید/آدرس و (در صورت نیاز) تیک «از طریق پروکسی» را بررسی کن."
: $"✅ هوش مصنوعی پاسخ داد — تصمیم: {r.Decision} | اطمینان: {r.Confidence}٪ | نقش: {r.Data?.Role} | شهر: {r.Data?.City} | شیفت: {r.Data?.ShiftType}";
}
catch (Exception ex) { AiTest = "❌ خطا در تماس با هوش مصنوعی: " + ex.Message; }
// TestAsync runs the real call and returns the exact reason on failure (HTTP status,
// response body, network/proxy error) — unlike AuditAsync, which swallows errors to null.
AiTest = await _ai.TestAsync(sample, s);
return RedirectToPage();
}
@@ -88,8 +88,8 @@
@section Scripts {
@if (!string.IsNullOrEmpty(Model.MapKey))
{
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.css" />
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script>
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.css" />
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.js"></script>
}
<script>
(function () {
+9
View File
@@ -14,6 +14,15 @@
</p>
}
@if (Model.AdminDetail is not null)
{
<div style="margin:16px 0; padding:14px; border:1px solid var(--danger); border-radius:10px; background:#fff5f5; direction:ltr; text-align:left;">
<strong>🔧 جزئیات خطا (فقط برای ادمین)</strong>
@if (Model.AdminPath is not null) { <div style="margin:6px 0;"><code>@Model.AdminPath</code></div> }
<pre style="white-space:pre-wrap; word-break:break-word; font-size:12px; margin:8px 0 0; max-height:50vh; overflow:auto;">@Model.AdminDetail</pre>
</div>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
+16
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -12,9 +13,24 @@ public class ErrorModel : PageModel
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
/// <summary>The real exception — shown ONLY to a logged-in Admin, so production 500s can be
/// diagnosed without server-log access. Hidden from everyone else.</summary>
public string? AdminDetail { get; private set; }
public string? AdminPath { get; private set; }
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
if (User.IsInRole("Admin"))
{
var feat = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
AdminPath = feat?.Path;
if (feat?.Error is { } ex)
AdminDetail = ex.GetType().FullName + ": " + ex.Message
+ (ex.InnerException is { } ie ? $"\n ↳ {ie.GetType().Name}: {ie.Message}" : "")
+ "\n\n" + ex.StackTrace;
}
}
}
@@ -31,7 +31,9 @@
<div class="container section">
@if (Model.Reported) { <div class="alert alert-success">✓ گزارش شما ثبت شد. متشکریم.</div> }
<div class="layout-2">
@* detail-grid = content(1fr) + sidebar(340px); the content div is first, so it gets the wide
column. (layout-2 is sidebar-first/270px and was squeezing the job cards into a narrow strip.) *@
<div class="detail-grid">
<div>
@if (Model.Shifts.Count == 0 && Model.Jobs.Count == 0)
{
@@ -147,3 +149,12 @@
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@* Place/clinic structured data — only for a real named facility (not the «نامشخص» placeholder). *@
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}";
@Html.Raw("<script type=\"application/ld+json\">"
+ JobsMedical.Web.Services.SeoJsonLd.MedicalOrganization(f, bu, Model.AvgRating, Model.RatingCount)
+ "</script>")
}
@@ -32,7 +32,7 @@
</p>
<div class="foot" style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--line); padding-top:12px;">
<span class="pay" style="color:var(--primary-dark); font-weight:800;">
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
@JalaliDate.ToPersianDigits(row.OpenListings.ToString()) آگهی فعال
</span>
<span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
</div>
@@ -10,21 +10,36 @@ public class IndexModel : PageModel
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
public record FacilityRow(Facility Facility, int OpenShifts);
public record FacilityRow(Facility Facility, int OpenListings);
public List<FacilityRow> Rows { get; private set; } = new();
// The shared placeholder for unnamed aggregated ads is not a real, browseable facility.
private const string PlaceholderName = "نامشخص / ثبت نشده";
public async Task OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
var counts = await _db.Shifts
var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
var facilities = await _db.Facilities.Include(f => f.City)
.Where(f => f.Name != PlaceholderName).ToListAsync();
// "Active listings" = open shifts + open (fresh) job openings — a facility that is hiring
// shouldn't read «۰ شیفت باز» just because it posted a job rather than a dated shift.
var shiftCounts = await _db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.GroupBy(s => s.FacilityId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
.GroupBy(s => s.FacilityId).Select(g => new { g.Key, C = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.C);
var jobCounts = await _db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
.GroupBy(j => j.FacilityId).Select(g => new { g.Key, C = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.C);
Rows = facilities
.Select(f => new FacilityRow(f, counts.GetValueOrDefault(f.Id)))
.Select(f => new FacilityRow(f, shiftCounts.GetValueOrDefault(f.Id) + jobCounts.GetValueOrDefault(f.Id)))
.OrderByDescending(r => r.OpenListings) // active facilities first
.ThenByDescending(r => r.Facility.IsVerified)
.ThenBy(r => r.Facility.Name)
.ToList();
}
}
+21 -42
View File
@@ -2,7 +2,7 @@
@model IndexModel
@{
ViewData["Title"] = null; // use default site title for the home page (best for SEO)
ViewData["Description"] = "همکادر؛ سریع‌ترین راه برای کادر درمان (پزشک، پرستار، ماما، تکنسین) جهت یافتن شیفت و موقعیت استخدامی در بیمارستان‌ها و کلینیک‌های تهران. به‌جای گشتن در کانال‌های تلگرام و بله، همه فرصت‌ها یک‌جا.";
ViewData["Description"] = "یافتن شیفت و موقعیت استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستان‌ها و کلینیک‌های تهران همهٔ فرصت‌ها یک‌جا در همکادر.";
}
<section class="hero">
@@ -19,7 +19,10 @@
<span class="hs-ico">🔎</span>
<input type="search" name="Q" autocomplete="off"
placeholder="جستجو: پرستار، mmt، دندان‌پزشک…" />
<button type="submit" class="btn btn-accent btn-lg">جستجو</button>
<button type="submit" class="btn btn-accent btn-lg hs-submit" aria-label="جستجو">
<span class="hs-submit-txt">جستجو</span>
<span class="hs-submit-ico" aria-hidden="true">🔎</span>
</button>
</div>
<div class="hero-chips">
<span class="hc-label">جستجوهای پرطرفدار:</span>
@@ -31,68 +34,44 @@
</form>
<div class="stat-pills">
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.OpenShiftCount.ToString())</span><span class="l">شیفت باز</span></div>
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.OpenJobCount.ToString())</span><span class="l">موقعیت استخدام</span></div>
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.FacilityCount.ToString())</span><span class="l">مرکز درمانی</span></div>
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.CityCount.ToString())</span><span class="l">شهر فعال</span></div>
</div>
</div>
</section>
@if (Model.Recommendations.Count > 0)
{
<section class="section" style="padding-bottom:0;">
<section class="section" style="padding-bottom:0;">
<div class="container">
@if (Model.HasPersonalization)
{
<div class="rec-banner">
<a asp-page="/Recommendations/Index" class="rec-banner" style="text-decoration:none; color:#fff;">
<div>
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
<span style="opacity:.9; font-size:14px;">بر اساس علاقه‌مندی‌ها و فعالیت شما انتخاب شده‌اند</span>
<span style="opacity:.9; font-size:14px;">فرصت‌های متناسب با نقش، شهر و فعالیت شما — همه یک‌جا</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">ویرایش علاقه‌مندی‌ها</a>
<span class="btn btn-outline">مشاهده پیشنهادها ←</span>
</a>
</div>
}
else
{
<div class="rec-banner">
<div>
<h2 style="margin:0 0 4px;">پیشنهادها را شخصی‌سازی کن</h2>
<span style="opacity:.9; font-size:14px;">نقش، شهر و نوع شیفت دلخواهت را بگو تا بهترین فرصت‌ها را برایت پیدا کنیم</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
</div>
}
<div class="grid grid-3">
@foreach (var rec in Model.Recommendations)
{
<partial name="_RecommendationCard" model="rec" />
}
</div>
</div>
</section>
}
</section>
<section class="section">
@* Shifts are rare for aggregated content (most ads are ongoing hiring, not dated shifts) — only
show the section when there are real open shifts, so we never display a fabricated/empty date. *@
@if (Model.LatestShifts.Count > 0)
{
<section class="section">
<div class="container">
<div class="section-head">
<h2>جدیدترین شیفت‌ها</h2>
<a asp-page="/Shifts/Index">مشاهده همه ←</a>
<a href="/Shifts">مشاهده همه ←</a>
</div>
@if (Model.LatestShifts.Count == 0)
{
<div class="empty-state">فعلاً شیفت بازی ثبت نشده است.</div>
}
else
{
<div class="grid grid-3">
@foreach (var s in Model.LatestShifts)
{
<partial name="_ShiftCard" model="s" />
}
</div>
}
</div>
</section>
</section>
}
@if (Model.LatestJobs.Count > 0)
{
@@ -100,7 +79,7 @@
<div class="container">
<div class="section-head">
<h2>فرصت‌های استخدامی</h2>
<a asp-page="/Jobs/Index">مشاهده همه ←</a>
<a href="/Jobs">مشاهده همه ←</a>
</div>
<div class="grid grid-3">
@foreach (var j in Model.LatestJobs)
+5 -13
View File
@@ -9,24 +9,19 @@ namespace JobsMedical.Web.Pages;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
private readonly RecommendationService _recs;
private readonly InterestService _interest;
public IndexModel(AppDbContext db, RecommendationService recs, InterestService interest)
public IndexModel(AppDbContext db)
{
_db = db;
_recs = recs;
_interest = interest;
}
public List<Recommendation> Recommendations { get; private set; } = new();
public bool HasPersonalization { get; private set; }
public List<Shift> LatestShifts { get; private set; } = new();
public List<JobOpening> LatestJobs { get; private set; } = new();
public List<TalentListing> LatestTalent { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public int OpenShiftCount { get; private set; }
public int OpenJobCount { get; private set; }
public int FacilityCount { get; private set; }
public int CityCount { get; private set; }
@@ -34,11 +29,6 @@ public class IndexModel : PageModel
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Recommendations = await _recs.GetForVisitorAsync(6);
// "Personalized" = we actually used a signal (prefs or behavior), not just cold-start freshness.
HasPersonalization = (await _interest.GetPreferencesAsync())?.HasAny == true
|| (await _interest.RecentEventsAsync(1)).Count > 0;
LatestShifts = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
@@ -62,12 +52,14 @@ public class IndexModel : PageModel
.Where(t => t.Status == ShiftStatus.Open
&& t.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc)
.OrderByDescending(t => t.CreatedAt)
.Take(3)
.Take(6) // two rows of the grid-3 «آماده به کار» section
.ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
OpenJobCount = await _db.JobOpenings.CountAsync(j => j.Status == ShiftStatus.Open
&& j.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc);
FacilityCount = await _db.Facilities.CountAsync();
CityCount = await _db.Cities.CountAsync(c => c.IsActive);
}
+62 -46
View File
@@ -3,8 +3,16 @@
@{
var j = Model.Job!;
var f = j.Facility!;
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
var jobContacts = (j.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).ToList();
// Map: listing's own approx coords (aggregated) then facility's; aggregated = approximate area.
var mapLat = j.Lat ?? f.Lat;
var mapLng = j.Lng ?? f.Lng;
var mapApprox = j.Source == JobsMedical.Web.Models.ShiftSource.Aggregated;
ViewData["Title"] = j.Title;
ViewData["Description"] = $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}.";
ViewData["Description"] = hasFac
? $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}."
: $"{j.Title} در {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}.";
// Don't let Google index filled/expired openings (avoids dead "Job for jobs" results).
if (j.Status != JobsMedical.Web.Models.ShiftStatus.Open) ViewData["NoIndex"] = true;
string empLabel = j.EmploymentType switch
@@ -17,18 +25,23 @@
string salary;
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
else if (j.SalaryMax is null) salary = "از " + JalaliDate.Toman(j.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
if (j.Role is not null) crumbs.Add(new(j.Role.Name, "/استخدام/" + JobsMedical.Web.Services.SeoSlug.Of(j.Role.Name)));
crumbs.Add(new(j.Title, null));
}
<div class="page-head">
<div class="container">
<partial name="_Breadcrumbs" model="crumbs" />
<div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge badge-job">@empLabel</span>
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
@if (f.IsVerified) { <span class="badge badge-verified">✓ مرکز تأیید شده</span> }
</div>
<h1 style="margin-top:8px;">@j.Title</h1>
<p class="muted">🏥 @f.Name — 📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p>
<p class="muted">@(hasFac ? "🏥 " + f.Name + " — " : "")📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p>
</div>
</div>
@@ -38,11 +51,18 @@
@if (Model.ShowContact)
{
<div class="contact-reveal" style="margin-bottom:16px;">
<h4>✓ راه‌های ارتباطی مرکز</h4>
<h4>✓ راه‌های ارتباطی</h4>
@if (jobContacts.Count > 0)
{
@* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@
<partial name="_ContactList" model="jobContacts" />
}
else if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) && (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)))
{
@if (!string.IsNullOrEmpty(f.Phone))
{
<div class="contact-row">
<span class="c-meta"><span class="c-type">📞 تلفن</span><span class="c-val" dir="ltr">@f.Phone</span></span>
<span class="c-meta"><span class="c-type">📞 تلفن مرکز</span><span class="c-val" dir="ltr">@f.Phone</span></span>
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
</div>
}
@@ -53,9 +73,10 @@
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
</div>
}
@if (string.IsNullOrEmpty(f.Phone) && string.IsNullOrEmpty(f.BaleId))
}
else
{
<p class="muted" style="margin:0;">شماره‌ای برای این مرکز ثبت نشده است.</p>
<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>
}
</div>
}
@@ -96,19 +117,14 @@
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div>
<p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p>
<div class="aside-apply">
<form method="post">
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id"
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
</form>
</div>
<div style="display:flex; gap:8px; margin-top:8px;">
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-block">♡ ذخیره</button>
</form>
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
</div>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:8px;"
data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
@if (Model.Reported)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
@@ -129,7 +145,7 @@
@if (j.Facility is not null)
{
<details style="margin-top:6px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@j.Facility.Name)</summary>
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این @(hasFac ? "مرکز (" + j.Facility.Name + ")" : "آگهی")</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Facility" />
<input type="hidden" name="targetId" value="@j.Facility.Id" />
@@ -143,15 +159,15 @@
}
</div>
@if (j.Facility?.Lat is not null && j.Facility?.Lng is not null)
{
var latS = j.Facility.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = j.Facility.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">موقعیت مکانی</h3>
@if (mapLat is not null && mapLng is not null)
{
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
@if (!string.IsNullOrEmpty(Model.MapKey))
{
<div id="facmap" data-lat="@latS" data-lng="@lngS" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="@(mapApprox ? "true" : "false")" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
}
else
{
@@ -159,44 +175,44 @@
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
</div>
}
@if (mapApprox)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبی (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
}
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
</div>
}
else
{
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
}
</div>
</aside>
</div>
</div>
@* Sticky bottom action bar — mobile only. *@
<div class="mobile-action-bar">
@if (Model.ShowContact)
{
@if (!string.IsNullOrEmpty(f.Phone))
{
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a>
}
else
{
<span class="cta-main center muted" style="align-self:center;">اطلاعات تماس در بالای صفحه</span>
}
}
else
{
<form method="post" class="cta-main">
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id" class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده تماس</button>
</form>
<form method="post">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
</form>
}
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
</div>
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Job?.Facility?.Lat is not null)
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@section Head {
@* Only emit JobPosting structured data for a real named employer — Google for Jobs rejects a
placeholder/empty hiringOrganization (most aggregated ads have no named center). *@
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.JobPosting(j, bu) + "</script>")
}
}
@@ -23,6 +23,8 @@ public class DetailsModel : PageModel
public JobOpening? Job { get; private set; }
public string? MapKey { get; private set; }
public int LikeCount { get; private set; }
public bool IsLiked { get; private set; }
public bool ShowContact { get; private set; }
public bool Saved { get; private set; }
public bool Reported { get; private set; }
@@ -31,7 +33,13 @@ public class DetailsModel : PageModel
{
await LoadAsync(id);
if (Job is null) return NotFound();
// Intentionally removed (admin-archived out-of-scope/duplicate ad): 410 Gone is the standard
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
if (Job.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
MapKey = (await _settings.GetAsync()).NeshanMapKey;
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Job && l.TargetId == id);
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Job && l.TargetId == id);
Reported = Request.Query["reported"] == "1";
await _interest.LogJobAsync(InterestEventType.View, id);
return Page();
@@ -67,6 +75,7 @@ public class DetailsModel : PageModel
.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District)
.Include(j => j.Role)
.Include(j => j.Contacts)
.FirstOrDefaultAsync(j => j.Id == id);
}
}
+30 -3
View File
@@ -1,23 +1,40 @@
@page
@model JobsMedical.Web.Pages.Jobs.IndexModel
@{
ViewData["Title"] = "موقعیت‌های استخدامی";
// Title/description are set in the page model (SetSeo) from the active role/city.
}
<div class="page-head">
<div class="container">
<h1>موقعیت‌های استخدامی</h1>
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
<h1>@Model.PageHeading</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) موقعیت شغلی پیدا شد
@if (Model.NearMeActive)
{
<span> — مرتب‌شده بر اساس نزدیک‌ترین به شما 📍</span>
}
</p>
@if (!string.IsNullOrEmpty(Model.PageIntro))
{
<p class="muted" style="max-width:720px; font-size:13.5px; line-height:1.9;">@Model.PageIntro</p>
}
</div>
</div>
<div class="container section">
@if (Model.Roles.Count > 0)
{
@* Internal links to the SEO landing pages (/استخدام/{نقش}) — and since this page IS the
landing page, every landing page cross-links to all the others. *@
<div class="role-links">
<span class="rl-label">استخدام بر اساس نقش:</span>
@foreach (var r in Model.Roles.Take(14))
{
<a class="rl-chip" href="/استخدام/@JobsMedical.Web.Services.SeoSlug.Of(r.Name)">@r.Name</a>
}
</div>
}
<div class="layout-2">
<aside class="card card-pad filter-card">
<h3>فیلترها</h3>
@@ -100,6 +117,7 @@
<partial name="_JobCard" model="j" />
}
</div>
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
}
</div>
</div>
@@ -124,3 +142,12 @@
}
</script>
}
@section Head {
@{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(Model.Breadcrumbs, bcUrl) + "</script>")
@if (Model.Results.Count > 0)
{
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ItemList(Model.Results.Select(j => "/Jobs/Details/" + j.Id), bcUrl) + "</script>")
}
}
+72 -6
View File
@@ -20,6 +20,16 @@ public class IndexModel : PageModel
[BindProperty(SupportsGet = true)] public double? Lat { get; set; }
[BindProperty(SupportsGet = true)] public double? Lng { get; set; }
// Pretty-URL segments (/استخدام/{roleSlug}/{citySlug?}); resolved to RoleId/CityId below.
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
[BindProperty(SupportsGet = true)] public string? CitySlug { get; set; }
[BindProperty(SupportsGet = true)] public int Page { get; set; } = 1;
private const int PageSize = 24;
public int TotalCount { get; private set; }
public int TotalPages { get; private set; }
public int CurrentPage { get; private set; }
public bool NearMeActive => Lat is not null && Lng is not null;
public List<JobOpening> Results { get; private set; } = new();
@@ -27,10 +37,35 @@ public class IndexModel : PageModel
public List<District> Districts { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public async Task OnGetAsync()
/// <summary>Dynamic page heading/H1 + title, set from the active role/city for SEO.</summary>
public string PageHeading { get; private set; } = "موقعیت‌های استخدامی";
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
public string? PageIntro { get; private set; }
/// <summary>Breadcrumb trail (also emitted as BreadcrumbList JSON-LD).</summary>
public IReadOnlyList<Crumb> Breadcrumbs { get; private set; } = Array.Empty<Crumb>();
public async Task<IActionResult> OnGetAsync()
{
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
// Pretty-URL landing: resolve slugs → filters. A slug matching nothing is a 404 (don't
// render a thin page under a junk URL).
if (!string.IsNullOrWhiteSpace(RoleSlug))
{
var role = Roles.FirstOrDefault(r => SeoSlug.Matches(r.Name, RoleSlug));
if (role is null) return NotFound();
RoleId = role.Id;
}
if (!string.IsNullOrWhiteSpace(CitySlug))
{
var city = Cities.FirstOrDefault(c => SeoSlug.Matches(c.Name, CitySlug));
if (city is null) return NotFound();
CityId = city.Id;
}
Districts = await _db.Districts
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
.OrderBy(d => d.Name).ToListAsync();
@@ -49,19 +84,50 @@ public class IndexModel : PageModel
if (GenderFilter is Gender g && g != Gender.Any)
q = q.Where(j => j.GenderRequirement == Gender.Any || j.GenderRequirement == g);
var results = await q.ToListAsync();
TotalCount = await q.CountAsync();
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
CurrentPage = Math.Clamp(Page, 1, TotalPages);
var skip = (CurrentPage - 1) * PageSize;
if (NearMeActive)
{
foreach (var j in results)
// Distance sort needs all rows in memory; paginate after sorting.
var all = await q.ToListAsync();
foreach (var j in all)
if (j.Facility.Lat is double flat && j.Facility.Lng is double flng)
j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
Results = results.OrderBy(j => j.DistanceKm ?? double.MaxValue)
.ThenByDescending(j => j.CreatedAt).ToList();
Results = all.OrderBy(j => j.DistanceKm ?? double.MaxValue)
.ThenByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToList();
}
else
{
Results = results.OrderByDescending(j => j.CreatedAt).ToList();
Results = await q.OrderByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToListAsync();
}
SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name);
return Page();
}
/// <summary>Title/H1/meta from the active role+city so the page targets «استخدام [نقش] [شهر]».</summary>
private void SetSeo(string? role, string? city)
{
PageHeading =
role is not null && city is not null ? $"استخدام {role} در {city}"
: role is not null ? $"استخدام {role}"
: city is not null ? $"استخدام کادر درمان در {city}"
: "موقعیت‌های استخدامی";
ViewData["Title"] = PageHeading;
ViewData["Description"] = role is not null || city is not null
? $"جدیدترین آگهی‌های {PageHeading} در همکادر؛ مشاهده فرصت‌ها و تماس مستقیم با مراکز درمانی."
: "موقعیت‌های استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستان‌ها و کلینیک‌های تهران — همکادر.";
if (role is not null || city is not null)
PageIntro = $"در این صفحه جدیدترین فرصت‌های {PageHeading}، گردآوری‌شده از منابع معتبر، را می‌بینید. "
+ "روی هر آگهی بزنید تا جزئیات، شرایط و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. "
+ "برای فرصت‌های مرتبط، نقش یا شهر دیگری را از لینک‌های بالا انتخاب کنید.";
var crumbs = new List<Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
if (role is not null) crumbs.Add(new(role, "/استخدام/" + SeoSlug.Of(role)));
if (city is not null) crumbs.Add(new(city, null));
Breadcrumbs = crumbs;
}
}
+47
View File
@@ -0,0 +1,47 @@
@page
@model JobsMedical.Web.Pages.Me.LikedModel
@{
ViewData["Title"] = "پسندیده‌ها";
}
<div class="page-head">
<div class="container">
<h1>❤️ پسندیده‌ها</h1>
<p class="muted">فرصت‌هایی که پسندیده‌ای — برای حذف، دوباره روی دکمهٔ ♥ بزن.</p>
</div>
</div>
<div class="container section">
@if (Model.Total == 0)
{
<div class="card empty-state">
هنوز چیزی نپسندیده‌ای. در <a asp-page="/Jobs/Index">استخدام</a>،
<a asp-page="/Shifts/Index">شیفت‌ها</a> و <a asp-page="/Talent/Index">آماده‌به‌کار</a>
فرصت‌ها را ببین و آن‌هایی که دوست داری را با دکمهٔ ♥ پسند کن.
</div>
}
@if (Model.Jobs.Count > 0)
{
<div class="section-head"><h2>موقعیت‌های استخدامی (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
</div>
}
@if (Model.Shifts.Count > 0)
{
<div class="section-head" style="margin-top:18px;"><h2>شیفت‌ها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
@if (Model.Talent.Count > 0)
{
<div class="section-head" style="margin-top:18px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
</div>
}
</div>
@@ -0,0 +1,45 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Me;
/// <summary>«پسندیده‌ها» — the listings the logged-in user has liked (jobs, shifts, talent), newest
/// first. Only still-open listings are shown; un-liking is done with the same heart button.</summary>
[Authorize]
public class LikedModel : PageModel
{
private readonly AppDbContext _db;
public LikedModel(AppDbContext db) => _db = db;
public List<JobOpening> Jobs { get; private set; } = new();
public List<Shift> Shifts { get; private set; } = new();
public List<TalentListing> Talent { get; private set; } = new();
public int Total => Jobs.Count + Shifts.Count + Talent.Count;
public async Task OnGetAsync()
{
var uid = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
var likes = await _db.Likes.Where(l => l.UserId == uid)
.OrderByDescending(l => l.CreatedAt).ToListAsync();
var jobIds = likes.Where(l => l.TargetType == LikeTargetType.Job).Select(l => l.TargetId).ToList();
var shiftIds = likes.Where(l => l.TargetType == LikeTargetType.Shift).Select(l => l.TargetId).ToList();
var talentIds = likes.Where(l => l.TargetType == LikeTargetType.Talent).Select(l => l.TargetId).ToList();
Jobs = await _db.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District)
.Include(j => j.Role)
.Where(j => jobIds.Contains(j.Id) && j.Status == ShiftStatus.Open).ToListAsync();
Shifts = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.Where(s => shiftIds.Contains(s.Id) && s.Status == ShiftStatus.Open).ToListAsync();
Talent = await _db.TalentListings
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => talentIds.Contains(t.Id) && t.Status == ShiftStatus.Open).ToListAsync();
}
}
@@ -29,31 +29,13 @@ public class IndexModel : PageModel
public bool Saved { get; private set; }
public async Task OnGetAsync()
{
await LoadListsAsync();
var prefs = await _interest.GetPreferencesAsync();
if (prefs is not null)
{
RoleId = prefs.RoleId;
CityId = prefs.CityId;
PreferredShiftType = prefs.PreferredShiftType;
MinPay = prefs.MinPay;
Gender = prefs.Gender;
}
}
// Preferences have moved onto the «پیشنهادهای ویژه شما» page (settings next to their result).
// Keep this route working by redirecting any old link/bookmark there.
public IActionResult OnGet() => RedirectToPage("/Recommendations/Index");
public async Task<IActionResult> OnPostAsync()
{
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
// Back to home so the personalized feed is the immediate payoff.
TempData["prefsSaved"] = true;
return RedirectToPage("/Index");
}
private async Task LoadListsAsync()
{
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
return RedirectToPage("/Recommendations/Index");
}
}
@@ -0,0 +1,94 @@
@page "/Recommendations"
@model JobsMedical.Web.Pages.Recommendations.IndexModel
@{
ViewData["Title"] = "پیشنهادهای ویژه شما";
ViewData["Description"] = "پیشنهادهای شخصی‌سازی‌شدهٔ شیفت و استخدام برای شما در همکادر — بر اساس نقش، شهر و فعالیت شما.";
ViewData["NoIndex"] = true; // personalized to the visitor — not an indexable page
}
<div class="page-head">
<div class="container">
<h1>✨ پیشنهادهای ویژه شما</h1>
<p class="muted">
@(Model.HasPersonalization
? "بر اساس علاقه‌مندی‌ها و فعالیت شما انتخاب شده‌اند. علاقه‌مندی‌ها را پایین‌تر تنظیم کن."
: "نقش، شهر و نوع شیفت دلخواهت را تنظیم کن تا بهترین فرصت‌ها را برایت پیدا کنیم.")
</p>
</div>
</div>
<div class="container section">
@if (Model.Saved)
{
<div class="alert alert-success">✓ علاقه‌مندی‌ها ذخیره شد — پیشنهادها به‌روزرسانی شدند.</div>
}
@* Preferences — the settings that drive the feed, collapsed by default once personalized. *@
<details class="card card-pad" style="margin-bottom:18px;" @(Model.HasPersonalization ? "" : "open")>
<summary style="font-weight:800; cursor:pointer; font-size:16px;">⚙️ تنظیم علاقه‌مندی‌ها</summary>
<form method="post" style="margin-top:14px;">
<div class="grid grid-3">
<div class="filter-group">
<label>نقش / رشته</label>
<select name="RoleId">
<option value="">مهم نیست</option>
@foreach (var r in Model.Roles)
{
<option value="@r.Id" selected="@(Model.RoleId == r.Id)">@r.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>شهر</label>
<select name="CityId">
<option value="">مهم نیست</option>
@foreach (var c in Model.Cities)
{
<option value="@c.Id" selected="@(Model.CityId == c.Id)">@c.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نوع شیفت ترجیحی</label>
<select name="PreferredShiftType">
<option value="">مهم نیست</option>
<option value="0" selected="@(Model.PreferredShiftType == ShiftType.Day)">صبح</option>
<option value="1" selected="@(Model.PreferredShiftType == ShiftType.Evening)">عصر</option>
<option value="2" selected="@(Model.PreferredShiftType == ShiftType.Night)">شب</option>
<option value="3" selected="@(Model.PreferredShiftType == ShiftType.OnCall)">آنکال</option>
</select>
</div>
<div class="filter-group">
<label>جنسیت شما</label>
<select name="Gender">
<option value="0" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Any)">نمی‌خواهم بگویم</option>
<option value="1" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Male)">آقا</option>
<option value="2" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Female)">خانم</option>
</select>
</div>
<div class="filter-group">
<label>حداقل حقوق مورد انتظار (تومان)</label>
<input type="number" name="MinPay" value="@Model.MinPay" placeholder="مثلاً ۲۰۰۰۰۰۰۰" dir="ltr" />
</div>
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top:6px;">ذخیره و دیدن پیشنهادها</button>
</form>
</details>
@if (Model.Recommendations.Count > 0)
{
<div class="grid grid-3">
@foreach (var rec in Model.Recommendations)
{
<partial name="_RecommendationCard" model="rec" />
}
</div>
}
else
{
<div class="card empty-state">
هنوز پیشنهادی برای شما نیست. علاقه‌مندی‌هایت را تنظیم کن یا چند فرصت را در
<a asp-page="/Jobs/Index">استخدام</a> و <a asp-page="/Shifts/Index">شیفت‌ها</a> ببین تا پیشنهادها شخصی شوند.
</div>
}
</div>
@@ -0,0 +1,66 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Recommendations;
/// <summary>
/// Dedicated «پیشنهادهای ویژه شما» page: the personalized recommendation feed plus the preference
/// controls that drive it (role/city/shift-type/pay/gender), in one place — moved off the homepage
/// and consolidating the old /Preferences screen so the settings live next to their result.
/// </summary>
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
private readonly RecommendationService _recs;
private readonly InterestService _interest;
public IndexModel(AppDbContext db, RecommendationService recs, InterestService interest)
{
_db = db;
_recs = recs;
_interest = interest;
}
public List<Recommendation> Recommendations { get; private set; } = new();
public bool HasPersonalization { get; private set; }
public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
[BindProperty] public int? RoleId { get; set; }
[BindProperty] public int? CityId { get; set; }
[BindProperty] public ShiftType? PreferredShiftType { get; set; }
[BindProperty] public long? MinPay { get; set; }
[BindProperty] public Gender Gender { get; set; }
[TempData] public bool Saved { get; set; }
public async Task OnGetAsync() => await LoadAsync();
public async Task<IActionResult> OnPostAsync()
{
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
Saved = true;
return RedirectToPage(); // reload so the feed reflects the new preferences immediately
}
private async Task LoadAsync()
{
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Recommendations = await _recs.GetForVisitorAsync(12);
var prefs = await _interest.GetPreferencesAsync();
HasPersonalization = prefs?.HasAny == true || (await _interest.RecentEventsAsync(1)).Count > 0;
if (prefs is not null)
{
RoleId = prefs.RoleId;
CityId = prefs.CityId;
PreferredShiftType = prefs.PreferredShiftType;
MinPay = prefs.MinPay;
Gender = prefs.Gender;
}
}
}
@@ -0,0 +1,20 @@
@model IReadOnlyList<JobsMedical.Web.Services.Crumb>
@* Visible breadcrumb trail. The last crumb is the current page (no link). Pair with the
BreadcrumbList JSON-LD (SeoJsonLd.Breadcrumb) emitted in @@section Head. *@
@if (Model is { Count: > 1 })
{
<nav class="breadcrumbs" aria-label="مسیر">
@for (var i = 0; i < Model.Count; i++)
{
if (i > 0) { <span class="bc-sep" aria-hidden="true"></span> }
@if (!string.IsNullOrEmpty(Model[i].Url) && i < Model.Count - 1)
{
<a href="@Model[i].Url">@Model[i].Name</a>
}
else
{
<span class="bc-current">@Model[i].Name</span>
}
}
</nav>
}
@@ -0,0 +1,17 @@
@model IReadOnlyList<JobsMedical.Web.Models.ContactMethod>
@* Renders one row per contact channel (phone/Bale/Telegram/email/…) with a clickable action.
Shared by the shift, job, and applicant detail pages. *@
@foreach (var c in Model.OrderBy(c => c.SortOrder))
{
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";
<div class="contact-row">
<span class="c-meta"><span class="c-type">@icon @label</span><span class="c-val" dir="ltr">@c.Value</span></span>
@if (href is not null)
{
<a class="btn @cls" href="@href" target="_blank" rel="noopener">@(c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "تماس" : "باز کردن")</a>
}
</div>
}
@@ -10,6 +10,7 @@
string salary;
if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی";
else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه";
else if (Model.SalaryMax is null) salary = "از " + JalaliDate.Toman(Model.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه";
var q = ViewData["q"] as string;
}
@@ -27,7 +28,10 @@
{
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
}
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(Model.Facility))
{
<span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
}
</div>
<div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
@if (Model.DistanceKm is double km)
@@ -39,6 +43,7 @@
{
<div class="search-snippet">@snip</div>
}
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
<div class="foot">
<span class="pay">@salary</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
+112 -14
View File
@@ -109,20 +109,25 @@
</label>
<div class="nav-collapse">
@* Browse items only — personal ones (پیشنهادها/پسندیده‌ها) live in the profile menu. *@
<nav class="main-nav">
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
<a asp-page="/Shifts/Index" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a asp-page="/Jobs/Index" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a href="/Jobs" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a href="/Shifts" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">مراکز درمانی</a>
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a>
</nav>
<form class="nav-search" method="get" action="/Search" role="search" data-suggest>
<div class="nav-search-pill">
<input type="search" name="Q" placeholder="جستجو…" aria-label="جستجو" autocomplete="off" />
<button type="submit" aria-label="جستجو">🔎</button>
@if (User.Identity?.IsAuthenticated != true)
{
<a asp-page="/Recommendations/Index" class="@(path.StartsWith("/Recommendations") ? "active" : null)">✨ پیشنهادها</a>
}
<details class="nav-more">
<summary class="@(path.StartsWith("/Facilities") || path.StartsWith("/Calendar") ? "active" : null)">بیشتر ▾</summary>
<div class="nav-more-menu">
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">🏥 مراکز درمانی</a>
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">🗓️ تقویم هفتگی</a>
</div>
</form>
</details>
<a asp-page="/Search" class="nav-search-link @(path.StartsWith("/Search") ? "active" : null)">🔎 جستجو</a>
</nav>
<div class="header-actions">
<a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post"> ثبت آگهی</a>
@if (User.Identity?.IsAuthenticated == true)
@@ -162,6 +167,9 @@
</div>
</div>
<div class="pd-sep"></div>
<a asp-page="/Recommendations/Index">✨ پیشنهادهای ویژه شما</a>
<a asp-page="/Me/Liked">❤️ پسندیده‌ها</a>
<div class="pd-sep"></div>
<a href="@dashUrl" data-tour="panel">@dashIcon @dashLabel</a>
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</a>
<div class="pd-sep"></div>
@@ -225,6 +233,10 @@
document.addEventListener('click', function (e) {
var t = document.getElementById('profile-toggle');
if (t && t.checked && !e.target.closest('.profile-menu')) t.checked = false;
// Close the «بیشتر» nav dropdown when clicking outside it.
document.querySelectorAll('details.nav-more[open]').forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute('open');
});
});
</script>
@@ -247,7 +259,9 @@
var box = document.createElement('div');
box.className = 'nav-search-results';
box.style.display = 'none';
form.appendChild(box);
// Anchor the dropdown to the input's box (the hero pill) so it sits
// directly under the input rather than below the popular-search chips.
(input.closest('.hero-search-pill') || form).appendChild(box);
var timer;
function hide() { box.style.display = 'none'; box.innerHTML = ''; }
input.addEventListener('input', function () {
@@ -257,15 +271,18 @@
timer = setTimeout(function () {
fetch('/search/suggest?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (items) {
if (!items || !items.length) { hide(); return; }
.then(function (data) {
var items = (data && data.items) || [];
var total = (data && data.total) || items.length;
if (!items.length) { hide(); return; }
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
var html = items.map(function (it) {
var sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
'</span><span class="ns-text"><span class="ns-label">' + hi(it.label, q) +
'</span>' + sub + '</span></a>';
}).join('');
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">همه نتایج برای «' + esc(q) + '» ←</a>';
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">مشاهده همه ' + fa(total) + ' نتیجه برای «' + esc(q) + '» ←</a>';
box.innerHTML = html;
box.style.display = 'block';
}).catch(function () { hide(); });
@@ -278,6 +295,87 @@
})();
</script>
@* Contact modal — any element with data-contact-type + data-contact-id opens it; numbers are
fetched from /contact on click (so they never sit in list HTML and bots can't scrape them). *@
<div id="contactModal" class="contact-modal" aria-hidden="true">
<div class="contact-modal-box" role="dialog" aria-modal="true" aria-labelledby="contactModalTitle">
<div class="contact-modal-head">
<h3 id="contactModalTitle">راه‌های ارتباطی</h3>
<button type="button" class="contact-modal-x" data-contact-close aria-label="بستن">✕</button>
</div>
<div id="contactModalBody" class="contact-modal-body"></div>
</div>
</div>
@* Like («پسندیدن») toggle — login-gated, updates the button state + count in place. *@
<script>
(function () {
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
document.addEventListener('click', function (e) {
var b = e.target.closest('.like-trigger');
if (!b) return;
e.preventDefault();
if (document.body.dataset.authed !== '1') {
location.href = '/Account/Login?returnUrl=' + encodeURIComponent(location.pathname);
return;
}
var fd = new FormData();
fd.append('type', b.dataset.likeType);
fd.append('id', b.dataset.likeId);
b.disabled = true;
fetch('/like', { method: 'POST', body: fd })
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (d) {
b.dataset.liked = d.liked ? 'true' : 'false';
b.classList.toggle('btn-accent', d.liked);
b.classList.toggle('btn-outline', !d.liked);
var ico = b.querySelector('.like-ico'); if (ico) ico.textContent = d.liked ? '♥' : '♡';
var c = b.querySelector('.like-count'); if (c) c.textContent = fa(d.count);
})
.catch(function () {})
.finally(function () { b.disabled = false; });
});
})();
</script>
<script>
(function () {
var modal = document.getElementById('contactModal');
var box = document.getElementById('contactModalBody');
var titleEl = document.getElementById('contactModalTitle');
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function open() { modal.classList.add('show'); modal.setAttribute('aria-hidden', 'false'); }
function close() { modal.classList.remove('show'); modal.setAttribute('aria-hidden', 'true'); box.innerHTML = ''; }
function render(d) {
titleEl.textContent = d.title || 'راه‌های ارتباطی';
var html = '';
(d.contacts || []).forEach(function (c) {
html += '<div class="contact-row"><span class="c-meta"><span class="c-type">' + esc(c.icon + ' ' + c.label) +
'</span><span class="c-val" dir="ltr">' + esc(c.value) + '</span></span>' +
(c.href ? '<a class="btn btn-accent" href="' + esc(c.href) + '" target="_blank" rel="nofollow noopener">تماس</a>' : '') + '</div>';
});
if (d.fallbackUrl) html += '<a class="btn btn-accent btn-block" href="' + esc(d.fallbackUrl) +
'" target="_blank" rel="nofollow noopener">' + esc(d.fallbackLabel || 'مشاهده') + '</a>';
box.innerHTML = html || '<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>';
}
document.addEventListener('click', function (e) {
var trigger = e.target.closest('[data-contact-type]');
if (trigger) {
e.preventDefault(); e.stopPropagation(); // don't follow the card link
titleEl.textContent = 'راه‌های ارتباطی';
box.innerHTML = '<p class="muted" style="margin:0;">در حال دریافت…</p>';
open();
fetch('/contact?type=' + encodeURIComponent(trigger.getAttribute('data-contact-type')) +
'&id=' + encodeURIComponent(trigger.getAttribute('data-contact-id')))
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(render)
.catch(function () { box.innerHTML = '<p class="muted" style="margin:0;">خطا در دریافت اطلاعات تماس.</p>'; });
return;
}
if (e.target.closest('[data-contact-close]') || e.target === modal) close();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
})();
</script>
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push).
Updates the bell badge, shows a toast, and fires a local OS notification when allowed. *@
@if (User.Identity?.IsAuthenticated == true)
@@ -4,18 +4,25 @@
data-lat="…" data-lng="…"> exists. Pass the Neshan web key as the model.
The SDK is a synchronous script, so the init below runs once L is defined.
*@
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.css" />
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script>
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.css" />
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.js"></script>
<script>
(function () {
var el = document.getElementById('facmap');
if (!el || !window.L) return;
var lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng);
if (isNaN(lat) || isNaN(lng)) return;
// Approximate (aggregated) listings show a shaded AREA circle, not a precise pin.
var approx = el.dataset.approx === 'true';
var map = new L.Map('facmap', {
key: '@Model', maptype: 'neshan', poi: true, traffic: false,
center: [lat, lng], zoom: 15
key: '@Model', maptype: 'neshan', poi: !approx, traffic: false,
center: [lat, lng], zoom: approx ? 14 : 15
});
if (approx) {
var radius = parseInt(el.dataset.radius || '700', 10);
L.circle([lat, lng], { radius: radius, color: '#e07b39', weight: 1, fillColor: '#e07b39', fillOpacity: 0.18 }).addTo(map);
} else {
L.marker([lat, lng]).addTo(map);
}
})();
</script>
@@ -0,0 +1,51 @@
@model (int Current, int Total)
@{
var (cur, total) = Model;
}
@if (total > 1)
{
@* Build a page URL that preserves every current filter in the query string. *@
Func<int, string> pageUrl = p =>
{
var parts = Context.Request.Query
.Where(kv => !string.Equals(kv.Key, "Page", StringComparison.OrdinalIgnoreCase))
.Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString()))
.ToList();
parts.Add("Page=" + p);
return Context.Request.Path + "?" + string.Join("&", parts);
};
var from = Math.Max(1, cur - 2);
var to = Math.Min(total, cur + 2);
<nav class="pager" aria-label="صفحه‌بندی">
@if (cur > 1)
{
<a class="pager-btn" href="@pageUrl(cur - 1)" rel="prev">→ قبلی</a>
}
@if (from > 1)
{
<a class="pager-num" href="@pageUrl(1)">@JalaliDate.ToPersianDigits("1")</a>
@if (from > 2) { <span class="pager-gap">…</span> }
}
@for (var p = from; p <= to; p++)
{
if (p == cur)
{
<span class="pager-num active" aria-current="page">@JalaliDate.ToPersianDigits(p.ToString())</span>
}
else
{
<a class="pager-num" href="@pageUrl(p)">@JalaliDate.ToPersianDigits(p.ToString())</a>
}
}
@if (to < total)
{
@if (to < total - 1) { <span class="pager-gap">…</span> }
<a class="pager-num" href="@pageUrl(total)">@JalaliDate.ToPersianDigits(total.ToString())</a>
}
@if (cur < total)
{
<a class="pager-btn" href="@pageUrl(cur + 1)" rel="next">بعدی ←</a>
}
</nav>
}
@@ -16,6 +16,7 @@
<a class="@(On("/Admin/Index") ? "active" : null)" asp-page="/Admin/Index">📥 صف آگهی‌ها</a>
<a class="@(On("/Admin/Ingested") ? "active" : null)" asp-page="/Admin/Ingested">📜 نتایج جمع‌آوری</a>
<a class="@(On("/Admin/Facilities") ? "active" : null)" asp-page="/Admin/Facilities">🏥 مراکز</a>
<a class="@(On("/Admin/Roles") ? "active" : null)" asp-page="/Admin/Roles">🏷️ نقش‌ها</a>
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارش‌ها</a>
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>
@@ -8,7 +8,7 @@
<strong>@Model.SourceChannel</strong>
<span style="display:flex; gap:8px; align-items:center;">
<span class="badge @confClass">اطمینان @JalaliDate.ToPersianDigits(c.ToString())٪</span>
<span class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(Model.FetchedAt)) — @JalaliDate.ToPersianDigits(Model.FetchedAt.ToString("HH:mm"))</span>
<span class="muted" style="font-size:12px;">@JalaliDate.DateTimeLabel(Model.FetchedAt)</span>
</span>
</div>
<p style="margin:10px 0; white-space:pre-wrap;">@Model.RawText</p>
@@ -1,6 +1,28 @@
@model JobsMedical.Web.Services.Recommendation
@{
var s = Model.Shift;
var isJob = Model.IsJob;
var role = isJob ? Model.Job!.Role?.Name : Model.Shift!.Role?.Name;
var fac = isJob ? Model.Job!.Facility : Model.Shift!.Facility;
var gender = isJob ? Model.Job!.GenderRequirement : Model.Shift!.GenderRequirement;
var url = isJob ? $"/Jobs/Details/{Model.Job!.Id}" : $"/Shifts/Details/{Model.Shift!.Id}";
string empLabel(JobsMedical.Web.Models.EmploymentType t) => t switch
{
JobsMedical.Web.Models.EmploymentType.PartTime => "پاره‌وقت",
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
JobsMedical.Web.Models.EmploymentType.Plan => "طرح",
_ => "تمام‌وقت",
};
}
<a class="card card-pad shift-card" href="@url">
<div class="row" style="justify-content: space-between;">
<span class="facility">@(role ?? (isJob ? "استخدام" : "شیفت"))</span>
@if (isJob)
{
<span class="badge badge-job">استخدام</span>
}
else
{
var s = Model.Shift!;
var (badgeClass, typeLabel) = s.ShiftType switch
{
ShiftType.Day => ("badge-day", "صبح"),
@@ -8,25 +30,31 @@
ShiftType.Night => ("badge-night", "شب"),
_ => ("badge-oncall", "آنکال"),
};
}
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@s.Id">
<div class="row" style="justify-content: space-between;">
<span class="facility">@s.Facility?.Name</span>
<span class="badge @badgeClass">@typeLabel</span>
}
</div>
<div class="row">
@if (s.Role is not null)
@if (gender != Gender.Any)
{
<span class="badge badge-type">@s.Role.Name</span>
<span class="badge badge-gender">@JalaliDate.GenderLabel(gender)</span>
}
@if (s.GenderRequirement != Gender.Any)
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(fac))
{
<span class="badge badge-gender">@JalaliDate.GenderLabel(s.GenderRequirement)</span>
<span>🏥 @fac?.Name</span>
}
<span>📍 @s.Facility?.City?.Name</span>
<span>📍 @fac?.City?.Name</span>
</div>
@if (isJob)
{
<div class="row">💼 @empLabel(Model.Job!.EmploymentType)</div>
}
else
{
var s = Model.Shift!;
<div class="row">📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)</div>
<partial name="_HourBar" model="s" />
}
@* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
<div class="rec-reasons">
@@ -37,7 +65,17 @@
</div>
<div class="foot">
<span class="pay">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span>
<span class="pay">
@if (isJob)
{
@(Model.Job!.SalaryMin is long m ? JalaliDate.ToPersianDigits(m.ToString("#,0")) + " تومان" : "توافقی")
}
else
{
var s = Model.Shift!;
@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)
}
</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
</div>
</a>
@@ -11,14 +11,10 @@
}
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;">
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role?.Name ?? "شیفت", q)</span>
<span class="badge @badgeClass">@typeLabel</span>
</div>
<div class="row">
@if (Model.Role is not null)
{
<span class="badge badge-type">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q)</span>
}
@if (Model.GenderRequirement != Gender.Any)
{
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
@@ -27,6 +23,10 @@
{
<span class="badge badge-verified">✓ تأیید شده</span>
}
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(Model.Facility))
{
<span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
}
</div>
<div class="row loc-row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
@if (Model.DistanceKm is double km)
@@ -41,6 +41,7 @@
<div class="search-snippet">@snip</div>
}
<partial name="_HourBar" model="Model" />
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
<div class="foot">
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
@@ -54,6 +54,7 @@
}
</div>
}
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
<div class="foot">
<span class="pay">@comp</span>
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
+58 -49
View File
@@ -3,8 +3,17 @@
@{
var s = Model.Shift!;
var f = s.Facility!;
ViewData["Title"] = $"شیفت {s.SpecialtyRequired} - {f.Name}";
ViewData["Description"] = $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}.";
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
var shiftContacts = (s.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).ToList();
// Map: prefer the listing's own approx coords (aggregated ads) then the facility's. Aggregated =
// approximate → shown as an area circle with a disclaimer, never a precise pin.
var mapLat = s.Lat ?? f.Lat;
var mapLng = s.Lng ?? f.Lng;
var mapApprox = s.Source == JobsMedical.Web.Models.ShiftSource.Aggregated;
ViewData["Title"] = hasFac ? $"شیفت {s.SpecialtyRequired} - {f.Name}" : $"شیفت {s.SpecialtyRequired} — {f.City?.Name}";
ViewData["Description"] = hasFac
? $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}."
: $"شیفت {s.SpecialtyRequired} در {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}.";
// Past/filled shifts shouldn't stay in the index as dead pages.
if (s.Status != JobsMedical.Web.Models.ShiftStatus.Open || s.Date < DateOnly.FromDateTime(DateTime.UtcNow))
ViewData["NoIndex"] = true;
@@ -15,10 +24,14 @@
ShiftType.Night => ("badge-night", "شیفت شب"),
_ => ("badge-oncall", "آنکال"),
};
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("شیفت‌ها", "/Shifts") };
if (s.Role is not null) crumbs.Add(new(s.Role.Name, "/شیفت/" + JobsMedical.Web.Services.SeoSlug.Of(s.Role.Name)));
crumbs.Add(new(JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) ? f.Name : "جزئیات شیفت", null));
}
<div class="page-head">
<div class="container">
<partial name="_Breadcrumbs" model="crumbs" />
<div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge @badgeClass">@typeLabel</span>
@if (f.IsVerified)
@@ -26,7 +39,7 @@
<span class="badge badge-verified">✓ مرکز تأیید شده</span>
}
</div>
<h1 style="margin-top:8px;">@s.SpecialtyRequired — @f.Name</h1>
<h1 style="margin-top:8px;">@s.SpecialtyRequired@(hasFac ? " — " + f.Name : "")</h1>
<p class="muted">📍 @f.City?.Name @(string.IsNullOrEmpty(f.Address) ? "" : "، " + f.Address)</p>
</div>
</div>
@@ -37,11 +50,18 @@
@if (Model.ShowContact)
{
<div class="contact-reveal" style="margin-bottom:16px;">
<h4>✓ راه‌های ارتباطی مرکز</h4>
<h4>✓ راه‌های ارتباطی</h4>
@if (shiftContacts.Count > 0)
{
@* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@
<partial name="_ContactList" model="shiftContacts" />
}
else if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) && (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)))
{
@if (!string.IsNullOrEmpty(f.Phone))
{
<div class="contact-row">
<span class="c-meta"><span class="c-type">📞 تلفن</span><span class="c-val" dir="ltr">@f.Phone</span></span>
<span class="c-meta"><span class="c-type">📞 تلفن مرکز</span><span class="c-val" dir="ltr">@f.Phone</span></span>
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
</div>
}
@@ -52,9 +72,10 @@
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
</div>
}
@if (string.IsNullOrEmpty(f.Phone) && string.IsNullOrEmpty(f.BaleId))
}
else
{
<p class="muted" style="margin:0;">شماره‌ای برای این مرکز ثبت نشده است.</p>
<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>
}
</div>
}
@@ -84,7 +105,7 @@
</div>
}
@if (Model.MoreAtFacility.Count > 0)
@if (hasFac && Model.MoreAtFacility.Count > 0)
{
<h3 style="margin:26px 0 14px;">شیفت‌های دیگر این مرکز</h3>
<div class="grid grid-3">
@@ -110,22 +131,15 @@
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ می‌شود.</div>
}
<div class="aside-apply">
<form method="post">
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id"
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
</form>
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده می‌شود.</p>
</div>
<div style="display:flex; gap:8px;">
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id"
class="btn btn-outline btn-block">♡ ذخیره</button>
</form>
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@s.Id"
class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger"
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
@if (Model.Reported)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
@@ -144,7 +158,7 @@
</form>
</details>
<details style="margin-top:6px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@f.Name)</summary>
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این @(hasFac ? "مرکز (" + f.Name + ")" : "آگهی")</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Facility" />
<input type="hidden" name="targetId" value="@f.Id" />
@@ -159,13 +173,13 @@
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">موقعیت مکانی</h3>
@if (f.Lat is not null && f.Lng is not null)
@if (mapLat is not null && mapLng is not null)
{
var latS = f.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = f.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
@if (!string.IsNullOrEmpty(Model.MapKey))
{
<div id="facmap" data-lat="@latS" data-lng="@lngS" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="@(mapApprox ? "true" : "false")" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
}
else
{
@@ -173,12 +187,16 @@
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
</div>
}
@if (mapApprox)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبی (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
}
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
}
else
{
<p class="muted" style="margin:0;">مختصات این مرکز هنوز ثبت نشده است.</p>
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
}
</div>
</aside>
@@ -187,34 +205,25 @@
@* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@
<div class="mobile-action-bar">
@if (Model.ShowContact)
{
@if (!string.IsNullOrEmpty(f.Phone))
{
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a>
}
else
{
<span class="cta-main center muted" style="align-self:center;">اطلاعات تماس در بالای صفحه</span>
}
}
else
{
<form method="post" class="cta-main">
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id" class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده تماس</button>
</form>
<form method="post">
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
</form>
}
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده تماس</button>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
</div>
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Shift?.Facility?.Lat is not null)
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@section Head {
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
@* Only for a real named employer — Google for Jobs rejects a placeholder hiringOrganization. *@
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ShiftPosting(s, bu) + "</script>")
}
}
@@ -24,6 +24,8 @@ public class DetailsModel : PageModel
public Shift? Shift { get; private set; }
public List<Shift> MoreAtFacility { get; private set; } = new();
public string? MapKey { get; private set; }
public int LikeCount { get; private set; }
public bool IsLiked { get; private set; }
// Set after the visitor taps "interested" — reveals the facility contact (handoff model).
public bool ShowContact { get; private set; }
@@ -34,7 +36,13 @@ public class DetailsModel : PageModel
{
await LoadAsync(id);
if (Shift is null) return NotFound();
// Intentionally removed (admin-archived out-of-scope/duplicate ad): 410 Gone is the standard
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
if (Shift.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
MapKey = (await _settings.GetAsync()).NeshanMapKey;
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Shift && l.TargetId == id);
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Shift && l.TargetId == id);
Reported = Request.Query["reported"] == "1";
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
return Page();
@@ -69,6 +77,7 @@ public class DetailsModel : PageModel
Shift = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.Include(s => s.Contacts)
.FirstOrDefaultAsync(s => s.Id == id);
if (Shift is not null)
+29 -3
View File
@@ -1,23 +1,39 @@
@page
@model JobsMedical.Web.Pages.Shifts.IndexModel
@{
ViewData["Title"] = "شیفت‌های موجود";
// Title/description are set in the page model (SetSeo) from the active role/city.
}
<div class="page-head">
<div class="container">
<h1>شیفت‌های موجود</h1>
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
<h1>@Model.PageHeading</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) شیفت باز پیدا شد
@if (Model.NearMeActive)
{
<span> — مرتب‌شده بر اساس نزدیک‌ترین به شما 📍</span>
}
</p>
@if (!string.IsNullOrEmpty(Model.PageIntro))
{
<p class="muted" style="max-width:720px; font-size:13.5px; line-height:1.9;">@Model.PageIntro</p>
}
</div>
</div>
<div class="container section">
@if (Model.Roles.Count > 0)
{
@* Internal links to the SEO landing pages (/شیفت/{نقش}); this page is itself such a page. *@
<div class="role-links">
<span class="rl-label">شیفت بر اساس نقش:</span>
@foreach (var r in Model.Roles.Take(14))
{
<a class="rl-chip" href="/شیفت/@JobsMedical.Web.Services.SeoSlug.Of(r.Name)">@r.Name</a>
}
</div>
}
<div class="layout-2">
<aside class="card card-pad filter-card">
<h3>فیلترها</h3>
@@ -129,6 +145,7 @@
<partial name="_ShiftCard" model="s" />
}
</div>
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
}
</div>
</div>
@@ -158,3 +175,12 @@
}
</script>
}
@section Head {
@{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(Model.Breadcrumbs, bcUrl) + "</script>")
@if (Model.Results.Count > 0)
{
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ItemList(Model.Results.Select(s => "/Shifts/Details/" + s.Id), bcUrl) + "</script>")
}
}
@@ -25,6 +25,16 @@ public class IndexModel : PageModel
[BindProperty(SupportsGet = true)] public double? Lat { get; set; }
[BindProperty(SupportsGet = true)] public double? Lng { get; set; }
// Pretty-URL segments (/شیفت/{roleSlug}/{citySlug?}); resolved to RoleId/CityId below.
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
[BindProperty(SupportsGet = true)] public string? CitySlug { get; set; }
[BindProperty(SupportsGet = true)] public int Page { get; set; } = 1;
private const int PageSize = 24;
public int TotalCount { get; private set; }
public int TotalPages { get; private set; }
public int CurrentPage { get; private set; }
public bool NearMeActive => Lat is not null && Lng is not null;
public List<Shift> Results { get; private set; } = new();
@@ -33,12 +43,36 @@ public class IndexModel : PageModel
public List<Role> Roles { get; private set; } = new();
public List<Facility> Facilities { get; private set; } = new();
public async Task OnGetAsync()
/// <summary>Dynamic page heading/H1 + title, set from the active role/city for SEO.</summary>
public string PageHeading { get; private set; } = "شیفت‌های خالی";
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
public string? PageIntro { get; private set; }
/// <summary>Breadcrumb trail (also emitted as BreadcrumbList JSON-LD).</summary>
public IReadOnlyList<Crumb> Breadcrumbs { get; private set; } = Array.Empty<Crumb>();
public async Task<IActionResult> OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
// Pretty-URL landing: resolve slugs → filters (404 on a slug that matches nothing).
if (!string.IsNullOrWhiteSpace(RoleSlug))
{
var role = Roles.FirstOrDefault(r => SeoSlug.Matches(r.Name, RoleSlug));
if (role is null) return NotFound();
RoleId = role.Id;
}
if (!string.IsNullOrWhiteSpace(CitySlug))
{
var city = Cities.FirstOrDefault(c => SeoSlug.Matches(c.Name, CitySlug));
if (city is null) return NotFound();
CityId = city.Id;
}
Districts = await _db.Districts
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
.OrderBy(d => d.Name).ToListAsync();
@@ -63,24 +97,53 @@ public class IndexModel : PageModel
if (GenderFilter is Gender g && g != Gender.Any)
q = q.Where(s => s.GenderRequirement == Gender.Any || s.GenderRequirement == g);
var results = await q.ToListAsync();
TotalCount = await q.CountAsync();
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
CurrentPage = Math.Clamp(Page, 1, TotalPages);
var skip = (CurrentPage - 1) * PageSize;
if (NearMeActive)
{
// Compute distance to each facility, then nearest-first (shifts without coords last).
foreach (var s in results)
{
// Distance sort needs all rows in memory; paginate after sorting (shifts without coords last).
var all = await q.ToListAsync();
foreach (var s in all)
if (s.Facility.Lat is double flat && s.Facility.Lng is double flng)
s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
}
Results = results
Results = all
.OrderBy(s => s.DistanceKm ?? double.MaxValue)
.ThenBy(s => s.Date).ThenBy(s => s.StartTime)
.ToList();
.Skip(skip).Take(PageSize).ToList();
}
else
{
Results = results.OrderBy(s => s.Date).ThenBy(s => s.StartTime).ToList();
Results = await q.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
.Skip(skip).Take(PageSize).ToListAsync();
}
SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name);
return Page();
}
/// <summary>Title/H1/meta from the active role+city so the page targets «شیفت [نقش] [شهر]».</summary>
private void SetSeo(string? role, string? city)
{
PageHeading =
role is not null && city is not null ? $"شیفت {role} در {city}"
: role is not null ? $"شیفت {role}"
: city is not null ? $"شیفت کادر درمان در {city}"
: "شیفت‌های خالی";
ViewData["Title"] = PageHeading;
ViewData["Description"] = role is not null || city is not null
? $"جدیدترین {PageHeading} در همکادر؛ مشاهده شیفت‌ها و تماس مستقیم با مراکز درمانی."
: "شیفت‌های خالی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستان‌ها و کلینیک‌های تهران — همکادر.";
if (role is not null || city is not null)
PageIntro = $"در این صفحه جدیدترین {PageHeading}، گردآوری‌شده از منابع معتبر، را می‌بینید. "
+ "روی هر شیفت بزنید تا تاریخ، ساعت، پرداخت و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. "
+ "برای موارد مرتبط، نقش یا شهر دیگری را از لینک‌های بالا انتخاب کنید.";
var crumbs = new List<Crumb> { new("خانه", "/"), new("شیفت‌ها", "/Shifts") };
if (role is not null) crumbs.Add(new(role, "/شیفت/" + SeoSlug.Of(role)));
if (city is not null) crumbs.Add(new(city, null));
Breadcrumbs = crumbs;
}
}
+27 -39
View File
@@ -13,16 +13,6 @@
comp = JalaliDate.Toman(pa) + " مدنظر";
else
comp = "توافقی";
string? telHref = null;
if (!string.IsNullOrWhiteSpace(t.Phone))
{
var digits = new string(t.Phone.Where(char.IsDigit).ToArray());
if (digits.Length >= 7) telHref = "tel:" + digits;
}
// Only Divar is surfaced as a fallback source (and only when no number was extracted).
// We never name other crawl sources (medjobs/telegram/…) publicly.
bool isDivar = !string.IsNullOrWhiteSpace(t.SourceUrl)
&& System.Uri.TryCreate(t.SourceUrl, UriKind.Absolute, out var su) && su.Host.Contains("divar");
}
<div class="page-head">
@@ -67,42 +57,40 @@
<aside>
<div class="card card-pad">
<h3 style="margin-top:0;">راه‌های ارتباطی</h3>
@{ var contacts = (t.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).OrderBy(c => c.SortOrder).ToList(); }
@if (contacts.Count > 0)
{
<div class="contact-reveal">
@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";
<div class="contact-row">
<span class="c-meta"><span class="c-type">@icon @label</span><span class="c-val" dir="ltr">@c.Value</span></span>
@if (href is not null)
{
<a class="btn @cls" href="@href" target="_blank" rel="nofollow noopener">باز کردن</a>
}
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
data-contact-type="talent" data-contact-id="@t.Id">📞 مشاهده راه‌های ارتباطی</button>
<p class="muted" style="font-size:12px; margin:10px 0 0;">با کلیک، شماره تماس و راه‌های ارتباطی نمایش داده می‌شود.</p>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:10px;"
data-like-type="talent" data-like-id="@t.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
</div>
}
</div>
}
else if (telHref is not null)
@if (t.Lat is not null && t.Lng is not null)
{
<a href="@telHref" class="btn btn-accent btn-block btn-lg" dir="ltr">📞 @t.Phone</a>
}
else if (isDivar)
var latS = t.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = t.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">موقعیت تقریبی</h3>
@if (!string.IsNullOrEmpty(Model.MapKey))
{
@* Divar hides the number behind a login-gated reveal — point to the original ad. *@
<p class="muted" style="margin-top:0;">شماره مستقیم استخراج نشد.</p>
<a href="@t.SourceUrl" target="_blank" rel="nofollow noopener" class="btn btn-accent btn-block btn-lg">مشاهده شماره در دیوار ↗</a>
<p class="muted" style="font-size:12px; margin:10px 0 0;">برای دریافت شماره به آگهی اصلی در دیوار مراجعه کن.</p>
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="true" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
}
else
{
<p class="muted">شماره تماس ثبت نشده است.</p>
}
<div style="background:var(--primary-soft); border-radius:10px; height:140px; display:grid; place-items:center; color:var(--primary-dark); text-align:center; padding:10px;">
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
</div>
}
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبیِ فعالیت (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
</div>
}
</aside>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.MapKey) && t.Lat is not null)
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@@ -9,9 +9,18 @@ namespace JobsMedical.Web.Pages.Talent;
public class DetailsModel : PageModel
{
private readonly AppDbContext _db;
public DetailsModel(AppDbContext db) => _db = db;
private readonly JobsMedical.Web.Services.Scraping.SettingsService _settings;
public DetailsModel(AppDbContext db, JobsMedical.Web.Services.Scraping.SettingsService settings)
{
_db = db;
_settings = settings;
}
public TalentListing? Item { get; private set; }
public string? MapKey { get; private set; }
public int LikeCount { get; private set; }
public bool IsLiked { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
@@ -22,6 +31,10 @@ public class DetailsModel : PageModel
.Include(t => t.Contacts)
.FirstOrDefaultAsync(t => t.Id == id);
if (Item is null) return NotFound();
MapKey = (await _settings.GetAsync()).NeshanMapKey;
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Talent && l.TargetId == id);
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Talent && l.TargetId == id);
return Page();
}
}
@@ -1,16 +1,15 @@
@page
@model JobsMedical.Web.Pages.Talent.IndexModel
@{
ViewData["Title"] = "آماده به کار — کادر درمان";
ViewData["Description"] = "فهرست کادر درمان آماده همکاری (پزشک، پرستار، ماما، تکنسین و…) در تهران و سایر شهرها — مرکز درمانی می‌تواند مستقیم تماس بگیرد.";
// Title/description are set in the page model (from the active role/city).
ViewData["q"] = Model.Q; // drives result highlighting in cards
}
<div class="page-head">
<div class="container">
<h1>آماده به کار</h1>
<h1>@Model.PageHeading</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) نیروی کادر درمان آماده‌ی همکاری —
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) نیروی کادر درمان آماده‌ی همکاری —
مراکز درمانی می‌توانند مستقیم تماس بگیرند.
</p>
</div>
@@ -84,6 +83,7 @@
<partial name="_TalentCard" model="t" />
}
</div>
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
}
</div>
</div>
@@ -17,12 +17,20 @@ public class IndexModel : PageModel
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
[BindProperty(SupportsGet = true)] public string? Q { get; set; } // deep search
[BindProperty(SupportsGet = true)] public int Page { get; set; } = 1;
private const int PageSize = 24;
public int TotalCount { get; private set; }
public int TotalPages { get; private set; }
public int CurrentPage { get; private set; }
public List<TalentListing> Results { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<District> Districts { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
/// <summary>Dynamic page heading/H1 + title, set from the active role/city for SEO.</summary>
public string PageHeading { get; private set; } = "کادر درمان آماده به کار";
public async Task OnGetAsync()
{
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
@@ -57,6 +65,20 @@ public class IndexModel : PageModel
EF.Functions.ILike(t.City.Name, like));
}
Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync();
TotalCount = await q.CountAsync();
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
CurrentPage = Math.Clamp(Page, 1, TotalPages);
Results = await q.OrderByDescending(t => t.CreatedAt)
.Skip((CurrentPage - 1) * PageSize).Take(PageSize).ToListAsync();
var role = Roles.FirstOrDefault(r => r.Id == RoleId)?.Name;
var city = Cities.FirstOrDefault(c => c.Id == CityId)?.Name;
PageHeading =
role is not null && city is not null ? $"{role} آماده به کار در {city}"
: role is not null ? $"{role} آماده به کار"
: city is not null ? $"کادر درمان آماده به کار در {city}"
: "کادر درمان آماده به کار";
ViewData["Title"] = PageHeading;
ViewData["Description"] = $"فهرست «آماده به کار» {(role ?? "کادر درمان")}{(city is not null ? " در " + city : "")} — همکادر؛ مشاهده و تماس مستقیم.";
}
}
+183 -20
View File
@@ -12,7 +12,16 @@ using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddRazorPages(options =>
{
// Pretty SEO landing routes that target «استخدام [نقش] [شهر]» / «شیفت …» searches, in addition
// to the query-string forms (/Jobs?RoleId=…&CityId=…). The page resolves the slugs to filters.
// roleSlug is OPTIONAL: a required slug made `asp-page="/Shifts/Index"` (with no slug) generate an
// empty href, breaking the nav «شیفت‌ها/استخدام» and the homepage «مشاهده همه» links. Optional →
// generation succeeds (e.g. «/شیفت») and the slug landing pages still work.
options.Conventions.AddPageRoute("/Jobs/Index", "استخدام/{roleSlug?}/{citySlug?}");
options.Conventions.AddPageRoute("/Shifts/Index", "شیفت/{roleSlug?}/{citySlug?}");
});
// Interest tracking + recommendation engine.
builder.Services.AddHttpContextAccessor();
@@ -54,6 +63,10 @@ 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.IranEstekhdamListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.MedboomListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.WebsiteListingSource>();
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
@@ -134,6 +147,28 @@ app.UseMiddleware<VisitorCookieMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
// HTML pages list live, fast-changing data (listings get archived between crawls). The CDN must NOT
// serve a stale homepage/detail copy — that's how an archived (410) listing can still appear as a
// card. Force revalidation on HTML; never let a private (logged-in) page be cached by the CDN, and
// Vary on the auth cookie so an anonymous copy is never handed to a logged-in visitor (or vice-versa).
// Static assets (css/js/fonts/images) are untouched — they keep MapStaticAssets' long cache headers.
app.Use(async (ctx, next) =>
{
ctx.Response.OnStarting(() =>
{
if (ctx.Response.ContentType is string ct && ct.StartsWith("text/html")
&& !ctx.Response.Headers.ContainsKey("Cache-Control"))
{
ctx.Response.Headers.CacheControl = ctx.User.Identity?.IsAuthenticated == true
? "private, no-store"
: "no-cache, must-revalidate";
ctx.Response.Headers.Vary = "Cookie";
}
return Task.CompletedTask;
});
await next();
});
app.MapStaticAssets();
app.MapRazorPages()
.WithStaticAssets();
@@ -282,15 +317,46 @@ app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext v
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
}).DisableAntiforgery();
// Toggle a logged-in user's «پسندیدن» of a listing; returns the new state + total like count.
app.MapPost("/like", async (HttpContext ctx, AppDbContext db, [FromForm] string type, [FromForm] int id) =>
{
int? uid = ctx.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier) is { } c
&& int.TryParse(c.Value, out var n) ? n : null;
if (uid is null) return Results.Unauthorized();
if (!Enum.TryParse<LikeTargetType>(type, true, out var tt)) return Results.BadRequest();
var existing = await db.Likes.FirstOrDefaultAsync(l => l.UserId == uid && l.TargetType == tt && l.TargetId == id);
bool liked;
if (existing is null) { db.Likes.Add(new Like { UserId = uid.Value, TargetType = tt, TargetId = id }); liked = true; }
else { db.Likes.Remove(existing); liked = false; }
await db.SaveChangesAsync();
var count = await db.Likes.CountAsync(l => l.TargetType == tt && l.TargetId == id);
return Results.Json(new { liked, count });
}).RequireAuthorization().DisableAntiforgery();
app.MapGet("/sw.js", () => Results.Content("""
const CACHE = 'hamkadr-v1';
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });
const CACHE = 'hamkadr-v2';
const OFFLINE = '<!doctype html><html lang="fa" dir="rtl"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>آفلاین</title><body style="font-family:Vazirmatn,system-ui,sans-serif;text-align:center;padding:48px 20px;color:#334155"><h2 style="margin:0 0 8px">اتصال اینترنت برقرار نیست</h2><p style="color:#64748b">صفحه باز نشد؛ اتصال خود را بررسی و دوباره تلاش کنید.</p><button onclick="location.reload()" style="margin-top:14px;padding:10px 24px;border:0;border-radius:10px;background:#0d9488;color:#fff;font:inherit;cursor:pointer">تلاش دوباره</button></body></html>';
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.add('/'))); });
self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); self.clients.claim(); });
self.addEventListener('fetch', e => {
const req = e.request;
if (req.method !== 'GET' || new URL(req.url).origin !== location.origin) return;
e.respondWith(fetch(req).then(res => { const copy = res.clone(); caches.open(CACHE).then(c => c.put(req, copy)); return res; })
.catch(() => caches.match(req).then(m => m || caches.match('/'))));
// Page navigations ALWAYS go to the network so listings are fresh (never a stale/archived card).
// We only fall back when the device is truly offline — to a cached copy of THAT exact page, or an
// offline notice. We never substitute the homepage for a detail page (that was the "clicking a job
// just shows the homepage" bug) and we never cache HTML, so a 410 can't poison the cache.
if (req.mode === 'navigate') {
e.respondWith(fetch(req).catch(() => caches.match(req).then(m =>
m || new Response(OFFLINE, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }))));
return;
}
// Static assets (css/js/fonts/images) are fingerprinted — cache-first is safe and fast.
e.respondWith(caches.match(req).then(hit => hit || fetch(req).then(res => {
if (res.ok && res.type === 'basic') { const copy = res.clone(); caches.open(CACHE).then(c => c.put(req, copy)); }
return res;
})));
});
self.addEventListener('push', e => {
let d = { title: 'همکادر', body: 'فرصت جدید برای شما', url: '/' };
@@ -355,10 +421,105 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
foreach (var fId in await db.Facilities.Select(f => f.Id).ToListAsync())
Url($"{b}/Facilities/Details/{fId}", null, "weekly");
// SEO landing pages: role-only and role×city combos that actually have live listings, so
// Google indexes pages targeting «استخدام [نقش] [شهر]» / «شیفت …». URL-encode each segment.
var roleNames = await db.Roles.ToDictionaryAsync(r => r.Id, r => r.Name);
var cityNames = await db.Cities.ToDictionaryAsync(c => c.Id, c => c.Name);
string Seg(string s) => Uri.EscapeDataString(s);
void Landing(string kind, int roleId, int? cityId)
{
if (!roleNames.TryGetValue(roleId, out var role)) return;
var loc = $"{b}/{Seg(kind)}/{Seg(SeoSlug.Of(role))}";
if (cityId is int c && cityNames.TryGetValue(c, out var city)) loc += $"/{Seg(SeoSlug.Of(city))}";
Url(loc, null, "daily");
}
var jobCombos = await db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
.Select(j => new { j.RoleId, j.Facility.CityId }).Distinct().ToListAsync();
foreach (var rid in jobCombos.Select(x => x.RoleId).Distinct()) Landing("استخدام", rid, null);
foreach (var x in jobCombos) Landing("استخدام", x.RoleId, x.CityId);
var shiftCombos = await db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.Select(s => new { s.RoleId, s.Facility.CityId }).Distinct().ToListAsync();
foreach (var rid in shiftCombos.Select(x => x.RoleId).Distinct()) Landing("شیفت", rid, null);
foreach (var x in shiftCombos) Landing("شیفت", x.RoleId, x.CityId);
sb.Append("</urlset>");
return Results.Content(sb.ToString(), "application/xml");
});
// ---- Contact reveal (modal): a listing's contact channels as JSON, fetched lazily on click so
// personal numbers never sit in list-page HTML. Logs the Apply interest signal for shift/job. ----
app.MapGet("/contact", async (string? type, int id, AppDbContext db, InterestService interest) =>
{
object Item(ContactType ct, string value) => new
{
icon = ContactInfo.Icon(ct), label = ContactInfo.Label(ct), value, href = ContactInfo.Href(ct, value),
};
string? title = null, fallbackUrl = null, fallbackLabel = null;
var items = new List<object>();
switch ((type ?? "").ToLowerInvariant())
{
case "shift":
{
var s = await db.Shifts.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (s is null) return Results.NotFound();
title = s.Role?.Name ?? "تماس";
items.AddRange(s.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
// Only fall back to the facility's number for a REAL named employer — the shared
// «نامشخص» placeholder's phone is NOT this ad's number (it leaked one number onto many posts).
if (SeoJsonLd.HasRealEmployer(s.Facility))
{
if (items.Count == 0 && !string.IsNullOrWhiteSpace(s.Facility!.Phone)) items.Add(Item(ContactType.Phone, s.Facility.Phone!));
if (!string.IsNullOrWhiteSpace(s.Facility!.BaleId)) items.Add(Item(ContactType.Bale, s.Facility.BaleId!));
}
if (items.Count == 0 && !string.IsNullOrWhiteSpace(s.SourceUrl)
&& Uri.TryCreate(s.SourceUrl, UriKind.Absolute, out var ss) && ss.AbsolutePath.Trim('/').Length > 0)
{ fallbackUrl = s.SourceUrl; fallbackLabel = ss.Host.Contains("divar") ? "مشاهده شماره در دیوار ↗" : "مشاهده آگهی در منبع ↗"; }
await interest.LogAsync(InterestEventType.Apply, id);
break;
}
case "job":
{
var j = await db.JobOpenings.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (j is null) return Results.NotFound();
title = j.Title;
items.AddRange(j.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
if (SeoJsonLd.HasRealEmployer(j.Facility))
{
if (items.Count == 0 && !string.IsNullOrWhiteSpace(j.Facility!.Phone)) items.Add(Item(ContactType.Phone, j.Facility.Phone!));
if (!string.IsNullOrWhiteSpace(j.Facility!.BaleId)) items.Add(Item(ContactType.Bale, j.Facility.BaleId!));
}
if (items.Count == 0 && !string.IsNullOrWhiteSpace(j.SourceUrl)
&& Uri.TryCreate(j.SourceUrl, UriKind.Absolute, out var js) && js.AbsolutePath.Trim('/').Length > 0)
{ fallbackUrl = j.SourceUrl; fallbackLabel = js.Host.Contains("divar") ? "مشاهده شماره در دیوار ↗" : "مشاهده آگهی در منبع ↗"; }
await interest.LogJobAsync(InterestEventType.Apply, id);
break;
}
case "talent":
{
var t = await db.TalentListings.Include(x => x.Role).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (t is null) return Results.NotFound();
title = string.IsNullOrWhiteSpace(t.PersonName) ? (t.Role?.Name ?? "آماده به کار") : t.PersonName;
items.AddRange(t.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.Phone)) items.Add(Item(ContactType.Mobile, t.Phone!));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.SourceUrl)
&& Uri.TryCreate(t.SourceUrl, UriKind.Absolute, out var su) && su.AbsolutePath.Trim('/').Length > 0)
{ fallbackUrl = t.SourceUrl; fallbackLabel = su.Host.Contains("divar") ? "مشاهده شماره در دیوار ↗" : "مشاهده آگهی در منبع ↗"; }
break;
}
default: return Results.BadRequest();
}
return Results.Json(new { title, contacts = items, fallbackUrl, fallbackLabel });
});
// ---- Instant search suggestions (typeahead dropdown) ----
app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
{
@@ -386,30 +547,32 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
return fallback;
}
var shiftRows = await db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
// Define each filtered query once, then reuse it for BOTH the Take(5) preview and the total count.
var shiftQ = db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like)
|| EF.Functions.ILike(s.SpecialtyRequired, like) || EF.Functions.ILike(s.Description ?? "", like)))
.OrderByDescending(s => s.CreatedAt).Take(5)
|| EF.Functions.ILike(s.SpecialtyRequired, like) || EF.Functions.ILike(s.Description ?? "", like)));
var jobQ = db.JobOpenings.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|| EF.Functions.ILike(j.Role.Name, like) || EF.Functions.ILike(j.Description ?? "", like)));
var talentQ = db.TalentListings.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like)
|| EF.Functions.ILike(t.PersonName ?? "", like) || EF.Functions.ILike(t.Description ?? "", like)));
var shiftRows = await shiftQ.OrderByDescending(s => s.CreatedAt).Take(5)
.Select(s => new { s.Id, Role = s.Role.Name, Fac = s.Facility.Name, City = s.Facility.City.Name, s.Description }).ToListAsync();
var shifts = shiftRows.Select(s => new SuggestItem("شیفت", s.Role + " — " + s.Fac, "/Shifts/Details/" + s.Id, Snip(s.Description, term, s.City))).ToList();
var jobRows = await db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|| EF.Functions.ILike(j.Role.Name, like) || EF.Functions.ILike(j.Description ?? "", like)))
.OrderByDescending(j => j.CreatedAt).Take(5)
var jobRows = await jobQ.OrderByDescending(j => j.CreatedAt).Take(5)
.Select(j => new { j.Id, j.Title, Fac = j.Facility.Name, City = j.Facility.City.Name, j.Description }).ToListAsync();
var jobs = jobRows.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, Snip(j.Description, term, j.Fac + " · " + j.City))).ToList();
var talentRows = await db.TalentListings
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like)
|| EF.Functions.ILike(t.PersonName ?? "", like) || EF.Functions.ILike(t.Description ?? "", like)))
.OrderByDescending(t => t.CreatedAt).Take(5)
var talentRows = await talentQ.OrderByDescending(t => t.CreatedAt).Take(5)
.Select(t => new { t.Id, t.PersonName, Role = t.Role.Name, City = t.City.Name, t.Tags, t.Description }).ToListAsync();
var talent = talentRows.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role) + " — " + t.City, "/Talent/Details/" + t.Id, Snip(t.Description ?? t.Tags, term, t.Tags))).ToList();
// Total matches across all three types (drives the result count shown in the dropdown).
var total = await shiftQ.CountAsync() + await jobQ.CountAsync() + await talentQ.CountAsync();
// round-robin merge so all three types appear, capped at 5
var merged = new List<SuggestItem>();
for (var i = 0; i < 5 && merged.Count < 5; i++)
@@ -418,7 +581,7 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
if (merged.Count < 5 && i < jobs.Count) merged.Add(jobs[i]);
if (merged.Count < 5 && i < talent.Count) merged.Add(talent[i]);
}
return Results.Json(merged.Take(5));
return Results.Json(new { items = merged.Take(5), total });
});
app.Run();
@@ -25,6 +25,36 @@ public static class JalaliDate
private static readonly char[] PersianDigits = { '۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹' };
/// <summary>Convert a UTC timestamp (we store everything as <c>DateTime.UtcNow</c>) to Tehran
/// wall-clock. Iran is a fixed UTC+3:30 (DST abolished in 2022), so a flat offset is exact and
/// needs no timezone database.</summary>
public static DateTime ToTehran(DateTime utc) => utc.AddMinutes(210);
/// <summary>Relative "added X ago" in Persian from a UTC timestamp: «همین حالا»، «۵ دقیقه پیش»،
/// «۲ ساعت پیش»، «۳ روز پیش»، «۲ هفته پیش»، «۴ ماه پیش»، «۱ سال پیش».</summary>
public static string TimeAgo(DateTime utc)
{
var span = DateTime.UtcNow - utc;
if (span < TimeSpan.Zero) span = TimeSpan.Zero;
var mins = (int)span.TotalMinutes;
if (mins < 1) return "همین حالا";
if (mins < 60) return ToPersianDigits(mins.ToString()) + " دقیقه پیش";
var hours = (int)span.TotalHours;
if (hours < 24) return ToPersianDigits(hours.ToString()) + " ساعت پیش";
var days = (int)span.TotalDays;
if (days < 7) return ToPersianDigits(days.ToString()) + " روز پیش";
if (days < 30) return ToPersianDigits((days / 7).ToString()) + " هفته پیش";
if (days < 365) return ToPersianDigits((days / 30).ToString()) + " ماه پیش";
return ToPersianDigits((days / 365).ToString()) + " سال پیش";
}
/// <summary>Jalali date + Tehran time, e.g. «۳۰ خرداد ۱۴۰۵ ۱۶:۲۱» — for UTC-stored timestamps.</summary>
public static string DateTimeLabel(DateTime utc)
{
var t = ToTehran(utc);
return ToLongDate(DateOnly.FromDateTime(t)) + " " + ToPersianDigits(t.ToString("HH:mm"));
}
/// <summary>Convert Latin digits in a string to Persian digits.</summary>
public static string ToPersianDigits(string input)
{
+36 -8
View File
@@ -69,8 +69,11 @@ public class HeuristicListingParser : IListingParser
}
else
{
p.Kind = (jobSignals && !shiftSignals) ? ListingKind.Job : ListingKind.Shift;
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)");
// A dated SHIFT requires an explicit shift signal («شیفت/آنکال/کشیک/نوبت»). Otherwise the ad
// is an ongoing hiring post → Job. (Defaulting to Shift forced a fabricated date/time onto
// generic ads like «پرستار درمانگاه», which the source never stated.)
p.Kind = shiftSignals ? ListingKind.Shift : ListingKind.Job;
p.Notes.Add(p.Kind == ListingKind.Shift ? "نوع: شیفت (تشخیص خودکار)" : "نوع: استخدام (تشخیص خودکار)");
}
// --- Roles (an ad can name several at once: «پرستار سالمند و کودک و همراه بیمار») ---
@@ -137,13 +140,12 @@ public class HeuristicListingParser : IListingParser
{ p.Notes.Add("پرداخت درصدی/سهمی (درصد نامشخص)"); }
// --- Fixed pay (strip phone numbers first so they're never read as money) ---
if (ContainsAny(text, "توافقی", "توافق")) { p.PayNegotiable = true; p.Notes.Add("حقوق: توافقی"); }
else
{
// A STATED amount wins over «توافقی»: ads often say a number AND «… بقیه توافقی»; showing the
// figure is far more useful than «توافقی». Fall back to negotiable only when no amount is found.
var amount = ExtractAmount(StripPhones(text));
if (amount is not null) { p.PayAmount = amount; p.Notes.Add($"حقوق تخمینی: {amount:#,0} تومان"); }
else if (ContainsAny(text, "توافقی", "توافق")) { p.PayNegotiable = true; p.Notes.Add("حقوق: توافقی"); }
else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد");
}
// --- Talent extras (only meaningful for «آماده به کار») ---
if (p.Kind == ListingKind.Talent)
@@ -218,12 +220,17 @@ public class HeuristicListingParser : IListingParser
{
if (NameStops.Contains(w)) break;
if (Regex.IsMatch(w, @"\d")) break; // numbers/phones aren't names
if (!w.Any(char.IsLetter)) break; // emoji / punctuation («📍») isn't a name
if (w.Length == 1) break; // stray letters
picked.Add(w);
if (picked.Count >= 3) break;
}
if (picked.Count == 0) continue; // bare keyword (e.g. just «بیمارستان») isn't useful
return (kw + " " + string.Join(" ", picked)).Trim();
var candidate = (kw + " " + string.Join(" ", picked)).Trim();
// Reject names that are only filler/verb/source noise («بیمارستان هستم», «... از مدجابز») —
// a real name couldn't be extracted, so fall back to the shared placeholder downstream.
if (Scraping.FacilityMatcher.IsJunkName(candidate)) continue;
return candidate;
}
return null;
}
@@ -231,6 +238,16 @@ public class HeuristicListingParser : IListingParser
// Titles that introduce a person's name in «آماده به کار» posts.
private static readonly string[] PersonTitles = { "دکتر", "خانم دکتر", "آقای دکتر", "مهندس", "سرکار خانم", "جناب آقای", "خانم", "آقای" };
// Words that are NOT a person's name — verbs/fillers/availability/role words the extractor was
// grabbing after a title («خانم هستم»، «دکتر ام»، «دکتر داروساز آماده»). Stop collecting at one.
private static readonly string[] NameNoise =
{
"هستم", "هستیم", "هستش", "ام", "بودم", "میباشم", "میباشد", "باشم", "آماده", "آماده‌ام",
"جویای", "بکار", "به‌کار", "کار", "همکاری", "نیازمند", "استخدام", "جذب", "عزیز", "محترم",
"گرامی", "خانم", "آقا", "اقا", "دکتر", "پزشک", "پرستار", "بهیار", "ماما", "دندانپزشک",
"داروساز", "تکنسین", "کارشناس", "متخصص", "عمومی", "مراقب", "کمک",
};
/// <summary>Best-effort person name: a title (دکتر/خانم/…) plus up to two following words.</summary>
private static string? ExtractPersonName(string text)
{
@@ -246,6 +263,7 @@ public class HeuristicListingParser : IListingParser
foreach (var w in words)
{
if (NameStops.Contains(w)) break;
if (NameNoise.Any(n => Normalize(n) == Normalize(w))) break; // «خانم هستم»/«دکتر ام»…
if (Regex.IsMatch(w, @"[\d]")) break;
if (w.Length == 1) break;
picked.Add(w);
@@ -275,6 +293,14 @@ public class HeuristicListingParser : IListingParser
bool hasToman = latin.Contains("تومان") || latin.Contains("تومن");
bool hasRial = (latin.Contains("ریال") || latin.Contains("ريال")) && !hasToman;
// Iranian salary shorthand: a 13 digit number means MILLIONS of toman — «۱۵ تومان»،
// «۴۰ تا ۵۰ تومان»، «۲۰ میلیون»، «۲۰م». Take the LOWER bound of a range. The lookarounds keep
// this from ever matching part of a long literal-toman number (the digits must end at the unit).
var collo = Regex.Match(latin,
@"(?<!\d)(\d{1,3})(?:\s*تا\s*(\d{1,3}))?\s*(?:میلیون|م(?![ا-یA-Za-z])|تومان|تومن)(?!\s*\d)");
if (collo.Success && int.TryParse(collo.Groups[1].Value, out var lo) && lo is > 0 and <= 500)
return (long)lo * 1_000_000;
// e.g. "۲ میلیون" / "2.5 میلیون [ریال]"
var million = Regex.Match(latin, @"(\d+(?:[.,]\d+)?)\s*میلیون\s*(ریال|ريال)?");
if (million.Success && double.TryParse(million.Groups[1].Value.Replace(",", "."),
@@ -356,7 +382,9 @@ public class HeuristicListingParser : IListingParser
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)"))
// Landline area codes start 0[1-8] (021 Tehran, 026 Karaj, …) — never 09, which is a MOBILE.
// The old 0\d{2,3} matched 09xx numbers and mislabeled mobiles as «تلفن ثابت».
foreach (Match m in Regex.Matches(latin, @"(?<!\d)0[1-8]\d{1,2}[\s-]?\d{7,8}(?!\d)"))
Add(ContactType.Phone, Regex.Replace(m.Value, @"\D", ""));
return list.Take(8).ToList();
@@ -4,14 +4,19 @@ using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Services;
public record Recommendation(Shift Shift, double Score, List<string> Reasons);
/// <summary>A recommended opportunity — either an open <see cref="Shift"/> or an open
/// <see cref="JobOpening"/>. Exactly one of the two is set.</summary>
public record Recommendation(double Score, List<string> Reasons, Shift? Shift = null, JobOpening? Job = null)
{
public bool IsJob => Job is not null;
}
/// <summary>
/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine.
/// It scores open shifts against a visitor's explicit preferences AND their recent behavior
/// (which roles/facilities/shift-types they keep engaging with), and returns the top matches
/// each with a human-readable reason. No ML/AI infra required — works from the first visit,
/// and every result is explainable. Behavioral data logged now feeds the ML stages later.
/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine. It scores open
/// opportunities — BOTH shifts AND job openings — against a visitor's explicit preferences and their
/// recent behavior, and returns the top matches each with a human-readable reason. Covering jobs (not
/// just shifts) matters because most roles — especially doctors — exist as استخدام, not dated shifts;
/// a shift-only feed would only ever recommend the handful of (mostly nurse) shifts.
/// </summary>
public class RecommendationService
{
@@ -24,7 +29,6 @@ public class RecommendationService
_interest = interest;
}
// Tunable weights — the whole point of a pattern engine is that these are legible.
private const double WRolePref = 40, WRoleBehavior = 15;
private const double WCityPref = 20;
private const double WShiftTypePref = 15, WShiftTypeBehavior = 8;
@@ -36,64 +40,69 @@ public class RecommendationService
public async Task<List<Recommendation>> GetForVisitorAsync(int take = 6)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var jobCutoff = Scraping.ListingPolicy.JobCutoffUtc;
var prefs = await _interest.GetPreferencesAsync();
var events = await _interest.RecentEventsAsync(150);
var candidates = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.ToListAsync();
var shifts = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City).Include(s => s.Role)
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today).ToListAsync();
var jobs = await _db.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role)
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff).ToListAsync();
// Cold start: no preferences and no behavior → just show the freshest opportunities.
// Cold start: freshest of both, interleaved (jobs lead — that's where the volume/roles are).
if (prefs is null && events.Count == 0)
{
return candidates
.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
.Take(take)
.Select(s => new Recommendation(s, 0, new() { "جدیدترین فرصت‌ها" }))
.ToList();
var cj = jobs.OrderByDescending(j => j.CreatedAt)
.Select(j => new Recommendation(0, new List<string> { "جدیدترین فرصت‌ها" }, Job: j));
var cs = shifts.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
.Select(s => new Recommendation(0, new List<string> { "جدیدترین فرصت‌ها" }, Shift: s));
return Interleave(cj, cs).Take(take).ToList();
}
// Derive behavioral affinities from the event log (shift events only — jobs are separate).
var shiftEvents = events.Where(e => e.ShiftId is not null).ToList();
var eventShiftIds = shiftEvents.Select(e => e.ShiftId!.Value).Distinct().ToList();
var eventShifts = candidates.Where(s => eventShiftIds.Contains(s.Id))
.Concat(await _db.Shifts.Include(s => s.Role)
.Where(s => eventShiftIds.Contains(s.Id)).ToListAsync())
.DistinctBy(s => s.Id)
.ToDictionary(s => s.Id);
// Behavioral affinities — derived from BOTH shift and job events (role/facility span both;
// shift-type is shift-only). Look up each engaged item's role/facility/type once.
var positive = new[] { InterestEventType.View, InterestEventType.Click, InterestEventType.Save, InterestEventType.Apply };
var negative = new[] { InterestEventType.Dismiss, InterestEventType.HideFacility };
var positive = new[] { InterestEventType.View, InterestEventType.Click,
InterestEventType.Save, InterestEventType.Apply };
var sIds = events.Where(e => e.ShiftId is not null).Select(e => e.ShiftId!.Value).Distinct().ToList();
var jIds = events.Where(e => e.JobOpeningId is not null).Select(e => e.JobOpeningId!.Value).Distinct().ToList();
var sMeta = (await _db.Shifts.Where(s => sIds.Contains(s.Id))
.Select(s => new { s.Id, s.RoleId, s.FacilityId, s.ShiftType }).ToListAsync()).ToDictionary(x => x.Id);
var jMeta = (await _db.JobOpenings.Where(j => jIds.Contains(j.Id))
.Select(j => new { j.Id, j.RoleId, j.FacilityId }).ToListAsync()).ToDictionary(x => x.Id);
var roleAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.RoleId);
var shiftTypeAffinity = TopBy(shiftEvents, positive, eventShifts, s => (int)s.ShiftType);
var facilityAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.FacilityId);
var roleCount = new Dictionary<int, int>();
var facCount = new Dictionary<int, int>();
var stCount = new Dictionary<int, int>();
var dismissedFacilities = new HashSet<int>();
foreach (var e in events)
{
int roleId, facId; int? st = null;
if (e.ShiftId is int si && sMeta.TryGetValue(si, out var sm)) { roleId = sm.RoleId; facId = sm.FacilityId; st = (int)sm.ShiftType; }
else if (e.JobOpeningId is int ji && jMeta.TryGetValue(ji, out var jm)) { roleId = jm.RoleId; facId = jm.FacilityId; }
else continue;
var dismissedFacilities = shiftEvents
.Where(e => e.EventType is InterestEventType.Dismiss or InterestEventType.HideFacility)
.Select(e => eventShifts.TryGetValue(e.ShiftId!.Value, out var s) ? s.FacilityId : 0)
.Where(id => id != 0).ToHashSet();
if (positive.Contains(e.EventType))
{
Bump(roleCount, roleId); Bump(facCount, facId);
if (st is int t) Bump(stCount, t);
}
else if (negative.Contains(e.EventType)) dismissedFacilities.Add(facId);
}
var roleAffinity = Top3(roleCount);
var facilityAffinity = Top3(facCount);
var shiftTypeAffinity = Top3(stCount);
var results = new List<Recommendation>();
foreach (var s in candidates)
foreach (var s in shifts)
{
// Skip listings whose gender requirement conflicts with the person's gender.
if (prefs?.Gender is Gender pg && pg != Gender.Any
&& s.GenderRequirement != Gender.Any && s.GenderRequirement != pg)
continue;
double score = 0;
var reasons = new List<string>();
if (prefs?.RoleId is int pr && pr == s.RoleId)
{ score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({s.Role.Name})"); }
else if (roleAffinity.Contains(s.RoleId))
{ score += WRoleBehavior; reasons.Add($"چون به فرصت‌های «{s.Role.Name}» علاقه نشان دادی"); }
if (prefs?.CityId is int pc && pc == s.Facility.CityId)
{ score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({s.Facility.City.Name})"); }
if (GenderConflicts(prefs, s.GenderRequirement)) continue;
double score = 0; var reasons = new List<string>();
ScoreCommon(ref score, reasons, prefs, s.RoleId, s.Role?.Name, s.Facility.CityId, s.Facility.City?.Name,
s.FacilityId, s.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities);
if (prefs?.PreferredShiftType is ShiftType pst && pst == s.ShiftType)
{ score += WShiftTypePref; reasons.Add($"نوع شیفت دلخواه شما ({ShiftTypeLabel(s.ShiftType)})"); }
@@ -103,41 +112,71 @@ public class RecommendationService
if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min)
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
if (facilityAffinity.Contains(s.FacilityId))
{ score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({s.Facility.Name})"); }
if (dismissedFacilities.Contains(s.FacilityId))
score -= PenaltyDismissedFacility;
// Sooner shifts and freshly posted ones get a small nudge.
var daysOut = s.Date.DayNumber - today.DayNumber;
if (daysOut <= 3) score += WSoon;
if ((DateTime.UtcNow - s.CreatedAt).TotalDays <= 2) score += WFreshness;
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
results.Add(new Recommendation(s, score, reasons));
results.Add(new Recommendation(score, reasons, Shift: s));
}
foreach (var j in jobs)
{
if (GenderConflicts(prefs, j.GenderRequirement)) continue;
double score = 0; var reasons = new List<string>();
ScoreCommon(ref score, reasons, prefs, j.RoleId, j.Role?.Name, j.Facility.CityId, j.Facility.City?.Name,
j.FacilityId, j.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities);
if (prefs?.MinPay is long min && j.SalaryMin is long pay && pay >= min)
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
if ((DateTime.UtcNow - j.CreatedAt).TotalDays <= 2) score += WFreshness;
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
results.Add(new Recommendation(score, reasons, Job: j));
}
return results
.Where(r => r.Score > 0)
.OrderByDescending(r => r.Score).ThenBy(r => r.Shift.Date)
.OrderByDescending(r => r.Score)
.Take(take)
.ToList();
}
/// <summary>Keys the visitor engaged with most (positive events), top 3.</summary>
private static HashSet<int> TopBy(
List<InterestEvent> events, InterestEventType[] positive,
Dictionary<int, Shift> shiftById, Func<Shift, int> key)
/// <summary>Role + city + facility scoring shared by shifts and jobs.</summary>
private static void ScoreCommon(ref double score, List<string> reasons, UserPreferences? prefs,
int roleId, string? roleName, int cityId, string? cityName, int facilityId, string? facilityName,
HashSet<int> roleAffinity, HashSet<int> facilityAffinity, HashSet<int> dismissedFacilities)
{
return events
.Where(e => e.ShiftId is not null && positive.Contains(e.EventType)
&& shiftById.ContainsKey(e.ShiftId.Value))
.GroupBy(e => key(shiftById[e.ShiftId!.Value]))
.OrderByDescending(g => g.Count())
.Take(3)
.Select(g => g.Key)
.ToHashSet();
if (prefs?.RoleId is int pr && pr == roleId)
{ score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({roleName})"); }
else if (roleAffinity.Contains(roleId))
{ score += WRoleBehavior; reasons.Add($"چون به فرصت‌های «{roleName}» علاقه نشان دادی"); }
if (prefs?.CityId is int pc && pc == cityId)
{ score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({cityName})"); }
if (facilityAffinity.Contains(facilityId))
{ score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({facilityName})"); }
if (dismissedFacilities.Contains(facilityId)) score -= PenaltyDismissedFacility;
}
private static bool GenderConflicts(UserPreferences? prefs, Gender req)
=> prefs?.Gender is Gender pg && pg != Gender.Any && req != Gender.Any && req != pg;
private static void Bump(Dictionary<int, int> d, int k) => d[k] = d.GetValueOrDefault(k) + 1;
private static HashSet<int> Top3(Dictionary<int, int> d) => d.OrderByDescending(k => k.Value).Take(3).Select(k => k.Key).ToHashSet();
private static IEnumerable<Recommendation> Interleave(IEnumerable<Recommendation> a, IEnumerable<Recommendation> b)
{
using var ea = a.GetEnumerator();
using var eb = b.GetEnumerator();
bool ha = ea.MoveNext(), hb = eb.MoveNext();
while (ha || hb)
{
if (ha) { yield return ea.Current; ha = ea.MoveNext(); }
if (hb) { yield return eb.Current; hb = eb.MoveNext(); }
}
}
private static string ShiftTypeLabel(ShiftType t) => t switch
@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
@@ -8,7 +9,13 @@ namespace JobsMedical.Web.Services.Scraping;
public record AiStructured(
string? Kind, string? Role, string? City, string? District, string? ShiftType,
string? EmploymentType, long? PayAmount, int? SharePercent, string? Title, string? FacilityName,
string? Phone = null, string? PersonName = null, int? YearsExperience = null, bool? IsLicensed = null);
string? Phone = null, string? PersonName = null, int? YearsExperience = null, bool? IsLicensed = null,
// Dynamic taxonomy: the model may name a role/category outside the seeded set (ingestion
// resolves-or-creates it). Tags carry the post's skills/requirements (ICU, MMT, پروانه‌دار…).
string? Category = null, IReadOnlyList<string>? Tags = null,
// Approximate coords the model infers from a named neighborhood — used ONLY as a geocoding
// fallback (validated against Tehran's bbox), when the source ad and the local table have none.
double? Lat = null, double? Lng = null);
/// <summary>An AI verdict on a raw listing.</summary>
public record AiAuditResult(string Decision, int Confidence, string? Reason, AiStructured? Data)
@@ -21,6 +28,11 @@ public interface IAiAuditor
{
/// <summary>Audit a raw post. Returns null when AI is off or the call fails (fail safe → manual).</summary>
Task<AiAuditResult?> AuditAsync(string rawText, AppSetting settings, CancellationToken ct = default);
/// <summary>Diagnostic: runs a real call and returns a detailed, human-readable Persian
/// success/error string (HTTP status, response snippet, exception detail) so the admin can
/// see exactly why the AI service won't connect. Never throws.</summary>
Task<string> TestAsync(string rawText, AppSetting settings, CancellationToken ct = default);
}
/// <summary>
@@ -39,8 +51,11 @@ public class OpenAiCompatibleAuditor : IAiAuditor
confidence: عدد ۰ تا ۱۰۰
reason: توضیح کوتاه فارسی
kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است)
role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه)
role: «حرفهٔ پایه»، نه با توصیفگر. گروه سنی/بخش/سطح را در tags بگذار («پرستار کودک»role «پرستار»). فقط برای حرفهٔ پایهٔ متفاوت که در فهرست نیست نقش جدید بساز.
category: فقط یکی از این پنج: پزشک | پرستار | ماما | تکنسین | دندانپزشک. اگر نگنجید «سایر». هرگز گروه جدید نساز.
tags: آرایهٔ کلیدواژههای بالینی (مهارت/بخش/گواهی/گروه سنی/سطح) مثل "ICU"،"دیالیز"،"کودک"،"پروانه‌دار". بدون مبلغ/پرداخت/تماس/شهر یا جملهٔ ناقص. اگر نبود [].
city, district: نام شهر و محله/منطقه در صورت ذکر
lat, lng: اگر محله/منطقه را در تهران تشخیص دادی، مختصاتِ تقریبیِ مرکزِ همان محله را بهصورت عدد اعشاری برگردان (lat حدود ۳۵.x، lng حدود ۵۱.x)؛ در غیر این صورت null. حدس نزن.
shiftType: day|evening|night|oncall (فقط برای shift)
employmentType: fulltime|parttime|contract|plan
payAmount: عدد تومان یا null ، sharePercent: عدد ۰ تا ۱۰۰ یا null (مثل «۵۰٪ تسویه»)
@@ -63,6 +78,79 @@ public class OpenAiCompatibleAuditor : IAiAuditor
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint)) return null;
try
{
var (status, body) = await SendAsync(rawText, s, ct);
if (!IsSuccess(status))
{
// Log the actual status + response body — the provider usually explains the failure
// here (bad key, unknown model, quota), so don't throw it away with EnsureSuccessStatusCode.
_log.LogWarning("AI endpoint {Endpoint} returned HTTP {Status}: {Body}",
s.AiEndpoint, (int)status, Truncate(body, 600));
return null;
}
var content = ExtractContent(body);
if (string.IsNullOrWhiteSpace(content))
{
_log.LogWarning("AI endpoint {Endpoint} returned no message content (response shape not OpenAI-compatible?). Body: {Body}",
s.AiEndpoint, Truncate(body, 600));
return null;
}
return ParseVerdict(content);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
_log.LogWarning("AI call to {Endpoint} timed out (proxy={Proxy}).", s.AiEndpoint, s.AiUseProxy);
return null;
}
catch (Exception ex)
{
_log.LogWarning(ex, "AI audit failed for endpoint {Endpoint} (proxy={Proxy}) — falling back to rule-based decision.",
s.AiEndpoint, s.AiUseProxy);
return null;
}
}
public async Task<string> TestAsync(string rawText, AppSetting s, CancellationToken ct = default)
{
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint))
return "هوش مصنوعی غیرفعال است یا آدرس سرویس خالی است. ابتدا آن را فعال و ذخیره کن.";
try
{
var (status, body) = await SendAsync(rawText, s, ct);
if (!IsSuccess(status))
return $"❌ سرویس کد HTTP {(int)status} ({status}) برگرداند.\nآدرس: {s.AiEndpoint}\nپروکسی: {(s.AiUseProxy ? "روشن" : "خاموش")}\nپاسخ سرویس:\n{Truncate(body, 800)}";
var content = ExtractContent(body);
if (string.IsNullOrWhiteSpace(content))
return $"❌ پاسخ دریافت شد ولی محتوای پیام خالی بود — ساختار پاسخ با OpenAI سازگار نیست؟\nپاسخ خام:\n{Truncate(body, 800)}";
var v = ParseVerdict(content);
return v is null
? $"⚠️ مدل پاسخ داد ولی JSON قابل‌خواندن نبود. (response_format=json_object را پشتیبانی نمی‌کند؟)\nمحتوا:\n{Truncate(content, 800)}"
: $"✅ اتصال موفق — تصمیم: {v.Decision} | اطمینان: {v.Confidence}٪ | نقش: {v.Data?.Role} | شهر: {v.Data?.City} | شیفت: {v.Data?.ShiftType}";
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return "❌ مهلت پاسخ‌گویی تمام شد (timeout ۱۰۰ ثانیه). اگر تیک «از طریق پروکسی» روشن است، صحت آدرس پروکسی را بررسی کن.";
}
catch (HttpRequestException ex)
{
// DNS failure, connection refused, TLS error, proxy unreachable — the common Iran cases.
var inner = ex.InnerException is { } i ? $" — {i.Message}" : "";
return $"❌ خطای شبکه/پروکسی: {ex.Message}{inner}\nآدرس: {s.AiEndpoint}\nپروکسی: {(s.AiUseProxy ? "روشن" : "خاموش")}";
}
catch (Exception ex)
{
return $"❌ خطا: {ex.GetType().Name}: {ex.Message}";
}
}
/// <summary>POSTs the chat-completions request and returns the raw status + body. Shared by
/// AuditAsync (fail-safe) and TestAsync (diagnostic) so both exercise the identical call path.</summary>
private async Task<(HttpStatusCode status, string body)> SendAsync(string rawText, AppSetting s, CancellationToken ct)
{
var payload = new
{
@@ -71,9 +159,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor
response_format = new { type = "json_object" },
messages = new object[]
{
// Admin prompt + an authoritative output schema, so classification/tags stay
// correct even if the stored prompt predates the talent/phone fields.
new { role = "system", content = s.AiSystemPrompt + "\n\n" + OutputSchema },
// Hardcoded, code-owned prompt (NOT the stored AiSystemPrompt) + the authoritative
// output schema, so classification/tags can never be broken by an admin edit.
new { role = "system", content = AppSetting.DefaultPrompt + "\n\n" + OutputSchema },
new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." },
},
};
@@ -87,22 +175,31 @@ public class OpenAiCompatibleAuditor : IAiAuditor
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.AiApiKey);
using var resp = await client.SendAsync(req, ct);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(body);
var content = doc.RootElement
.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
if (string.IsNullOrWhiteSpace(content)) return null;
return ParseVerdict(content);
return (resp.StatusCode, body);
}
catch (Exception ex)
private static bool IsSuccess(HttpStatusCode s) => (int)s is >= 200 and < 300;
/// <summary>Pulls choices[0].message.content out of an OpenAI-style response. Returns null on any
/// unexpected shape (e.g. an error object) rather than throwing, so the caller can show the body.</summary>
private static string? ExtractContent(string body)
{
_log.LogWarning(ex, "AI audit failed — falling back to rule-based decision.");
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("choices", out var choices)
&& choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0
&& choices[0].TryGetProperty("message", out var msg)
&& msg.TryGetProperty("content", out var content))
return content.GetString();
}
catch (JsonException) { }
return null;
}
}
private static string Truncate(string? s, int max)
=> string.IsNullOrEmpty(s) ? "(خالی)" : (s.Length <= max ? s : s[..max] + " …");
private static AiAuditResult? ParseVerdict(string json)
{
@@ -113,20 +210,38 @@ public class OpenAiCompatibleAuditor : IAiAuditor
if (start < 0 || end <= start) return null;
json = json.Substring(start, end - start + 1);
using var doc = JsonDocument.Parse(json);
JsonDocument doc;
try { doc = JsonDocument.Parse(json); }
catch (JsonException) { return null; } // model returned non-JSON content
using (doc)
{
var r = doc.RootElement;
// Guard on ValueKind == Number first — TryGetInt32/64 THROW on null/string values
// (the model often returns payAmount/sharePercent as null), which would fail the whole parse.
string? S(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
int I(string k, int d) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : d;
long? L(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) ? n : null;
double? D(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var n) ? n : null;
int? NI(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : null;
bool? B(string k) => r.TryGetProperty(k, out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False) ? v.GetBoolean() : null;
// Array-of-strings reader (tolerates the model returning a single string instead of an array).
IReadOnlyList<string>? SA(string k)
{
if (!r.TryGetProperty(k, out var v)) return null;
var list = new List<string>();
if (v.ValueKind == JsonValueKind.Array)
foreach (var el in v.EnumerateArray())
if (el.ValueKind == JsonValueKind.String && el.GetString() is { Length: > 0 } s) list.Add(s);
else if (v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } one) list.Add(one);
return list.Count > 0 ? list : null;
}
var decision = (S("decision") ?? "review").ToLowerInvariant();
var data = new AiStructured(S("kind"), S("role"), S("city"), S("district"), S("shiftType"),
S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName"),
Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed"));
Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed"),
Category: S("category"), Tags: SA("tags"), Lat: D("lat"), Lng: D("lng"));
return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data);
}
}
}
@@ -36,17 +36,20 @@ public class BaleListingSource : IListingSource
var items = new List<ScrapedItem>();
foreach (var update in result.EnumerateArray())
{
var text = TextOf(update, "channel_post") ?? TextOf(update, "message");
if (!string.IsNullOrWhiteSpace(text) && text!.Trim().Length >= 15)
items.Add(new ScrapedItem("بله", text.Trim()));
var post = Msg(update, "channel_post") ?? Msg(update, "message");
if (post is not { } p) continue;
var text = p.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null;
if (string.IsNullOrWhiteSpace(text) || text!.Trim().Length < 15) continue;
// Bot API messages carry a unix `date` — keep it so stale posts can be aged out.
DateTime? postedAt = p.TryGetProperty("date", out var d) && d.ValueKind == JsonValueKind.Number && d.TryGetInt64(out var epoch)
? DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime : null;
items.Add(new ScrapedItem("بله", text.Trim(), PostedAt: postedAt));
}
return items;
}
catch (Exception ex) { _log.LogWarning(ex, "Bale fetch failed."); return Array.Empty<ScrapedItem>(); }
}
private static string? TextOf(JsonElement update, string key)
=> update.TryGetProperty(key, out var m)
&& m.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString() : null;
private static JsonElement? Msg(JsonElement update, string key)
=> update.TryGetProperty(key, out var m) && m.ValueKind == JsonValueKind.Object ? m : null;
}
@@ -59,17 +59,31 @@ public class DivarListingSource : IListingSource
continue;
}
using var doc = JsonDocument.Parse(body);
var cityLabel = CityLabel(s.DivarCity); // every result is from the city we searched
foreach (var (text, token) in Harvest(doc.RootElement).Take(25))
{
var url = token is not null ? $"https://divar.ir/v/{token}" : "https://divar.ir";
var withPhone = text;
// Only a real post token gives a usable deep link. Without one, leave SourceUrl null —
// a bare «https://divar.ir» just opens Divar's homepage, which is useless to the user.
var url = token is not null ? $"https://divar.ir/v/{token}" : null;
var itemText = text;
// Stamp the city so the parser/AI always resolve a location (Divar's own location
// line isn't always in the search row; the searched city is authoritative).
if (!string.IsNullOrWhiteSpace(cityLabel) && !text.Contains(cityLabel))
itemText += $"\n📍 {cityLabel}";
double? lat = null, lng = null;
if (token is not null)
{
var phones = await RevealPhonesAsync(client, token, s, ct);
if (phones.Count > 0 && !phones.Any(text.Contains))
withPhone = text + "\nشماره تماس: " + string.Join("، ", phones);
// One detail fetch yields the FULL description, the phone, AND the map center.
// (The search row only carries a short one-line summary — the rich ad body lives
// on the post detail, so without this the listing looked "censored".)
var (phones, gLat, gLng, fullDesc) = await FetchDetailAsync(client, token, ct);
if (!string.IsNullOrWhiteSpace(fullDesc) && !itemText.Contains(fullDesc))
itemText += "\n" + fullDesc;
if (phones.Count > 0 && !phones.Any(itemText.Contains))
itemText += "\nشماره تماس: " + string.Join("، ", phones);
lat = gLat; lng = gLng;
}
items.Add(new ScrapedItem("دیوار", withPhone, url));
items.Add(new ScrapedItem("دیوار", itemText, url, lat, lng));
}
}
catch (Exception ex) { _log.LogWarning(ex, "Divar fetch failed for query {Query}", q); }
@@ -95,16 +109,31 @@ public class DivarListingSource : IListingSource
};
}
/// <summary>Persian display name for the searched city (slug/number/Persian → Persian), used to
/// stamp every Divar result with its (authoritative) location.</summary>
private static string CityLabel(string? city) => (city ?? "").Trim().ToLowerInvariant() switch
{
"1" or "tehran" or "تهران" => "تهران",
"3" or "isfahan" or "esfahan" or "اصفهان" => "اصفهان",
"4" or "mashhad" or "مشهد" => "مشهد",
"5" or "shiraz" or "شیراز" => "شیراز",
"6" or "tabriz" or "تبریز" => "تبریز",
"1745" or "karaj" or "کرج" => "کرج",
_ => (city ?? "").Trim(),
};
// The post detail endpoint returns the FULL description — many Divar job ads write the phone
// straight into the body, so we can harvest it without Divar's (login-gated) contact reveal.
private const string PostDetailUrl = "https://api.divar.ir/v8/posts-v2/web/";
/// <summary>
/// Fetch a post's detail JSON and harvest any contact number it contains (mostly numbers the
/// poster wrote into the description). Divar's true "نمایش شماره" reveal is auth-gated; this
/// covers the common case where the number is in the ad text. Fails soft.
/// Fetch a post's detail JSON ONCE and harvest both (a) any contact number it contains (mostly
/// numbers the poster wrote into the description; Divar's true "نمایش شماره" reveal is auth-gated)
/// and (b) the post's APPROXIMATE map coordinates (the privacy-fuzzed center Divar shows as a
/// circle). Fails soft — returns whatever it could extract.
/// </summary>
private async Task<List<string>> RevealPhonesAsync(HttpClient client, string token, AppSetting s, CancellationToken ct)
private async Task<(List<string> phones, double? lat, double? lng, string? description)> FetchDetailAsync(
HttpClient client, string token, CancellationToken ct)
{
try
{
@@ -112,18 +141,101 @@ public class DivarListingSource : IListingSource
req.Headers.TryAddWithoutValidation("User-Agent", Ua);
req.Headers.TryAddWithoutValidation("Accept", "application/json");
using var resp = await client.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode) return new();
if (!resp.IsSuccessStatusCode) return (new(), null, null, null);
var body = await resp.Content.ReadAsStringAsync(ct);
if (body.Contains("BLOCKING_VIEW")) return new();
return HtmlUtil.HarvestPhones(body);
if (body.Contains("BLOCKING_VIEW")) return (new(), null, null, null);
var phones = HtmlUtil.HarvestPhones(body);
double? lat = null, lng = null; string? desc = null;
try
{
using var doc = JsonDocument.Parse(body);
if (FindLatLng(doc.RootElement) is { } g) { lat = g.lat; lng = g.lng; }
desc = FindLongestText(doc.RootElement); // the full ad body
}
catch (JsonException) { /* detail wasn't JSON — phones still harvested from text */ }
return (phones, lat, lng, desc);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Divar detail/reveal failed for {Token}", token);
return new();
return (new(), null, null, null);
}
}
/// <summary>The full ad description in Divar's detail JSON = the longest free-text string. We skip
/// Divar's own safety/boilerplate notices (which mention «دیوار») and absurdly long blobs.</summary>
private static string? FindLongestText(JsonElement root)
{
string? best = null;
var stack = new Stack<JsonElement>();
stack.Push(root);
while (stack.Count > 0)
{
var e = stack.Pop();
switch (e.ValueKind)
{
case JsonValueKind.Object:
foreach (var p in e.EnumerateObject()) stack.Push(p.Value);
break;
case JsonValueKind.Array:
foreach (var it in e.EnumerateArray()) stack.Push(it);
break;
case JsonValueKind.String:
var s = e.GetString();
if (s is { Length: >= 40 and <= 4000 } && s.Contains(' ') && !s.Contains("دیوار")
&& (best is null || s.Length > best.Length)) best = s;
break;
}
}
return best?.Trim();
}
// Iran's bounding box — guards against picking up an unrelated number pair (timestamps, ids…).
private const double MinLat = 24, MaxLat = 40, MinLng = 44, MaxLng = 64;
/// <summary>
/// Tolerantly find an approximate (lat, lng) anywhere in Divar's detail JSON. Divar's shape
/// shifts (sometimes `latitude`/`longitude`, sometimes nested under `location`/`coordinates`),
/// so we walk the tree and accept the first OBJECT that holds BOTH a latitude-like and a
/// longitude-like numeric property whose values fall inside Iran. Pairing within one object
/// avoids matching a stray lat to an unrelated lng. Returns null if nothing plausible is found.
/// </summary>
private static (double lat, double lng)? FindLatLng(JsonElement el)
{
if (el.ValueKind == JsonValueKind.Object)
{
double? lat = null, lng = null;
foreach (var p in el.EnumerateObject())
{
if (lat is null && IsLatKey(p.Name) && TryNum(p.Value, out var la)) lat = la;
else if (lng is null && IsLngKey(p.Name) && TryNum(p.Value, out var lo)) lng = lo;
}
if (lat is double L && lng is double G && L is >= MinLat and <= MaxLat && G is >= MinLng and <= MaxLng)
return (L, G);
foreach (var p in el.EnumerateObject())
if (FindLatLng(p.Value) is { } r) return r;
}
else if (el.ValueKind == JsonValueKind.Array)
foreach (var item in el.EnumerateArray())
if (FindLatLng(item) is { } r) return r;
return null;
}
private static bool IsLatKey(string k) => k.Equals("latitude", StringComparison.OrdinalIgnoreCase) || k.Equals("lat", StringComparison.OrdinalIgnoreCase);
private static bool IsLngKey(string k) =>
k.Equals("longitude", StringComparison.OrdinalIgnoreCase) || k.Equals("lng", StringComparison.OrdinalIgnoreCase)
|| k.Equals("lon", StringComparison.OrdinalIgnoreCase) || k.Equals("long", StringComparison.OrdinalIgnoreCase);
/// <summary>Coordinate may be a JSON number or a numeric string ("35.7"). Invariant culture.</summary>
private static bool TryNum(JsonElement v, out double d)
{
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out d);
if (v.ValueKind == JsonValueKind.String)
return double.TryParse(v.GetString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out d);
d = 0; return false;
}
private static readonly string[] DescKeys =
{ "description", "middle_description_text", "subtitle", "bottom_description_text", "normal_text" };
@@ -134,9 +246,11 @@ public class DivarListingSource : IListingSource
if (el.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String)
{
var sb = new StringBuilder(t.GetString());
// Append ALL present description fields — the location/time line («… در تهران، جنت‌آباد»)
// is usually in bottom_description_text, so don't stop at the first match.
foreach (var k in DescKeys)
if (el.TryGetProperty(k, out var d) && d.ValueKind == JsonValueKind.String)
{ sb.Append(" — ").Append(d.GetString()); break; }
if (el.TryGetProperty(k, out var d) && d.ValueKind == JsonValueKind.String && d.GetString() is { Length: > 0 } v)
sb.Append(" — ").Append(v);
var text = sb.ToString().Trim();
if (text.Length >= 15) yield return (text, FindToken(el));
}
@@ -17,7 +17,10 @@ public static class FacilityMatcher
{
"بیمارستان", "زایشگاه", "پلی کلینیک", "پلیکلینیک", "درمانگاه", "کلینیک",
"مرکز درمانی", "مرکز جراحی", "مجتمع پزشکی", "مجتمع درمانی", "مرکز", "مجتمع",
"آزمایشگاه", "مطب", "تخصصی", "فوق تخصصی", "فوقتخصصی", "عمومی", "دکتر", "دی کلینیک",
"آزمایشگاه", "داروخانه", "آسایشگاه", "مطب", "تخصصی", "فوق تخصصی", "فوقتخصصی", "عمومی", "دکتر", "دی کلینیک",
// Generic descriptors — never the distinctive part of a name. Stripping them stops false
// merges like «درمانگاه شبانه‌روزی اسفند» → «پلی کلینیک شبانه روزی» (they share «شبانه روزی»).
"شبانه روزی", "شبانه‌روزی", "خیریه", "دولتی", "خصوصی", "۲۴ ساعته", "24 ساعته", "تامین اجتماعی",
};
/// <summary>Lower-cased, Arabic→Persian folded, punctuation-stripped, whitespace-collapsed.</summary>
@@ -47,6 +50,42 @@ public static class FacilityMatcher
return Regex.Replace(n, @"\s+", " ").Trim();
}
// Filler/verb/locator tokens that are never a real facility name — the parser sweeps these in
// when an ad has no named facility («بیمارستان هستم», «مطب نیازمندیم سه», «کلینیک های فقط منطقه»).
private static readonly string[] JunkCoreWords =
{
"هستم", "هستیم", "هستش", "میشوم", "میشم", "بشوم", "میباشد", "باشد", "میباشم",
"نیازمندیم", "نیازمند", "نیازمندم", "داریم", "دارم", "میخواهیم", "میخوام",
"حتی", "تعدادی", "فقط", "منطقه", "واقع", "های", "مبتدی", "محترم", "خوب",
"سه", "دو", "یک", "چند", "این", "آن", "همکار", "نیرو",
};
// Crawl-source names that must never appear as a public facility («مرکز درمانی (از مدجابز)»),
// plus the shared placeholder text.
private static readonly string[] SourceMarkers =
{
"مدجابز", "مدجاب", "از تلگرام", "از دیوار", "از بله", "از کانال", "ثبت نشده", "نامشخص",
};
/// <summary>
/// True when a name is NOT a usable facility name: a bare type word («بیمارستان»), a name whose
/// distinctive core is only filler/verb tokens («بیمارستان هستم» → «هستم»), or a leaked crawl
/// source / placeholder («... از مدجابز», «نامشخص»). Such an ad has no real named facility and
/// should fall back to the shared placeholder instead of forging a fake one.
/// </summary>
public static bool IsJunkName(string? name)
{
var normalized = Normalize(name);
if (normalized.Length == 0) return true;
if (SourceMarkers.Any(m => normalized.Contains(Normalize(m)))) return true;
var core = Core(name);
if (core.Length == 0) return true; // bare type word only («بیمارستان»، «کلینیک»)
var tokens = core.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return tokens.All(t => t.Length <= 1 || JunkCoreWords.Contains(t));
}
/// <summary>True when two names almost certainly denote the same facility.</summary>
public static bool IsSame(string? a, string? b)
{
@@ -2,8 +2,13 @@ using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services.Scraping;
/// <summary>One raw post pulled from a source (a Telegram message, a Divar ad, etc.).</summary>
public record ScrapedItem(string Source, string RawText, string? SourceUrl = null);
/// <summary>One raw post pulled from a source (a Telegram message, a Divar ad, etc.).
/// Lat/Lng are an APPROXIMATE location when the source exposes one (e.g. Divar's privacy-fuzzed
/// map center) — used to place an aggregated facility on the map / enable «near me».
/// PostedAt is the post's ORIGINAL publish time when the source exposes it (Telegram &lt;time&gt;,
/// Bale message date…) — used to drop stale applicant ads at ingest. Null when unknown.</summary>
public record ScrapedItem(string Source, string RawText, string? SourceUrl = null,
double? Lat = null, double? Lng = null, DateTime? PostedAt = null);
/// <summary>
/// A pluggable source the ingestion engine pulls from. Configuration (enabled, channels, tokens)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
using System.Text.RegularExpressions;
using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services.Scraping;
/// <summary>
/// Scrapes clinical job ads from iranestekhdam.ir. It reads the site's monthly ad sitemaps
/// (sitemap-ads.xml → sitemap-ads-YYYY-M.xml) to enumerate ad URLs, keeps only those whose
/// readable Persian slug names a CLINICAL role (veterinary / non-clinical excluded), then fetches
/// each ad page and extracts its title + description (+ any phone). These are EMPLOYER ads at NAMED
/// facilities (بیمارستان/درمانگاه/کلینیک/آزمایشگاه …) — far higher quality than classifieds, so they
/// directly improve the «نامشخص»-facility problem. Content-hash dedupe ingests each ad once; the
/// medical-gate validator + AI auditor + junk filters do the final screening on top.
/// </summary>
public class IranEstekhdamListingSource : IListingSource
{
private const string SitemapIndex = "https://iranestekhdam.ir/sitemap-ads.xml";
private readonly ScrapeHttpClients _clients;
private readonly ILogger<IranEstekhdamListingSource> _log;
public IranEstekhdamListingSource(ScrapeHttpClients clients, ILogger<IranEstekhdamListingSource> log)
{
_clients = clients;
_log = log;
}
public string Name => "ایران‌استخدام (iranestekhdam.ir)";
// Clinical-role markers matched against the DECODED Persian URL slug. Words are hyphen-joined in
// the slug, so substring matching works on the decoded form.
private static readonly string[] RoleSlugs =
{
"پرستار", "بهیار", "کمک-پرستار", "کمک-بهیار", "پزشک", "دندان", "مامایی", "ماما", "تکنسین",
"رادیولوژ", "سونوگراف", "فیزیوتراپ", "کاردرمان", "گفتاردرمان", "شنوایی", "بینایی", "اپتومتر",
"دیالیز", "اتاق-عمل", "بیهوش", "تزریقات", "فوریت", "اورژانس", "داروساز", "نسخه", "سالمند",
};
// Slugs that share a substring with a clinical role but are NOT کادر درمان — drop them.
private static readonly string[] ExcludeSlugs = { "دامپزشک", "دام-پزشک", "دامپزشکی" };
// LAUNCH = TEHRAN ONLY. We keep only ads located in Tehran (the ad's og:description reliably
// states «شهر تهران»). Other major cities named in the slug are pre-dropped to save fetches.
// When the engine is proven and we expand nationwide, make this a per-source city setting.
private const string Tehran = "تهران";
private static readonly string[] OtherCitySlugs =
{
"شیراز", "اصفهان", "مشهد", "تبریز", "کرج", "اهواز", "قم", "یزد", "رشت", "کرمان", "اراک",
"اردبیل", "همدان", "کرمانشاه", "زنجان", "قزوین", "ساری", "گرگان", "بندرعباس", "بوشهر",
"سنندج", "خرم-آباد", "بیرجند", "سمنان", "شهرکرد", "ایلام", "یاسوج", "زاهدان", "ارومیه",
"نجف-آباد", "کاشان", "قائم-شهر", "بابل", "آمل", "دزفول", "ملارد", "پاکدشت",
};
public async Task<IReadOnlyList<ScrapedItem>> FetchAsync(AppSetting s, CancellationToken ct = default)
{
if (!s.IranEstekhdamEnabled) return Array.Empty<ScrapedItem>();
var max = Math.Clamp(s.IranEstekhdamMaxAds, 1, 500);
var client = _clients.For(s, s.IranEstekhdamUseProxy);
try
{
// 1. sitemap index → the monthly ad sitemaps (newest first as listed by the site)
var index = await client.GetStringAsync(SitemapIndex, ct);
var monthly = Locs(index).Where(u => u.Contains("sitemap-ads-")).ToList();
if (monthly.Count == 0) { _log.LogWarning("iranestekhdam: no monthly ad sitemaps found"); return Array.Empty<ScrapedItem>(); }
// 2. pool clinical-role candidate URLs, pre-dropping obvious non-Tehran slugs. We gather
// more than `max` because the authoritative Tehran check (on the ad text) trims further.
var pool = new List<string>();
var budget = max * 5;
foreach (var sm in monthly)
{
if (pool.Count >= budget) break;
try
{
foreach (var u in Locs(await client.GetStringAsync(sm, ct)))
{
if (IsClinicalSlug(u) && !IsOtherCitySlug(u) && !pool.Contains(u)) pool.Add(u);
if (pool.Count >= budget) break;
}
}
catch (Exception ex) { _log.LogWarning(ex, "iranestekhdam: sitemap {Sm} failed", sm); }
}
// 3. fetch each ad → keep only Tehran ones (text must name «تهران»), up to `max`.
var items = new List<ScrapedItem>();
foreach (var url in pool)
{
if (items.Count >= max) break;
ct.ThrowIfCancellationRequested();
try
{
var html = await client.GetStringAsync(url, ct);
var text = ExtractAd(html);
if (text.Length < 25 || !text.Contains(Tehran)) continue; // Tehran-only launch filter
items.Add(new ScrapedItem("ایران‌استخدام", text, url));
}
catch (Exception ex) { _log.LogWarning(ex, "iranestekhdam: ad {Url} failed", url); }
}
_log.LogInformation("iranestekhdam: fetched {Count} Tehran clinical ads (from {Pool} pooled)", items.Count, pool.Count);
return items;
}
catch (Exception ex)
{
_log.LogWarning(ex, "iranestekhdam fetch failed");
return Array.Empty<ScrapedItem>();
}
}
private static bool IsClinicalSlug(string url)
{
var slug = Uri.UnescapeDataString(url);
if (ExcludeSlugs.Any(slug.Contains)) return false;
return RoleSlugs.Any(slug.Contains);
}
private static bool IsOtherCitySlug(string url)
{
var slug = Uri.UnescapeDataString(url);
return OtherCitySlugs.Any(slug.Contains);
}
private static IEnumerable<string> Locs(string xml)
=> Regex.Matches(xml, "<loc>([^<]+)</loc>").Select(m => m.Groups[1].Value.Trim());
/// <summary>Title (site suffix stripped) + the ad's description. iranestekhdam puts a complete,
/// structured summary (facility + city + district + role) in og:description, with the full
/// requirements in the .single-ad container — prefer whichever yields more text.</summary>
private static string ExtractAd(string html)
{
var title = Meta(html, "og:title");
if (title is not null) { var bar = title.IndexOf('|'); if (bar > 10) title = title[..bar].Trim(); }
var ogBody = Meta(html, "og:description");
var single = BetweenClass(html, "single-ad");
var singleText = single is null ? null : HtmlUtil.ToPlainText(single);
var body = (singleText?.Length ?? 0) > (ogBody?.Length ?? 0) ? singleText : ogBody;
var text = HtmlUtil.ToPlainText(string.Join("\n", new[] { title, body }.Where(p => !string.IsNullOrWhiteSpace(p))));
if (text.Length > 1800) text = text[..1800];
var phones = HtmlUtil.HarvestPhones(body ?? "");
if (phones.Count > 0 && !phones.Any(text.Contains))
text += "\nشماره تماس: " + string.Join("، ", phones);
return text;
}
private static string? Meta(string html, string prop)
{
var m = Regex.Match(html, $"<meta[^>]+property=[\"']{Regex.Escape(prop)}[\"'][^>]+content=[\"']([^\"']*)[\"']");
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value) : null;
}
private static string? BetweenClass(string html, string cls)
{
var m = Regex.Match(html, $"<(?:div|article|section)[^>]+class=[\"'][^\"']*{Regex.Escape(cls)}[^\"']*[\"'][^>]*>(.*?)</(?:div|article|section)>",
RegexOptions.Singleline);
return m.Success ? m.Groups[1].Value : null;
}
}
@@ -3,7 +3,7 @@ using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services.Scraping;
public record ValidationResult(bool IsValid, bool IsSpam, int Confidence, List<string> Issues);
public record ValidationResult(bool IsValid, bool IsSpam, int Confidence, List<string> Issues, bool LooksMedical = false);
/// <summary>
/// Scores a parsed listing for completeness and screens out spam. A listing must look like a
@@ -39,6 +39,24 @@ public class ListingValidator
"بوتاکس و فیلر", "مزوتراپی", "فیلر صورت",
};
// Domestic-helper ads (housekeeping/cleaning/servant) — not کادر درمان, even when they also
// mention سالمند/نگهداری. The «امور منزل / نظافت» phrasing is the giveaway.
private static readonly string[] DomesticMarkers =
{
"امور منزل", "امور سبک منزل", "امورسبک منزل", "کارهای منزل", "کار منزل", "نظافت منزل",
"نظافتچی", "خدمتکار", "کارگر منزل", "خدمات منزل", "مستخدم",
};
// Home childcare / babysitting — a family hiring someone to mind their child at home. NOT کادر
// درمان even when phrased «پرستار کودک/بچه». Clinical pediatric roles say «بخش اطفال/کودکان/NICU»,
// not «نگهداری/بچه‌داری» or a parent self-identifying («پدر/مادر کودک»).
private static readonly string[] ChildcareMarkers =
{
"بچه داری", "بچه‌داری", "بچه دار ", "نگهداری کودک", "نگهداری از کودک", "نگهداری بچه",
"نگهداری از بچه", "نگهداری فرزند", "نگهداری نوزاد", "نگهداری شیرخوار", "پرستار بچه",
"پدر کودک", "مادر کودک", "نگهدار کودک", "نگهدار بچه", "مراقبت از کودک", "مراقبت از بچه",
};
// Words that signal a real staffing post (hiring, shift, or availability).
private static readonly string[] StaffingIntent =
{
@@ -64,7 +82,21 @@ public class ListingValidator
if (isPromo)
{
issues.Add("آگهی تبلیغاتی/آموزشی است، نه استخدام/شیفت");
return new ValidationResult(false, true, 0, issues); // IsSpam → auto-discard
return new ValidationResult(false, true, 0, issues, looksMedical); // IsSpam → auto-discard
}
// Domestic-helper / housekeeping ads — out of scope (not کادر درمان), discard.
if (DomesticMarkers.Any(text.Contains))
{
issues.Add("آگهی خدماتِ منزل/نظافت است، نه کادر درمان");
return new ValidationResult(false, true, 0, issues, looksMedical); // IsSpam → auto-discard
}
// Home childcare / babysitting — out of scope (not کادر درمان), discard.
if (ChildcareMarkers.Any(text.Contains))
{
issues.Add("آگهی نگهداری کودک در منزل است، نه کادر درمان");
return new ValidationResult(false, true, 0, issues, looksMedical); // IsSpam → auto-discard
}
// «آماده به کار»: a worker offering themselves. No facility/shift-date expected; the role
@@ -84,7 +116,7 @@ public class ListingValidator
if (tlen < 20) { ts -= 20; issues.Add("متن خیلی کوتاه است"); }
ts = Math.Clamp(ts, 0, 100);
bool tValid = !isSpam && looksMedical && ts >= 50; // role(40)+medical(10) passes w/o phone
return new ValidationResult(tValid, isSpam, ts, issues);
return new ValidationResult(tValid, isSpam, ts, issues, looksMedical);
}
int score = 0;
@@ -107,6 +139,6 @@ public class ListingValidator
// Valid enough for the queue if it's medical, not spam, and reasonably complete.
bool isValid = !isSpam && looksMedical && score >= 50;
return new ValidationResult(isValid, isSpam, score, issues);
return new ValidationResult(isValid, isSpam, score, issues, looksMedical);
}
}
@@ -0,0 +1,155 @@
using System.Text.RegularExpressions;
using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services.Scraping;
/// <summary>
/// Scrapes clinical ads from medboom.ir («مرجع استخدام و نیازمندی علوم پزشکی») — a WordPress
/// ad-listing site like medjobs.ir. It enumerates ad posts via the WP sitemap
/// (wp-sitemap.xml → wp-sitemap-posts-post-N.xml), newest first, keeps clinical-role slugs, and
/// extracts each ad's title + description (+ phone). medboom skews toward DOCTORS/DENTISTS and
/// carries BOTH hiring («نیازمند…») and availability («آماده همکاری / جویای کار») posts, so it
/// directly broadens the role mix the nurse-heavy classifieds sources miss. Tehran-only for launch.
/// VPN-free (Iranian-hosted). Content-hash dedupe ingests each ad once; the validator/AI screen on top.
/// </summary>
public class MedboomListingSource : IListingSource
{
private const string SitemapIndex = "https://medboom.ir/wp-sitemap.xml";
private readonly ScrapeHttpClients _clients;
private readonly ILogger<MedboomListingSource> _log;
public MedboomListingSource(ScrapeHttpClients clients, ILogger<MedboomListingSource> log)
{
_clients = clients;
_log = log;
}
public string Name => "مدبوم (medboom.ir)";
// Clinical-role markers matched against the decoded Persian ad slug.
private static readonly string[] RoleSlugs =
{
"پزشک", "دندان", "پرستار", "بهیار", "مامایی", "ماما", "تکنسین", "رادیولوژ", "سونوگراف",
"فیزیوتراپ", "کاردرمان", "گفتاردرمان", "شنوایی", "بینایی", "اپتومتر", "دیالیز", "اتاق-عمل",
"بیهوش", "هوشبری", "تزریقات", "فوریت", "اورژانس", "داروساز", "داروخانه", "نسخه", "سالمند",
"علوم-آزمایشگاهی", "آزمایشگاه", "مسئول-فنی", "مامو", "تغذیه", "روانشناس", "اپتیک",
};
// Veterinary + obvious non-staffing categories medboom also carries (equipment sale, real estate).
private static readonly string[] ExcludeSlugs =
{
"دامپزشک", "دام-پزشک", "دامپزشکی", "فروش", "اجاره", "املاک", "دستگاه", "تجهیزات", "ملک",
};
private const string Tehran = "تهران";
private static readonly string[] OtherCitySlugs =
{
"شیراز", "اصفهان", "مشهد", "تبریز", "کرج", "قم", "یزد", "رشت", "کرمان", "اراک", "اردبیل",
"همدان", "کرمانشاه", "زنجان", "قزوین", "ساری", "گرگان", "بندرعباس", "بوشهر", "سنندج",
"بیرجند", "سمنان", "شهرکرد", "ایلام", "یاسوج", "زاهدان", "ارومیه", "البرز", "اهواز", "کاشان",
};
public async Task<IReadOnlyList<ScrapedItem>> FetchAsync(AppSetting s, CancellationToken ct = default)
{
if (!s.MedboomEnabled) return Array.Empty<ScrapedItem>();
var max = Math.Clamp(s.MedboomMaxAds, 1, 500);
var client = _clients.For(s, s.MedboomUseProxy);
try
{
// 1. WP sitemap index → the ad-post sitemaps. Process newest first (highest-numbered).
var index = await client.GetStringAsync(SitemapIndex, ct);
var postMaps = Locs(index).Where(u => u.Contains("posts-post-"))
.OrderByDescending(u => u).ToList();
if (postMaps.Count == 0) { _log.LogWarning("medboom: no ad-post sitemaps found"); return Array.Empty<ScrapedItem>(); }
// 2. pool clinical candidate URLs (newest first within each map), pre-dropping other cities.
var pool = new List<string>();
var budget = max * 6;
foreach (var sm in postMaps)
{
if (pool.Count >= budget) break;
try
{
var urls = Locs(await client.GetStringAsync(sm, ct)).Reverse(); // newest ads last → take from end
foreach (var u in urls)
{
if (IsClinicalSlug(u) && !IsOtherCitySlug(u) && !pool.Contains(u)) pool.Add(u);
if (pool.Count >= budget) break;
}
}
catch (Exception ex) { _log.LogWarning(ex, "medboom: sitemap {Sm} failed", sm); }
}
// 3. fetch each ad → keep only Tehran ones, up to `max`.
var items = new List<ScrapedItem>();
foreach (var url in pool)
{
if (items.Count >= max) break;
ct.ThrowIfCancellationRequested();
try
{
var html = await client.GetStringAsync(url, ct);
var text = ExtractAd(html);
if (text.Length < 25 || !text.Contains(Tehran)) continue; // Tehran-only launch filter
items.Add(new ScrapedItem("مدبوم", text, url));
}
catch (Exception ex) { _log.LogWarning(ex, "medboom: ad {Url} failed", url); }
}
_log.LogInformation("medboom: fetched {Count} Tehran clinical ads (from {Pool} pooled)", items.Count, pool.Count);
return items;
}
catch (Exception ex)
{
_log.LogWarning(ex, "medboom fetch failed");
return Array.Empty<ScrapedItem>();
}
}
private static bool IsClinicalSlug(string url)
{
var slug = Uri.UnescapeDataString(url);
if (ExcludeSlugs.Any(slug.Contains)) return false;
return RoleSlugs.Any(slug.Contains);
}
private static bool IsOtherCitySlug(string url)
{
var slug = Uri.UnescapeDataString(url);
return OtherCitySlugs.Any(slug.Contains);
}
private static IEnumerable<string> Locs(string xml)
=> Regex.Matches(xml, "<loc>([^<]+)</loc>").Select(m => m.Groups[1].Value.Trim());
private static string ExtractAd(string html)
{
var title = Meta(html, "og:title");
if (title is not null) { var bar = title.IndexOf('|'); if (bar > 10) title = title[..bar].Trim(); }
var ogBody = Meta(html, "og:description");
var entry = BetweenClass(html, "entry-content");
var entryText = entry is null ? null : HtmlUtil.ToPlainText(entry);
var body = (entryText?.Length ?? 0) > (ogBody?.Length ?? 0) ? entryText : ogBody;
var text = HtmlUtil.ToPlainText(string.Join("\n", new[] { title, body }.Where(p => !string.IsNullOrWhiteSpace(p))));
if (text.Length > 1800) text = text[..1800];
var phones = HtmlUtil.HarvestPhones(body ?? "");
if (phones.Count > 0 && !phones.Any(text.Contains))
text += "\nشماره تماس: " + string.Join("، ", phones);
return text;
}
private static string? Meta(string html, string prop)
{
var m = Regex.Match(html, $"<meta[^>]+property=[\"']{Regex.Escape(prop)}[\"'][^>]+content=[\"']([^\"']*)[\"']");
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value) : null;
}
private static string? BetweenClass(string html, string cls)
{
var m = Regex.Match(html, $"<(?:div|article|section)[^>]+class=[\"'][^\"']*{Regex.Escape(cls)}[^\"']*[\"'][^>]*>(.*?)</(?:div|article|section)>",
RegexOptions.Singleline);
return m.Success ? m.Groups[1].Value : null;
}
}
@@ -31,8 +31,7 @@ public class SettingsService
s.AiEndpoint = incoming.AiEndpoint?.Trim();
s.AiApiKey = incoming.AiApiKey?.Trim();
s.AiModel = incoming.AiModel?.Trim();
s.AiSystemPrompt = string.IsNullOrWhiteSpace(incoming.AiSystemPrompt)
? AppSetting.DefaultPrompt : incoming.AiSystemPrompt;
s.AiSystemPrompt = AppSetting.DefaultPrompt; // hardcoded & read-only — keep the column in sync
s.AiAutoApprove = incoming.AiAutoApprove;
s.AiUseProxy = incoming.AiUseProxy;
// Channel scraping sources
@@ -56,6 +55,12 @@ public class SettingsService
s.DivarQueries = incoming.DivarQueries?.Trim();
s.MedjobsEnabled = incoming.MedjobsEnabled;
s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500);
s.IranEstekhdamEnabled = incoming.IranEstekhdamEnabled;
s.IranEstekhdamMaxAds = Math.Clamp(incoming.IranEstekhdamMaxAds, 1, 500);
s.IranEstekhdamUseProxy = incoming.IranEstekhdamUseProxy;
s.MedboomEnabled = incoming.MedboomEnabled;
s.MedboomMaxAds = Math.Clamp(incoming.MedboomMaxAds, 1, 500);
s.MedboomUseProxy = incoming.MedboomUseProxy;
s.SmsEnabled = incoming.SmsEnabled;
s.SmsApiKey = incoming.SmsApiKey?.Trim();
s.SmsTemplate = incoming.SmsTemplate?.Trim();
@@ -0,0 +1,52 @@
using System.Text.RegularExpressions;
namespace JobsMedical.Web.Services.Scraping;
/// <summary>
/// Coarse neighborhood → APPROXIMATE center geocoder for Tehran. Many ads (Medjobs/Telegram) name a
/// neighborhood but carry no coordinates; this lets us show an approximate-area circle from the name
/// alone. Centers are deliberately rough (the UI always labels them «محدودهٔ تقریبی»), never an
/// address. Extend the table freely — order doesn't matter, matching is name-normalized + substring.
/// </summary>
public static class TehranGeo
{
private static readonly (string Name, double Lat, double Lng)[] Raw =
{
("سعادت‌آباد", 35.7872, 51.3760), ("شهرک غرب", 35.7570, 51.3680), ("نارمک", 35.7448, 51.5085),
("تهرانپارس", 35.7350, 51.5400), ("ونک", 35.7560, 51.4100), ("تجریش", 35.8040, 51.4340),
("ولیعصر", 35.7986, 51.4087), ("پارک‌وی", 35.7986, 51.4087), ("گیشا", 35.7400, 51.3880),
("برج میلاد", 35.7448, 51.3753), ("پاسداران", 35.7890, 51.4560), ("میرداماد", 35.7600, 51.4300),
("جردن", 35.7700, 51.4180), ("آفریقا", 35.7700, 51.4180), ("ولنجک", 35.8080, 51.4080),
("نیاوران", 35.8170, 51.4700), ("زعفرانیه", 35.8100, 51.4200), ("الهیه", 35.7900, 51.4320),
("قیطریه", 35.7950, 51.4450), ("فرمانیه", 35.8000, 51.4700), ("دروس", 35.7850, 51.4500),
("یوسف‌آباد", 35.7370, 51.4050), ("امیرآباد", 35.7260, 51.3920), ("انقلاب", 35.7010, 51.3940),
("صادقیه", 35.7150, 51.3450), ("پونک", 35.7620, 51.3300), ("جنت‌آباد", 35.7600, 51.3100),
("اکباتان", 35.7150, 51.3100), ("ستارخان", 35.7200, 51.3550), ("مرزداران", 35.7400, 51.3500),
("نازی‌آباد", 35.6400, 51.4080), ("یافت‌آباد", 35.6600, 51.3500), ("شهرری", 35.5850, 51.4350),
("پیروزی", 35.7000, 51.4800), ("رسالت", 35.7450, 51.5000), ("حکیمیه", 35.7450, 51.5800),
("تهرانسر", 35.7100, 51.2500), ("شریعتی", 35.7600, 51.4400), ("سهروردی", 35.7300, 51.4300),
("آزادی", 35.7000, 51.3600), ("جمهوری", 35.6960, 51.3920), ("هفت تیر", 35.7250, 51.4230),
("ولیعصر پایین", 35.7100, 51.4070), ("نواب", 35.6850, 51.3750), ("سعدی", 35.6900, 51.4250),
};
// Built once: normalized name → center. Insertion order kept for the substring pass.
private static readonly List<(string Key, double Lat, double Lng)> Map =
Raw.Select(x => (Norm(x.Name), x.Lat, x.Lng)).ToList();
/// <summary>First of the given names that maps to a known Tehran neighborhood center (exact, then
/// substring — «میدان ونک» → «ونک»). Returns null when nothing matches.</summary>
public static (double lat, double lng)? Locate(params string?[] names)
{
foreach (var raw in names)
{
if (string.IsNullOrWhiteSpace(raw)) continue;
var n = Norm(raw);
foreach (var m in Map) if (m.Key == n) return (m.Lat, m.Lng); // exact
foreach (var m in Map) if (n.Contains(m.Key)) return (m.Lat, m.Lng); // «… ونک …»
}
return null;
}
private static string Norm(string s) => Regex.Replace(
s.Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim(), @"\s+", " ").ToLowerInvariant();
}
@@ -33,21 +33,28 @@ public class TelegramListingSource : IListingSource
try
{
var html = await client.GetStringAsync($"https://t.me/s/{ch}", ct);
foreach (var text in ExtractMessages(html).Take(20))
items.Add(new ScrapedItem($"تلگرام/{ch}", text, $"https://t.me/{ch}"));
foreach (var (text, postedAt) in ExtractMessages(html).Take(20))
items.Add(new ScrapedItem($"تلگرام/{ch}", text, $"https://t.me/{ch}", PostedAt: postedAt));
}
catch (Exception ex) { _log.LogWarning(ex, "Telegram fetch failed for {Channel}", ch); }
}
return items;
}
private static IEnumerable<string> ExtractMessages(string html)
private static IEnumerable<(string text, DateTime? postedAt)> ExtractMessages(string html)
{
foreach (Match m in Regex.Matches(html,
"<div class=\"tgme_widget_message_text[^\"]*\"[^>]*>(.*?)</div>", RegexOptions.Singleline))
{
var text = HtmlUtil.ToPlainText(m.Groups[1].Value);
if (text.Length >= 15) yield return text;
if (text.Length < 15) continue;
// The message's date link (<time datetime="…">) follows its text in the same bubble —
// grab the nearest one after this match.
DateTime? postedAt = null;
var tail = html.Substring(m.Index + m.Length, Math.Min(2000, html.Length - (m.Index + m.Length)));
var dm = Regex.Match(tail, "datetime=\"([^\"]+)\"");
if (dm.Success && DateTimeOffset.TryParse(dm.Groups[1].Value, out var dto)) postedAt = dto.UtcDateTime;
yield return (text, postedAt);
}
}
}
@@ -63,6 +70,30 @@ internal static class HtmlUtil
return s.Trim();
}
/// <summary>Best-effort age (in days) of a post from a Persian "time ago" phrase in its text
/// («دیروز»، «۳ روز پیش»، «هفته پیش»، «۲ هفته پیش»، «ماه پیش»…). Divar embeds this in the row
/// text, so we can age-filter it without a real timestamp. Now/minutes/hours → 0; null when no
/// such phrase is present (caller then treats age as unknown).</summary>
public static int? AgeDaysFromPersianText(string? text)
{
if (string.IsNullOrEmpty(text)) return null;
var t = ToLatinDigits(text);
if (Regex.IsMatch(t, "لحظات|هم[‌ ]?اکنون|چند لحظه|دقیقه پیش|دقایقی پیش|ساعت پیش|ساعتی پیش")) return 0;
if (t.Contains("پریروز")) return 2;
if (t.Contains("دیروز")) return 1;
var m = Regex.Match(t, @"(\d+)\s*(روز|هفته|ماه|سال)\s*پیش");
if (m.Success)
{
var n = int.Parse(m.Groups[1].Value);
return m.Groups[2].Value switch
{ "روز" => n, "هفته" => n * 7, "ماه" => n * 30, "سال" => n * 365, _ => (int?)null };
}
if (Regex.IsMatch(t, @"هفته\s*پیش")) return 7; // bare «هفته پیش» = ۱ هفته
if (Regex.IsMatch(t, @"ماه\s*پیش")) return 30;
if (Regex.IsMatch(t, @"سال\s*پیش") || t.Contains("پارسال")) return 365;
return null;
}
/// <summary>Convert Persian/Arabic-Indic digits to Latin.</summary>
public static string ToLatinDigits(string s)
{
+65
View File
@@ -16,6 +16,12 @@ public static class SeoJsonLd
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>Whether a facility is a REAL named employer (not the «نامشخص» placeholder used for
/// aggregated ads with no named center). Google for Jobs rejects a JobPosting whose
/// hiringOrganization is empty/placeholder, so callers should skip the JSON-LD when this is false.</summary>
public static bool HasRealEmployer(Facility? f)
=> f is not null && !string.IsNullOrWhiteSpace(f.Name) && !f.Name.Contains("نامشخص") && !f.Name.Contains("ثبت نشده");
public static string ShiftPosting(Shift s, string baseUrl)
{
var typeLabel = s.ShiftType switch
@@ -94,6 +100,26 @@ public static class SeoJsonLd
return Fix(JsonSerializer.Serialize(obj, Opts));
}
/// <summary>schema.org structured data for a facility page — a Hospital/MedicalClinic with its
/// address, map coordinates, and aggregate review rating, so Google can show a rich place result.</summary>
public static string MedicalOrganization(Facility f, string baseUrl, double avgRating = 0, int ratingCount = 0)
{
var schemaType = f.Type == FacilityType.Hospital ? "Hospital" : "MedicalClinic";
var obj = new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = schemaType,
["name"] = f.Name,
["url"] = $"{baseUrl}/Facilities/Details/{f.Id}",
["address"] = new { type = "PostalAddress", addressLocality = f.City?.Name, addressCountry = "IR", streetAddress = f.Address },
};
if (f.Lat is double la && f.Lng is double lo)
obj["geo"] = new { type = "GeoCoordinates", latitude = la, longitude = lo };
if (ratingCount > 0)
obj["aggregateRating"] = new { type = "AggregateRating", ratingValue = Math.Round(avgRating, 1), reviewCount = ratingCount };
return Fix(JsonSerializer.Serialize(obj, Opts));
}
public static string Organization(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
@@ -119,9 +145,48 @@ public static class SeoJsonLd
},
}, Opts));
/// <summary>BreadcrumbList JSON-LD from an ordered crumb trail (relative URLs are made absolute).
/// Google can then show the breadcrumb path in search results.</summary>
public static string Breadcrumb(IReadOnlyList<Crumb> items, string baseUrl)
{
var els = new List<object>();
for (var i = 0; i < items.Count; i++)
{
var el = new Dictionary<string, object?> { ["type"] = "ListItem", ["position"] = i + 1, ["name"] = items[i].Name };
if (!string.IsNullOrEmpty(items[i].Url))
el["item"] = items[i].Url!.StartsWith("http") ? items[i].Url : baseUrl + items[i].Url;
els.Add(el);
}
return Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "BreadcrumbList",
["itemListElement"] = els,
}, Opts));
}
/// <summary>ItemList JSON-LD for a results/landing page — an ordered list of the listing URLs,
/// so Google understands it as a curated collection. Relative URLs are made absolute.</summary>
public static string ItemList(IEnumerable<string> urls, string baseUrl)
{
var els = urls.Select((u, i) => (object)new Dictionary<string, object?>
{
["type"] = "ListItem", ["position"] = i + 1, ["url"] = u.StartsWith("http") ? u : baseUrl + u,
}).ToList();
return Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "ItemList",
["itemListElement"] = els,
}, Opts));
}
// Nested anonymous objects use "type"/"queryyy" placeholders for @type / query-input;
// restore the @-prefixed schema.org keys here.
private static string Fix(string json) => json
.Replace("\"type\":", "\"@type\":")
.Replace("\"queryyy\":", "\"query-input\":");
}
/// <summary>One step in a breadcrumb trail. <see cref="Url"/> is null for the current (last) page.</summary>
public record Crumb(string Name, string? Url = null);
+22
View File
@@ -0,0 +1,22 @@
using System.Text.RegularExpressions;
namespace JobsMedical.Web.Services;
/// <summary>
/// Pretty-URL slugs for SEO landing pages (e.g. /استخدام/پزشک-عمومی/تهران). We keep Persian
/// characters — Google indexes UTF-8 URLs fine and they read naturally — and just turn spaces into
/// hyphens. Matching is tolerant of ي/ك, ZWNJ and hyphen/space variants so a hand-typed or
/// search-engine-rewritten slug still resolves.
/// </summary>
public static class SeoSlug
{
/// <summary>The canonical slug for a role/city name («پزشک عمومی» → «پزشک-عمومی»).</summary>
public static string Of(string? name) => Key(name);
/// <summary>True when <paramref name="slug"/> (from the URL) refers to <paramref name="name"/>.</summary>
public static bool Matches(string? name, string? slug) => Key(name) == Key(slug);
private static string Key(string? s) => Regex.Replace(
(s ?? "").Trim().Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Replace('-', ' '),
@"\s+", "-").ToLowerInvariant();
}
+76 -9
View File
@@ -71,6 +71,26 @@ a { color: inherit; text-decoration: none; }
content: ""; position: absolute; inset-inline: 0; bottom: -6px; height: 2px;
background: var(--accent); border-radius: 2px;
}
/* «بیشتر» nav dropdown (native <details>) — secondary browse links (facilities, calendar). */
.nav-more { position: relative; }
.nav-more > summary { list-style: none; cursor: pointer; color: var(--muted); font-weight: 600; font-size: 15px; white-space: nowrap; padding: 4px 0; user-select: none; }
.nav-more > summary::-webkit-details-marker, .nav-more > summary::marker { display: none; content: ""; }
.nav-more > summary:hover, .nav-more[open] > summary, .nav-more > summary.active { color: var(--primary-dark); }
.nav-more-menu { position: absolute; top: 200%; inset-inline-end: 0; background: #fff; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 30px rgba(0,0,0,.12); padding: 6px; min-width: 180px; z-index: 60; display: flex; flex-direction: column; }
.nav-more-menu a { color: var(--text); font-weight: 600; font-size: 14px; padding: 9px 12px; border-radius: 8px; white-space: nowrap; }
.nav-more-menu a:hover, .nav-more-menu a.active { background: var(--primary-soft); color: var(--primary-dark); }
/* Pagination */
.pager { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 6px; margin: 26px 0 4px; }
.pager-btn, .pager-num {
display: inline-flex; align-items: center; justify-content: center; min-width: 38px; height: 38px;
padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: #fff;
color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; transition: all .15s;
}
.pager-btn:hover, .pager-num:hover { border-color: var(--primary); color: var(--primary-dark); }
.pager-num.active { background: var(--primary); border-color: var(--primary); color: #fff; cursor: default; }
.pager-gap { padding: 0 2px; color: var(--muted); }
.cta-post { white-space: nowrap; box-shadow: 0 2px 8px rgba(240,132,62,.35); }
.header-actions { display: flex; align-items: center; gap: 12px; margin-inline-start: auto; }
.nav-action { font-weight: 600; font-size: 15px; color: var(--muted); white-space: nowrap; transition: color .15s; }
@@ -208,8 +228,8 @@ button, input, select, textarea, optgroup { font-family: inherit; }
background: linear-gradient(135deg, #0e8f8a 0%, #0a6f6b 100%);
color: #fff; padding: clamp(40px, 8vw, 64px) 0 clamp(48px, 9vw, 80px); text-align: center;
}
.hero h1 { font-size: clamp(23px, 5.5vw, 34px); font-weight: 900; margin: 0 0 14px; line-height: 1.45; }
.hero p { font-size: clamp(15px, 3.2vw, 17px); opacity: .92; max-width: 620px; margin: 0 auto 30px; }
.hero h1 { font-size: clamp(19px, 5.2vw, 34px); font-weight: 900; margin: 0 0 12px; line-height: 1.4; }
.hero p { font-size: clamp(13px, 3vw, 17px); opacity: .92; max-width: 620px; margin: 0 auto 28px; line-height: 1.7; }
/* search box on hero */
.search-card {
@@ -305,27 +325,52 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
/* Homepage hero — search-engine box (replaces the old filter form) */
.hero-search { position: relative; max-width: 720px; margin: 10px auto 0; }
.hero-search-pill { display: flex; align-items: center; gap: 8px; background: var(--surface);
.hero-search-pill { position: relative; display: flex; align-items: center; gap: 8px; background: var(--surface);
border-radius: 16px; padding: 8px; box-shadow: 0 18px 44px rgba(0,0,0,.20); }
.hero-search-pill .hs-ico { font-size: 18px; opacity: .55; flex: 0 0 auto; padding-inline-start: 10px; }
.hero-search-pill input { flex: 1; min-width: 0; border: none; background: transparent; font-family: inherit;
font-size: 16px; padding: 10px 4px; color: var(--ink); }
.hero-search-pill input:focus { outline: none; }
.hero-search-pill .btn { flex: 0 0 auto; }
.hero-search .nav-search-results { inset-inline: 0; min-width: 0; top: calc(100% + 8px); text-align: start; }
.hs-submit .hs-submit-ico { display: none; } /* desktop shows the «جستجو» label; icon is mobile-only */
/* Dropdown anchors to the pill (its positioned ancestor) → sits directly under the input,
full pill width (override the header dropdown's min/max-width caps). */
.hero-search .nav-search-results { inset-inline: 0; min-width: 0; max-width: none; top: calc(100% + 8px); text-align: start; }
.hero-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; margin-top: 14px; font-size: 13px; }
.hero-chips .hc-label { color: rgba(255,255,255,.85); }
.hero-chips a { background: rgba(255,255,255,.16); color: #fff; padding: 5px 13px; border-radius: 999px; font-weight: 600; transition: background .15s; }
.hero-chips a:hover { background: rgba(255,255,255,.3); }
/* Role quick-links on the list pages — internal links to the SEO landing pages. */
.role-links { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 18px; }
.role-links .rl-label { color: var(--muted); font-size: 13px; font-weight: 700; }
.role-links .rl-chip { background: var(--surface); border: 1px solid var(--line); color: var(--ink);
padding: 5px 12px; border-radius: 999px; font-size: 13px; transition: all .15s; }
.role-links .rl-chip:hover { border-color: var(--primary); color: var(--primary); }
/* Breadcrumb trail (paired with BreadcrumbList JSON-LD). */
.breadcrumbs { font-size: 12.5px; color: var(--muted); margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.breadcrumbs a { color: var(--muted); }
.breadcrumbs a:hover { color: var(--primary); }
.breadcrumbs .bc-sep { opacity: .55; }
.breadcrumbs .bc-current { color: var(--ink); font-weight: 600; }
@media (max-width: 560px) {
/* Smaller, tighter typography on phones */
.hero { padding: 28px 0 32px; }
.hero h1 { font-size: 18px; line-height: 1.55; margin-bottom: 8px; }
.hero p { font-size: 12.5px; line-height: 1.8; margin-bottom: 20px; }
.section-head h2, .page-head h1 { font-size: 18px; }
.hero-search { margin-top: 6px; }
.hero-search-pill { flex-direction: column; align-items: stretch; gap: 8px; padding: 10px; border-radius: 14px; }
/* Stay a single row: text input + a compact magnify button sitting inside the pill. */
.hero-search-pill { gap: 6px; padding: 6px; border-radius: 12px; }
.hero-search-pill .hs-ico { display: none; }
.hero-search-pill input { width: 100%; border: 1px solid var(--line); border-radius: 10px;
padding: 12px 14px; font-size: 15px; background: var(--bg); }
.hero-search-pill .btn { width: 100%; }
.hero-chips { gap: 6px; }
.hero-search-pill input { padding: 10px 10px; font-size: 14px; }
.hero-search-pill .btn.hs-submit { width: 44px; min-width: 44px; height: 44px; padding: 0;
border-radius: 10px; font-size: 18px; justify-content: center; }
.hs-submit .hs-submit-txt { display: none; }
.hs-submit .hs-submit-ico { display: inline; }
.hero-chips { gap: 6px; font-size: 12px; }
.hero-chips .hc-label { flex-basis: 100%; text-align: center; margin-bottom: 2px; }
.stat-pill .n { font-size: 18px; }
.stat-pill .l { font-size: 11px; }
}
/* Big search box on the /Search page head */
.search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }
@@ -350,6 +395,22 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
.contact-row .btn { flex: 0 0 auto; padding: 6px 14px; }
.badge-gender { background: #f3eefb; color: #6b3fa0; }
/* ---------- Contact modal (lazy-loaded numbers) ---------- */
.contact-modal { position: fixed; inset: 0; z-index: 200; display: none; align-items: center;
justify-content: center; padding: 16px; background: rgba(15,23,42,.55); }
.contact-modal.show { display: flex; animation: revealIn .2s ease; }
.contact-modal-box { background: var(--surface); border-radius: 16px; width: 100%; max-width: 420px;
box-shadow: 0 24px 60px rgba(0,0,0,.3); overflow: hidden; animation: revealIn .25s cubic-bezier(.2,.7,.3,1); }
.contact-modal-head { display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--line); }
.contact-modal-head h3 { margin: 0; font-size: 16px; }
.contact-modal-x { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--muted);
line-height: 1; padding: 4px 6px; border-radius: 8px; }
.contact-modal-x:hover { background: var(--bg); color: var(--ink); }
.contact-modal-body { padding: 14px 16px; }
/* The card-level trigger sits inside an <a>; show it as the primary action. */
.contact-trigger { cursor: pointer; }
/* ---------- Filters layout ---------- */
.layout-2 { display: grid; grid-template-columns: 270px 1fr; gap: 24px; align-items: start; }
.filter-card { position: sticky; top: 84px; }
@@ -566,6 +627,12 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
.main-nav a.active { background: var(--primary-soft); border-radius: 8px; }
.main-nav a:last-child { border-bottom: none; }
/* In the burger panel the «بیشتر» menu expands inline (no floating dropdown). */
.nav-more { width: 100%; }
.nav-more > summary { padding: 13px 20px; font-size: 15px; border-bottom: 1px solid var(--line); }
.nav-more-menu { position: static; box-shadow: none; border: none; padding: 0; min-width: 0; }
.nav-more-menu a { padding: 13px 34px; border-bottom: 1px solid var(--line); border-radius: 0; }
.header-actions {
flex-direction: column; align-items: stretch; gap: 0;
margin: 0; padding: 8px 14px 16px; border-top: 1px solid var(--line);