feat: enhance SEO for listing and specialty pages, fix search autocomplete issue

This commit is contained in:
hamed
2026-07-20 10:26:03 +03:30
parent 9011b230b3
commit 30c3fe8637
14 changed files with 460 additions and 69 deletions
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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} چقدر است؟`,
+15
View File
@@ -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("ایران");
});
});
+27
View File
@@ -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);
}
+27
View File
@@ -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);
});
});