diff --git a/.claude/prompt/nobat724-search-fix-and-seo.md b/.claude/prompt/nobat724-search-fix-and-seo.md new file mode 100644 index 0000000..c1ae0b5 --- /dev/null +++ b/.claude/prompt/nobat724-search-fix-and-seo.md @@ -0,0 +1,251 @@ +# اصلاحات Nobat724: رفع خطای جستجوی پزشک + بهینه‌سازی SEO صفحات لیست و تخصص + +## پروژه + +`nobat724_front` + +> این پرامپت جایگزین `fix-search-autocomplete-doctor-name.md` است (تسک ۱ همان باگ را پوشش می‌دهد) و چهار تسک SEO را هم اضافه می‌کند. هر تسک مستقل است؛ می‌توانی جدا کامیت کنی. + +## زمینه + +سایت عمومی نوبت‌دهی چند-دامنه‌ای (هر شهر یک دامنه، مثل `yasuj-nobat.ir`، و دامنهٔ ریشه `nobat724.com`). پنج اصلاح لازم است: یک باگ جستجوی UI و چهار مورد بهینه‌سازی SEO روی صفحات لیست پزشکان و صفحات فرود تخصص. + +--- + +## تسک ۱ — رفع خطای `getOptionLabel` در جستجوی نام پزشک + +### مشکل + +در صفحهٔ اصلی، تایپ «دکتر مهدیه» در باکس جستجو این ارور را می‌دهد: + +``` +MUI: The `getOptionLabel` method of Autocomplete returned undefined instead of a string for "دکتر مهدیه". +``` + +علت: `Autocomplete` با `freeSolo` رندر شده و `options={specialtiesData}` (اشیاء تخصص با فیلد `name`). وقتی کاربر متنی تایپ می‌کند که در options نیست، MUI مقدار تایپ‌شده را به‌صورت **رشته** به `getOptionLabel` می‌دهد؛ `"دکتر مهدیه".name` می‌شود `undefined`. دیتای `specialties.json` سالم است (هر ۹۳ آیتم `name` دارند) — منشأ ارور رشتهٔ freeSolo است، نه آیتم بدون name. + +### فایل + +`components/searchHead/smSearch/AutoCompleteSearch.js` + +### وضعیت فعلی + +```jsx +getOptionLabel={(option) => option.name} // خط ۲۷ — روی رشته می‌شکند +... +onChange={(_, newValue) => { + setSelectedOption && setSelectedOption(newValue); + handleSearch(newValue ? newValue.name : ""); // خط ۳۶ — newValue می‌تواند رشته باشد +}} +``` + +### راه‌حل + +هر دو نقطه را برای رشتهٔ freeSolo امن کن؛ همیشه string برگردان: + +```jsx +getOptionLabel={(option) => + typeof option === "string" ? option : option?.name ?? "" +} +... +onChange={(_, newValue) => { + setSelectedOption && setSelectedOption(newValue); + const label = + typeof newValue === "string" ? newValue : newValue?.name ?? ""; + handleSearch(label); +}} +``` + +`renderOption` (خط ۳۹–۴۳) فقط برای options واقعی صدا زده می‌شود؛ دست نزن. + +### تست + +- تایپ «دکتر مهدیه» → بدون ارور؛ کلیک جستجو → هدایت به `/doctors?...name=دکتر مهدیه` (منطق redirect در `components/home/search/RedirectLink.js` از قبل درست است: match نشدن با تخصص → `filters.name`). +- انتخاب یک تخصص از لیست (مثل «داخلی») → `/doctors?...specialty=داخلی`. +- خالی‌کردن ورودی → بدون ارور. + +--- + +## تسک ۲ — `noindex, follow` برای همهٔ صفحات جستجوی `/doctors?...` + +### هدف + +هر `/doctors` که **کوئری‌پارامتر داشته باشد** (`?specialty=`, `?city=`, `?state=`, `?name=`, یا هر ترکیبی) باید `robots: noindex, follow` بگیرد. فقط `/doctors` بدونِ کوئری باید ایندکس شود. دلیل: صفحات جستجو داینامیک و Duplicate/Thin هستند؛ صفحهٔ فرود قابل‌ایندکسِ تخصص باید `/specialties/[slug]` باشد (تسک ۳)، نه `/doctors?specialty=`. + +### فایل + +`lib/listingRobots.js` (توسط `app/doctors/page.js` خط ۲۵ و صفحهٔ `/clinics` مصرف می‌شود). + +### وضعیت فعلی + +```js +const USER_FILTER_KEYS = ["specialty", "gender", "degree", "active", "sort"]; + +export function listingRobots(params, { matchedCity, matchedState } = {}) { + if (params?.name) return { robots: { index: false, follow: true } }; + const activeFilters = USER_FILTER_KEYS.filter((key) => params?.[key]); + if (params?.city && params.city !== matchedCity?.name) activeFilters.push("city"); + if (params?.state && params.state !== matchedState?.name) activeFilters.push("state"); + if (activeFilters.length >= 2) return { robots: { index: false, follow: true } }; + return {}; +} +``` + +منطق فعلی عمداً «تک‌فیلتر specialty» و «city/state تزریق‌شده از دامنه» را ایندکس‌پذیر نگه می‌داشت. طبق خواستهٔ جدید این رفتار باید عوض شود: **هر کوئری‌پارامتر واقعی → noindex**. + +### راه‌حل + +منطق را ساده و سخت‌گیرانه کن: اگر بعد از حذف کلیدهای خالی، هیچ پارامتری نمانده → `{}` (ایندکس)؛ در غیر این صورت → `noindex, follow`. + +```js +// هر جستجوی /doctors با کوئری واقعی noindex,follow می‌شود؛ فقط /doctors خام ایندکس. +export function listingRobots(params) { + const hasQuery = + params && + Object.entries(params).some( + ([, v]) => v !== undefined && v !== null && v !== "" + ); + return hasQuery ? { robots: { index: false, follow: true } } : {}; +} +``` + +### نکتهٔ حیاتی — مرز URL vs. فیلترِ سرور + +در `app/doctors/page.js` روی دامنهٔ شهری، `city`/`state` **در URL نیستند**؛ سرور آن‌ها را فقط داخل fetch تزریق می‌کند (`newSearchParams`)، ولی `generateMetadata` تابع را با `awaitedParams = await searchParams` (پارامترهای واقعی URL) صدا می‌زند. پس `/doctors` خام روی `yasuj-nobat.ir` همچنان بدون کوئری و **ایندکس‌پذیر** می‌ماند. ✅ قبل از پیاده‌سازی این را در همان فایل تأیید کن (خط ۹–۲۵) که `listingRobots` با پارامترهای خام URL صدا زده می‌شود، نه با `newSearchParams`. + +بعد از تغییر امضا (حذف آرگومان دوم)، فراخوان در `app/doctors/page.js` و هر مصرف‌کنندهٔ دیگر (`/clinics`) را هم‌راستا کن — `grep -rn "listingRobots" app` بگیر و همه را بررسی کن. + +--- + +## تسک ۳ — قابل‌ایندکس‌بودن `/specialties` و صفحات تخصص + +### وضعیت فعلی (اکثراً درست) + +- `app/specialties/page.js` — هیچ override روباتی ندارد → ایندکس‌پذیر. ✅ نیازی به تغییر نیست. +- `app/specialties/[slug]/page.js` — فقط وقتی `total === 0` است `noindex,follow` می‌دهد (thin content)، وگرنه ایندکس. ✅ + +فقط قانون تسک ۴ باید به `[slug]` اضافه شود. کاری اضافه در این تسک لازم نیست جز اطمینان از اینکه بعد از تسک ۲، صفحات `/specialties/[child-slug]` (مثل `endourology`, `gastroenterology`) ایندکس‌پذیر می‌مانند. + +--- + +## تسک ۴ — فقط تخصص‌های **فرزند** ایندکس شوند (والدها noindex) + +### هدف + +تخصص‌های والد (`parent_id === null`، مثل «داخلی») صفحاتشان دسته‌بندی کلی و کم‌محتوا است و با فرزندان در نتایج گوگل رقابت می‌کنند. باید: + +- `/specialties/internal-medicine` (والد، `parent_id: null`) → `noindex, follow` +- `/specialties/gastroenterology` (فرزند، `parent_id: 2`) → `index` + +### فایل + +`app/specialties/[slug]/page.js` — تابع `generateMetadata` (خط ۴۳–۶۹). + +### وضعیت فعلی + +```js +const { total } = await getDoctors(specialty.id, isRoot ? null : matchedCity?.id); + +return { + title, + description, + ...(total === 0 && { robots: { index: false, follow: true } }), + openGraph: { title, description, images: [image] }, + twitter: { card: "summary_large_image", title, description, images: [image] }, +}; +``` + +`specialty` از `findSpecialty(slug)` می‌آید و شیء کامل `specialties.json` است، پس `specialty.parent_id` در دسترس است. + +### راه‌حل + +والد (`parent_id === null`) را هم به شرط noindex اضافه کن. توجه: `parent_id` ممکن است `null` باشد — از مقایسهٔ صریح استفاده کن. + +```js +const isParentCategory = specialty.parent_id == null; // والد یا بدون parent +const noindex = isParentCategory || total === 0; + +return { + title, + description, + ...(noindex && { robots: { index: false, follow: true } }), + openGraph: { title, description, images: [image] }, + twitter: { card: "summary_large_image", title, description, images: [image] }, +}; +``` + +### نکته + +- اگر `sitemap.js` صفحات تخصص را لیست می‌کند، مطمئن شو والدها آنجا هم حذف/فیلتر می‌شوند تا با `noindex` هم‌خوان باشد. `grep -rn "specialties" app/sitemap.js` و در صورت وجود، والدها (`parent_id == null`) را از خروجی sitemap کنار بگذار. + +--- + +## تسک ۵ — اصلاح نگارشی متن SEO سوالات متداول تخصص + برند روی دامنهٔ ریشه + +### فایل + +`lib/specialtyContent.js` — تابع `buildSpecialtyFaq` (خط ۹۸–۱۲۰). مصرف‌کننده: `app/specialties/[slug]/page.js` خط ۸۶. + +### وضعیت فعلی + +```js +export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0) { + return [ + { + question: `چطور از متخصص ${specialtyName} در ${cityName} نوبت بگیرم؟`, + ... +``` + +روی دامنهٔ ریشه `cityName` برابر `"ایران"` است (از `resolveCityDisplayName` → `ROOT_DISPLAY_NAME`)، پس سوال می‌شود: «چطور از متخصص اینترونشنال کاردیولوژی در **ایران** نوبت بگیرم؟». + +### خواسته + +۱. جملهٔ سوال به شکل نگارشیِ روان‌تر تغییر کند: «چطور از … نوبت **بگیرم؟**» → «چطور **می‌توان** از … نوبت **گرفت؟**». +۲. روی دامنهٔ ریشه، به‌جای «ایران» برند «**نوبت724**» استفاده شود (تقویت برند + طبیعی‌تر). روی دامنهٔ شهری همان نام شهر بماند. + +### راه‌حل + +بدون تغییر `ROOT_DISPLAY_NAME` سراسری (که در Titleها استفاده می‌شود و «متخصص X در ایران» آنجا درست است)، فقط داخل FAQ برند را جایگزین کن. یک آرگومان اختیاری `isRoot` به تابع اضافه کن: + +```js +export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0, { isRoot = false } = {}) { + const loc = isRoot ? "نوبت724" : cityName; + return [ + { + question: `چطور می‌توان از متخصص ${specialtyName} در ${loc} نوبت گرفت؟`, + answer: `از فهرست همین صفحه پزشک موردنظرتان را انتخاب کنید، وارد پروفایل او شوید و از تقویم نوبت‌های خالی، تاریخ و ساعت دلخواه را ثبت کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک می‌شود.`, + }, + { + question: `چند پزشک ${specialtyName} در ${loc} در دسترس است؟`, + answer: + doctorCount > 0 + ? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${loc} در این سامانه ثبت شده‌اند. این عدد با افزوده‌شدن پزشکان جدید به‌روزرسانی می‌شود.` + : `فهرست پزشکان ${specialtyName} در ${loc} در حال تکمیل است و به‌مرور پزشکان جدید به آن افزوده می‌شوند.`, + }, + // بقیهٔ سوالات (هزینه ویزیت / لغو نوبت) بدون تغییر + ... + ]; +} +``` + +سپس در `app/specialties/[slug]/page.js` خط ۸۶، `isRoot` را پاس بده: + +```js +const faq = buildSpecialtyFaq(specialty.name, cityName, total, { isRoot }); +``` + +(`isRoot` از قبل در همان تابع از `getStateInfo()` گرفته شده — خط ۷۶.) + +### نکات + +- **فقط FAQ** برند را عوض کند؛ `buildSpecialtyIntro` و Titleها دست‌نخورده بمانند (روی ریشه همچنان «در ایران»). این عمدی است تا فقط متن سوالِ موردِ اشارهٔ کاربر اصلاح شود. +- این متن هم در بلوک بصری صفحه و هم در `FAQPage` JSON-LD استفاده می‌شود (منبع مشترک) — پس اصلاح یک‌جا هر دو را می‌پوشاند. + +--- + +## نکات کلی پروژه + +- App Router / Next.js 15؛ همیشه `await params` و `await searchParams`. +- بعد از تغییرِ `listingRobots`، `robots.js`/`sitemap.js` را برای ناسازگاری بررسی کن (صفحات noindex نباید در sitemap باشند). +- RTL فارسی، MUI v5 + Tailwind، فونت Vazir؛ استایل جدید اضافه نکن. +- بعد از اتمام: `graphify update .` برای به‌روز نگه‌داشتن گراف (بعد از کامیت). diff --git a/app/clinics/page.js b/app/clinics/page.js index be0865c..80f13d4 100644 --- a/app/clinics/page.js +++ b/app/clinics/page.js @@ -22,7 +22,7 @@ export async function generateMetadata({ searchParams }) { return { title, description, - ...listingRobots(awaitedParams, { matchedCity, matchedState }), + ...listingRobots(awaitedParams), openGraph: { title, description, images: [image] }, twitter: { card: "summary_large_image", title, description, images: [image] }, }; diff --git a/app/doctors/page.js b/app/doctors/page.js index 7e8e0cf..e88eea7 100644 --- a/app/doctors/page.js +++ b/app/doctors/page.js @@ -22,7 +22,7 @@ export async function generateMetadata({ searchParams }) { return { title, description, - ...listingRobots(awaitedParams, { matchedCity, matchedState }), + ...listingRobots(awaitedParams), openGraph: { title, description, images: [image] }, twitter: { card: "summary_large_image", title, description, images: [image] }, }; diff --git a/app/sitemap.js b/app/sitemap.js index 9d82d9d..0750293 100644 --- a/app/sitemap.js +++ b/app/sitemap.js @@ -6,6 +6,7 @@ import { isRootCity } from '@/lib/rootCity'; import { extractEntityCityId, findDomainByCityId } from '@/lib/domainHelpers'; import { isThinClinic, isThinDoctor } from '@/lib/entityQuality'; import specialtiesData from '@/data/specialties.json'; +import { isIndexableSpecialty } from '@/lib/specialtyIndexing'; // sitemap باید در زمان اجرا (per-request) روی سرور production تولید شود، نه در build. // در build کانتینر به API دسترسی شبکه ندارد → fetch failed. force-dynamic این را قطعی می‌کند. @@ -165,9 +166,11 @@ async function getClinicUrls(baseUrl, scope) { // نمی‌شمارد)، و اگر sitemap از یک منبع و خودِ صفحه از منبع دیگر تصمیم بگیرد، صفحهٔ // ایندکس‌پذیرِ اعلام‌نشده یا برعکس می‌سازیم. این‌طور هیچ فراخوانی اضافه‌ای هم ندارد. function getSpecialtyUrls(baseUrl, doctors) { + // والدها noindex هستند (lib/specialtyIndexing)؛ اعلامشان در sitemap سیگنال + // متناقض به گوگل می‌دهد، پس اصلاً منتشر نمی‌شوند. const slugById = new Map( specialtiesData - .filter((s) => s?.slug && s.status === 1) + .filter((s) => s?.slug && s.status === 1 && isIndexableSpecialty(s)) .map((s) => [String(s.id), s.slug]) ); diff --git a/app/specialties/[slug]/page.js b/app/specialties/[slug]/page.js index 6b641d8..ec893c0 100644 --- a/app/specialties/[slug]/page.js +++ b/app/specialties/[slug]/page.js @@ -7,6 +7,7 @@ import { getStateInfo } from "@/lib/getStateInfo"; import { getRequestOrigin } from "@/lib/getCanonicalUrl"; import { resolveCityDisplayName } from "@/lib/domainHelpers"; import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent"; +import { isCategorySpecialty } from "@/lib/specialtyIndexing"; import { safeJsonLd } from "@/lib/sanitize"; import { fetchReq } from "@/lib/req"; import { isNextRedirectError } from "@/lib/maintenance"; @@ -58,10 +59,14 @@ export async function generateMetadata({ params }) { // داخلی‌اش دنبال شوند. به‌محض افزوده‌شدن اولین پزشک، خودکار ایندکس‌پذیر می‌شود. const { total } = await getDoctors(specialty.id, isRoot ? null : matchedCity?.id); + // دستهٔ سطح‌بالا (دارای زیرشاخهٔ فعال) محتوای مستقلی ندارد و با فرزندانش روی همان + // کوئری رقابت می‌کند؛ صفحهٔ قابل‌ایندکس، همان زیرشاخه است. + const noindex = isCategorySpecialty(specialty) || total === 0; + return { title, description, - ...(total === 0 && { robots: { index: false, follow: true } }), + ...(noindex && { robots: { index: false, follow: true } }), // C1-a — صفحهٔ فرود تخصص per-domain است؛ canonical لایهٔ layout (self) درست است. openGraph: { title, description, images: [image] }, twitter: { card: "summary_large_image", title, description, images: [image] }, @@ -83,7 +88,7 @@ async function SpecialtyDetail({ params }) { ); const intro = buildSpecialtyIntro(specialty.name, cityName, total); - const faq = buildSpecialtyFaq(specialty.name, cityName, total); + const faq = buildSpecialtyFaq(specialty.name, cityName, total, { isRoot }); const pageUrl = `${origin}/specialties/${specialty.slug}`; const pageJsonLd = { diff --git a/components/searchHead/smSearch/AutoCompleteSearch.js b/components/searchHead/smSearch/AutoCompleteSearch.js index 77bae1b..26e5284 100644 --- a/components/searchHead/smSearch/AutoCompleteSearch.js +++ b/components/searchHead/smSearch/AutoCompleteSearch.js @@ -24,7 +24,9 @@ function AutoCompleteSearch({ option.name} + getOptionLabel={(option) => + typeof option === "string" ? option : (option?.name ?? "") + } inputValue={inputValue} className="!w-full" onInputChange={(_, newInputValue) => { @@ -33,7 +35,10 @@ function AutoCompleteSearch({ }} onChange={(_, newValue) => { setSelectedOption && setSelectedOption(newValue); - handleSearch(newValue ? newValue.name : ""); + // freeSolo: مقدار تأییدشده می‌تواند رشتهٔ تایپ‌شده باشد، نه گزینهٔ لیست. + handleSearch( + typeof newValue === "string" ? newValue : (newValue?.name ?? "") + ); }} componentsProps={clearIndicatorProps} renderOption={(props, option) => ( diff --git a/lib/canonicalStrategy.test.js b/lib/canonicalStrategy.test.js index 628ed60..22fcc14 100644 --- a/lib/canonicalStrategy.test.js +++ b/lib/canonicalStrategy.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildCanonicalQuery, + buildFilteredListingCanonical, getEntityCanonical, } from "@/lib/getCanonicalUrl"; @@ -27,6 +28,41 @@ describe("buildCanonicalQuery — تصمیم M3-B", () => { }); }); +describe("buildFilteredListingCanonical — صفحات لیست فیلتردار", () => { + const HOST = "yasuj-nobat.ir"; + + it("صفحهٔ فیلتردار self-canonical می‌شود، نه canonical به صفحهٔ دیگر", () => { + expect( + buildFilteredListingCanonical(HOST, "/doctors", "?specialty=x") + ).toBe("https://yasuj-nobat.ir/doctors?specialty=x"); + }); + + it("/clinics هم همین سیاست را دارد", () => { + expect(buildFilteredListingCanonical(HOST, "/clinics", "?name=a")).toBe( + "https://yasuj-nobat.ir/clinics?name=a" + ); + }); + + it("ترتیب متفاوت پارامترها به یک canonical می‌رسد", () => { + const a = buildFilteredListingCanonical(HOST, "/doctors", "?gender=woman&specialty=x"); + const b = buildFilteredListingCanonical(HOST, "/doctors", "?specialty=x&gender=woman"); + expect(a).toBe(b); + }); + + it("page به‌تنهایی فیلتر نیست → مسیر عادی M3-B", () => { + expect(buildFilteredListingCanonical(HOST, "/doctors", "?page=2")).toBeNull(); + }); + + it("لیست بدون کوئری و مسیرهای غیرلیست دخالتی نمی‌گیرند", () => { + expect(buildFilteredListingCanonical(HOST, "/doctors", "")).toBeNull(); + expect(buildFilteredListingCanonical(HOST, "/specialties/x", "?a=1")).toBeNull(); + }); + + it("پارامتر خالی فیلتر حساب نمی‌شود", () => { + expect(buildFilteredListingCanonical(HOST, "/doctors", "?specialty=")).toBeNull(); + }); +}); + describe("getEntityCanonical — C1-b", () => { it("موجودیت به دامنهٔ شهر خودش canonical می‌شود، نه دامنهٔ سرو‌کننده", () => { expect(getEntityCanonical(YASUJ_CITY_ID, "/doctor/abc", "nobat724.com")).toBe( diff --git a/lib/getCanonicalUrl.js b/lib/getCanonicalUrl.js index a8104eb..60d0f66 100644 --- a/lib/getCanonicalUrl.js +++ b/lib/getCanonicalUrl.js @@ -1,6 +1,5 @@ import { headers } from "next/headers"; import { findDomainByCityId } from "@/lib/domainHelpers"; -import specialtiesData from "@/data/specialties.json"; // استراتژی canonical سه‌دسته: // C1-a per-domain : /، /doctors، /clinics، /specialties، /specialties/[slug]، /blogs @@ -62,29 +61,27 @@ export function buildMainCanonicalUrl(pathname) { * تصمیم M3-B: صفحات ۲+ لیست خودشان canonical دارند تا ایندکس‌پذیر بمانند. * (یاسوج به‌تنهایی ۳۸ صفحه دارد؛ collapse به صفحهٔ ۱ همهٔ آن‌ها را از ایندکس حذف می‌کرد.) */ -// /doctors?specialty=X با تک‌فیلتر، همان صفحهٔ فرود تخصص است — اعتبارش آنجا تجمیع شود. -// city/state تزریق‌شده از host اینجا هم «فیلتر کاربر» حساب نمی‌شوند (هم‌راستا با listingRobots). -const HOST_INJECTED_KEYS = new Set(["city", "state", "specialty", "page"]); +const LISTING_PATHS = new Set(["/doctors", "/clinics"]); -export function buildSpecialtyCanonicalPath(pathname, search) { - if (buildCanonicalPath(pathname) !== "/doctors" || !search) return null; +/** + * صفحات لیستِ فیلتردار طبق lib/listingRobots نوایندکس هستند. کنار noindex، canonical + * به یک URL دیگر سیگنال متناقض است و می‌تواند noindex را به هدف canonical ببرد — + * دقیقاً همان /specialties/[slug] یا /doctors خامی که باید ایندکس بمانند. + * پس این صفحات self-canonical می‌شوند و کل کوئری‌شان حفظ می‌گردد. + * + * پارامترها مرتب می‌شوند تا دو ترتیب متفاوت از یک فیلتر به یک canonical برسند. + */ +export function buildFilteredListingCanonical(host, path, search) { + if (!LISTING_PATHS.has(path) || !search) return null; const params = new URLSearchParams(search); - const specialtyName = params.get("specialty"); - if (!specialtyName) return null; + const filled = [...params.entries()].filter(([, value]) => value !== ""); + const hasFilter = filled.some(([key]) => key !== "page"); + if (!hasFilter) return null; - // صفحهٔ ۲ به بعد محتوای متفاوتی دارد و صفحهٔ فرود تخصص pagination ندارد؛ - // canonical کردن صفحهٔ N به صفحهٔ ۱ خلاف راهنمای گوگل است و یا نادیده گرفته - // می‌شود یا مسیر کشف پزشکان صفحات بعدی را می‌بندد. آن‌ها self-canonical می‌مانند. - if (Number(params.get("page")) > 1) return null; - - const hasOtherFilter = [...params.keys()].some((key) => !HOST_INJECTED_KEYS.has(key)); - if (hasOtherFilter) return null; - - const specialty = specialtiesData.find( - (item) => item.name === specialtyName && item.status === 1 - ); - return specialty?.slug ? `/specialties/${specialty.slug}` : null; + const normalized = new URLSearchParams(filled); + normalized.sort(); + return `${buildCanonicalUrl(host, path)}?${normalized.toString()}`; } export function buildCanonicalQuery(search) { @@ -140,11 +137,13 @@ export async function getCanonicalUrl() { if (STATIC_SHARED_PAGES.has(path)) return buildMainCanonicalUrl(path); const search = headersList.get("x-search"); - const specialtyPath = buildSpecialtyCanonicalPath(path, search); - if (specialtyPath) return buildCanonicalUrl(headersList.get("host"), specialtyPath); + const host = headersList.get("host"); + + const filteredListing = buildFilteredListingCanonical(host, path, search); + if (filteredListing) return filteredListing; const query = buildCanonicalQuery(search); - return `${buildCanonicalUrl(headersList.get("host"), path)}${query}`; + return `${buildCanonicalUrl(host, path)}${query}`; } catch (error) { console.error("Error generating canonical URL:", error); return null; diff --git a/lib/listingRobots.js b/lib/listingRobots.js index f9262d9..9428599 100644 --- a/lib/listingRobots.js +++ b/lib/listingRobots.js @@ -1,20 +1,29 @@ // سیاست ایندکس صفحات لیست (/doctors و /clinics) — یک پیاده‌سازی مشترک تا دو مسیر واگرا نشوند. // -// جستجوی داخلی (name) و ترکیب چند فیلتر واقعی نباید ایندکس شوند (crawl budget و duplicate). -// اما city/state روی دامنه‌های شهری خودکار از host تزریق می‌شوند و «فیلتر انتخابی کاربر» -// نیستند؛ شمردنشان باعث می‌شد صفحهٔ اصلی لیست روی هر ۳۵ دامنه همیشه noindex شود. +// هر کوئری جستجو/فیلتر یک صفحهٔ داینامیک با محتوای تکراری می‌سازد و نباید ایندکس شود. +// صفحهٔ فرود قابل‌ایندکسِ یک تخصص، /specialties/[slug] است نه /doctors?specialty=. +// پس فقط لیست خام (/doctors و /clinics) ایندکس می‌شود. +// +// page استثناست: pagination فیلتر جستجو نیست و صفحات ۲+ محتوای واقعاً متفاوتی دارند +// (یاسوج به‌تنهایی ~۳۸ صفحه)؛ noindex کردنشان مسیر کشف پزشکان آن صفحات را می‌بندد. +// +// follow در همهٔ حالت‌ها می‌ماند تا لینک‌های داخلی صفحه (پروفایل پزشک، صفحهٔ تخصص) دنبال شوند. -const USER_FILTER_KEYS = ["specialty", "gender", "degree", "active", "sort"]; +const NON_FILTER_KEYS = new Set(["page"]); -export function listingRobots(params, { matchedCity, matchedState } = {}) { - if (params?.name) return { robots: { index: false, follow: true } }; +const isEmpty = (value) => + value === undefined || value === null || value === ""; - const activeFilters = USER_FILTER_KEYS.filter((key) => params?.[key]); +/** آیا این صفحهٔ لیست، فیلتر یا جستجوی واقعی کاربر دارد؟ */ +export function hasListingFilters(params) { + if (!params) return false; + return Object.entries(params).some( + ([key, value]) => !NON_FILTER_KEYS.has(key) && !isEmpty(value) + ); +} - // فقط city/state متفاوت از شهر/استانِ همین دامنه، انتخاب صریح کاربر است. - if (params?.city && params.city !== matchedCity?.name) activeFilters.push("city"); - if (params?.state && params.state !== matchedState?.name) activeFilters.push("state"); - - if (activeFilters.length >= 2) return { robots: { index: false, follow: true } }; - return {}; +export function listingRobots(params) { + return hasListingFilters(params) + ? { robots: { index: false, follow: true } } + : {}; } diff --git a/lib/listingRobots.test.js b/lib/listingRobots.test.js index f1cb9e0..72bf5ed 100644 --- a/lib/listingRobots.test.js +++ b/lib/listingRobots.test.js @@ -1,41 +1,43 @@ import { describe, expect, it } from "vitest"; import { listingRobots } from "@/lib/listingRobots"; -const YASUJ = { name: "یاسوج" }; -const KOHGILUYEH = { name: "کهگیلویه و بویراحمد" }; -const scope = { matchedCity: YASUJ, matchedState: KOHGILUYEH }; - const isNoindex = (result) => result?.robots?.index === false; describe("listingRobots", () => { it("صفحهٔ لیست بدون query ایندکس‌پذیر است", () => { - expect(isNoindex(listingRobots({}, scope))).toBe(false); + expect(isNoindex(listingRobots({}))).toBe(false); + expect(isNoindex(listingRobots(undefined))).toBe(false); }); - it("city/state تزریق‌شده از host شمرده نمی‌شوند (باگ noindex سیستمیک)", () => { - const injected = { city: "یاسوج", state: "کهگیلویه و بویراحمد" }; - expect(isNoindex(listingRobots(injected, scope))).toBe(false); + it("pagination ایندکس‌پذیر می‌ماند (تصمیم M3-B)", () => { + expect(isNoindex(listingRobots({ page: "2" }))).toBe(false); + expect(isNoindex(listingRobots({ page: "38" }))).toBe(false); }); - it("تک‌فیلتر تخصص ایندکس‌پذیر می‌ماند", () => { - expect(isNoindex(listingRobots({ specialty: "پوست و مو" }, scope))).toBe(false); + it("هر فیلتر جستجو noindex می‌گیرد", () => { + expect(isNoindex(listingRobots({ specialty: "پوست و مو" }))).toBe(true); + expect(isNoindex(listingRobots({ name: "احمدی" }))).toBe(true); + expect(isNoindex(listingRobots({ gender: "woman" }))).toBe(true); + expect(isNoindex(listingRobots({ sort: "point" }))).toBe(true); }); - it("جستجوی نام همیشه noindex است", () => { - expect(isNoindex(listingRobots({ name: "احمدی" }, scope))).toBe(true); + it("city/state حتی وقتی از لینک دامنهٔ خودی می‌آیند noindex می‌گیرند", () => { + const fromHomeSearch = { city: "یاسوج", state: "کهگیلویه و بویراحمد" }; + expect(isNoindex(listingRobots(fromHomeSearch))).toBe(true); }); - it("دو فیلتر واقعی کاربر noindex می‌گیرد", () => { - expect(isNoindex(listingRobots({ specialty: "پوست و مو", gender: "woman" }, scope))).toBe(true); + it("فیلتر کنار page هم noindex می‌گیرد", () => { + expect(isNoindex(listingRobots({ specialty: "پوست و مو", page: "3" }))).toBe(true); }); - it("city متفاوت از دامنه یک فیلتر واقعی است", () => { - const params = { city: "تهران", specialty: "پوست و مو" }; - expect(isNoindex(listingRobots(params, scope))).toBe(true); + it("پارامتر با مقدار خالی فیلتر حساب نمی‌شود", () => { + expect(isNoindex(listingRobots({ specialty: "", name: undefined }))).toBe(false); }); - it("روی دامنهٔ ریشه (بدون شهر) city صریح کاربر شمرده می‌شود", () => { - const params = { city: "یاسوج", specialty: "پوست و مو" }; - expect(isNoindex(listingRobots(params, {}))).toBe(true); + it("noindex همیشه با follow همراه است", () => { + expect(listingRobots({ name: "احمدی" }).robots).toEqual({ + index: false, + follow: true, + }); }); }); diff --git a/lib/specialtyContent.js b/lib/specialtyContent.js index e95f684..727ee92 100644 --- a/lib/specialtyContent.js +++ b/lib/specialtyContent.js @@ -94,19 +94,31 @@ export function buildDoctorFaq(doctorName, specialtyName, cityName) { ]; } -/** سوالات متداول صفحهٔ تخصص — منبع مشترک بلوک بصری و FAQPage schema. */ -export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0) { +/** + * سوالات متداول صفحهٔ تخصص — منبع مشترک بلوک بصری و FAQPage schema. + * + * روی دامنهٔ ریشه، cityName برابر «ایران» است؛ در جملهٔ سوال، نام برند طبیعی‌تر + * می‌نشیند («… در نوبت724 نوبت گرفت؟»). Title و متن مقدمه عمداً همان «ایران» را + * نگه می‌دارند، چون آنجا اشاره به گسترهٔ جغرافیایی است نه محل رزرو. + */ +export function buildSpecialtyFaq( + specialtyName, + cityName, + doctorCount = 0, + { isRoot = false } = {} +) { + const place = isRoot ? "نوبت724" : cityName; return [ { - question: `چطور از متخصص ${specialtyName} در ${cityName} نوبت بگیرم؟`, + question: `چطور می‌توان از متخصص ${specialtyName} در ${place} نوبت گرفت؟`, answer: `از فهرست همین صفحه پزشک موردنظرتان را انتخاب کنید، وارد پروفایل او شوید و از تقویم نوبت‌های خالی، تاریخ و ساعت دلخواه را ثبت کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک می‌شود.`, }, { - question: `چند پزشک ${specialtyName} در ${cityName} در دسترس است؟`, + question: `چند پزشک ${specialtyName} در ${place} در دسترس است؟`, answer: doctorCount > 0 - ? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${cityName} در این سامانه ثبت شده‌اند. این عدد با افزوده‌شدن پزشکان جدید به‌روزرسانی می‌شود.` - : `فهرست پزشکان ${specialtyName} در ${cityName} در حال تکمیل است و به‌مرور پزشکان جدید به آن افزوده می‌شوند.`, + ? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${place} در این سامانه ثبت شده‌اند. این عدد با افزوده‌شدن پزشکان جدید به‌روزرسانی می‌شود.` + : `فهرست پزشکان ${specialtyName} در ${place} در حال تکمیل است و به‌مرور پزشکان جدید به آن افزوده می‌شوند.`, }, { question: `هزینهٔ ویزیت متخصص ${specialtyName} چقدر است؟`, diff --git a/lib/specialtyContent.test.js b/lib/specialtyContent.test.js index a3d37bd..e6efa20 100644 --- a/lib/specialtyContent.test.js +++ b/lib/specialtyContent.test.js @@ -60,4 +60,19 @@ describe("سوالات متداول تخصص (M1)", () => { expect(withCount).not.toBe(without); expect(withCount).toContain("9"); }); + + it("روی دامنهٔ شهری نام شهر در سوال می‌نشیند", () => { + const question = buildSpecialtyFaq("اندویورولوژی", "یاسوج", 3)[0].question; + expect(question).toBe("چطور می‌توان از متخصص اندویورولوژی در یاسوج نوبت گرفت؟"); + }); + + it("روی دامنهٔ ریشه به‌جای «ایران» برند می‌نشیند", () => { + const question = buildSpecialtyFaq("اینترونشنال کاردیولوژی", "ایران", 3, { + isRoot: true, + })[0].question; + expect(question).toBe( + "چطور می‌توان از متخصص اینترونشنال کاردیولوژی در نوبت724 نوبت گرفت؟" + ); + expect(question).not.toContain("ایران"); + }); }); diff --git a/lib/specialtyIndexing.js b/lib/specialtyIndexing.js new file mode 100644 index 0000000..aa88de3 --- /dev/null +++ b/lib/specialtyIndexing.js @@ -0,0 +1,27 @@ +// سیاست ایندکس صفحات فرود تخصص — منبع مشترک صفحه و sitemap تا واگرا نشوند. +// +// تخصصی که زیرشاخهٔ فعال دارد (مثل «داخلی» با فرزند «گوارش و کبد») یک دستهٔ سطح‌بالاست: +// محتوای مستقلی ندارد و با فرزندانش روی همان کوئری رقابت می‌کند → noindex. +// +// اما «والد بودن» به‌تنهایی کافی نیست: تخصص‌هایی مثل «پزشک عمومی» هم parent_id تهی +// دارند و هم هیچ زیرشاخه‌ای ندارند. آن‌ها در عمل برگ‌اند و صفحهٔ واقعی خدمات و +// پزشکان را نشان می‌دهند، پس ایندکس می‌مانند. + +import specialtiesData from "@/data/specialties.json"; + +// فقط فرزندان فعال شمرده می‌شوند؛ زیرشاخهٔ غیرفعال صفحه‌ای ندارد که رقابت کند. +const IDS_WITH_ACTIVE_CHILDREN = new Set( + specialtiesData + .filter((item) => item?.status === 1 && item?.parent_id != null) + .map((item) => Number(item.parent_id)) +); + +/** دستهٔ سطح‌بالا: بدون والد، ولی دارای زیرشاخهٔ فعال. */ +export function isCategorySpecialty(specialty) { + if (!specialty || specialty.parent_id != null) return false; + return IDS_WITH_ACTIVE_CHILDREN.has(Number(specialty.id)); +} + +export function isIndexableSpecialty(specialty) { + return Boolean(specialty) && !isCategorySpecialty(specialty); +} diff --git a/lib/specialtyIndexing.test.js b/lib/specialtyIndexing.test.js new file mode 100644 index 0000000..4840ee1 --- /dev/null +++ b/lib/specialtyIndexing.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import specialtiesData from "@/data/specialties.json"; +import { isCategorySpecialty, isIndexableSpecialty } from "@/lib/specialtyIndexing"; + +const bySlug = (slug) => specialtiesData.find((item) => item.slug === slug); + +describe("isCategorySpecialty", () => { + it("والدِ دارای زیرشاخهٔ فعال دسته است → noindex", () => { + expect(isCategorySpecialty(bySlug("internal-medicine"))).toBe(true); + }); + + it("زیرشاخه هرگز دسته نیست → index", () => { + expect(isCategorySpecialty(bySlug("gastroenterology"))).toBe(false); + expect(isIndexableSpecialty(bySlug("gastroenterology"))).toBe(true); + }); + + it("والدِ بدون زیرشاخه در عمل برگ است → index", () => { + expect(isCategorySpecialty(bySlug("general-practitioner"))).toBe(false); + expect(isIndexableSpecialty(bySlug("general-practitioner"))).toBe(true); + }); + + it("ورودی تهی امن است", () => { + expect(isCategorySpecialty(null)).toBe(false); + expect(isIndexableSpecialty(null)).toBe(false); + expect(isIndexableSpecialty(undefined)).toBe(false); + }); +});