feat: enhance SEO for listing and specialty pages, fix search autocomplete issue
This commit is contained in:
@@ -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 .` برای بهروز نگهداشتن گراف (بعد از کامیت).
|
||||
+1
-1
@@ -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] },
|
||||
};
|
||||
|
||||
+1
-1
@@ -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] },
|
||||
};
|
||||
|
||||
+4
-1
@@ -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])
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -24,7 +24,9 @@ function AutoCompleteSearch({
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={data}
|
||||
getOptionLabel={(option) => 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) => (
|
||||
|
||||
@@ -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(
|
||||
|
||||
+22
-23
@@ -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;
|
||||
|
||||
+22
-13
@@ -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 } }
|
||||
: {};
|
||||
}
|
||||
|
||||
+22
-20
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+18
-6
@@ -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} چقدر است؟`,
|
||||
|
||||
@@ -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("ایران");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user