feat: add canonical URL handling and entity quality checks

- Implemented canonical URL strategies for city-specific domains and entities.
- Added helper functions for domain and city resolution.
- Created tests for canonical URL generation and domain resolution.
- Introduced entity quality checks for doctors and clinics to ensure meaningful content.
- Developed unique introductory texts for listing pages to avoid duplicate content.
- Established robots.txt policies for listing pages to manage indexing based on user filters.
- Enhanced specialty content with dynamic introductions and FAQs to improve SEO.
This commit is contained in:
hamed
2026-07-19 07:52:50 +03:30
parent 36816eded2
commit fd48b48613
48 changed files with 3951 additions and 733 deletions
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
buildCanonicalQuery,
getEntityCanonical,
} from "@/lib/getCanonicalUrl";
const YASUJ_CITY_ID = 123;
const ROOT_CITY_ID = 600;
describe("buildCanonicalQuery — تصمیم M3-B", () => {
it("صفحهٔ ۲+ در canonical می‌ماند تا ایندکس‌پذیر بماند", () => {
expect(buildCanonicalQuery("?page=2")).toBe("?page=2");
expect(buildCanonicalQuery("?page=38")).toBe("?page=38");
});
it("صفحهٔ ۱ حذف می‌شود (URL متعارف بدون page)", () => {
expect(buildCanonicalQuery("?page=1")).toBe("");
expect(buildCanonicalQuery("")).toBe("");
});
it("فیلتر و sort و جستجو از canonical حذف می‌شوند", () => {
expect(buildCanonicalQuery("?specialty=x&gender=woman&sort=point&name=a")).toBe("");
});
it("page در کنار فیلترها فقط page را نگه می‌دارد", () => {
expect(buildCanonicalQuery("?specialty=x&page=3")).toBe("?page=3");
});
});
describe("getEntityCanonical — C1-b", () => {
it("موجودیت به دامنهٔ شهر خودش canonical می‌شود، نه دامنهٔ سرو‌کننده", () => {
expect(getEntityCanonical(YASUJ_CITY_ID, "/doctor/abc", "nobat724.com")).toBe(
"https://yasuj-nobat.ir/doctor/abc"
);
});
it("روی دامنهٔ خودِ شهر self-canonical است", () => {
expect(getEntityCanonical(YASUJ_CITY_ID, "/clinic/abc", "yasuj-nobat.ir")).toBe(
"https://yasuj-nobat.ir/clinic/abc"
);
});
it("شهر بدون دامنه → self-canonical (هرگز canonical شکسته)", () => {
expect(getEntityCanonical(999999, "/doctor/abc", "nobat724.com")).toBe(
"https://nobat724.com/doctor/abc"
);
expect(getEntityCanonical(null, "/doctor/abc", "tehran-nobat.ir")).toBe(
"https://tehran-nobat.ir/doctor/abc"
);
});
it("رکورد ریشه شهر نیست → self-canonical", () => {
expect(getEntityCanonical(ROOT_CITY_ID, "/doctor/abc", "yazd-nobat.ir")).toBe(
"https://yazd-nobat.ir/doctor/abc"
);
});
});
+57
View File
@@ -0,0 +1,57 @@
// هلپرهای مشترک نگاشت «شهر ↔ دامنه» و نام نمایشی شهر.
// این ماژول pure است (بدون next/headers) تا در client، server، sitemap و تست قابل import باشد.
import citiesData from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
const ROOT_DISPLAY_NAME = "ایران";
/**
* دامنهٔ شهریِ متناظر با یک city_id.
* رکورد ریشه (id:600 → nobat724.com) هرگز به‌عنوان «شهر» برنمی‌گردد.
* @returns {string|null} مثلا "yasuj-nobat.ir" یا null
*/
export function findDomainByCityId(cityId) {
if (cityId == null || cityId === "") return null;
const id = Number(cityId);
if (!Number.isFinite(id)) return null;
const city = citiesData.find((c) => Number(c.id) === id);
if (!city || isRootCity(city) || !city.domain) return null;
return city.domain;
}
/** رکورد شهر بر اساس id — رکورد ریشه شهر نیست. */
export function findCityById(cityId) {
if (cityId == null || cityId === "") return null;
const city = citiesData.find((c) => Number(c.id) === Number(cityId));
return city && !isRootCity(city) ? city : null;
}
/**
* نام قابل‌نمایش شهر برای H1/Title.
* روی دامنهٔ ریشه نام رکورد («نوبت 724») یک برند است نه شهر — به‌جای آن «ایران».
*/
export function resolveCityDisplayName(cityInfo) {
if (!cityInfo || isRootCity(cityInfo)) return ROOT_DISPLAY_NAME;
return cityInfo.name || ROOT_DISPLAY_NAME;
}
/**
* city_id یک موجودیت را از شکل‌های مختلف پاسخ API بیرون می‌کشد:
* - کلینیک: city: [{ id }]
* - پزشک: city خالی است و شهر واقعی داخل address[].city.id می‌نشیند
* - بلاگ: city_id مسطح
* اولین شهرِ دارای دامنهٔ اختصاصی برنده است (پزشک چند-شهری → یک شهر اصلی).
*/
export function extractEntityCityId(entity) {
if (!entity) return null;
const candidates = [
entity.city_id,
...(Array.isArray(entity.city) ? entity.city.map((c) => c?.id) : [entity.city?.id]),
...(Array.isArray(entity.address) ? entity.address.map((a) => a?.city?.id) : []),
].filter((id) => id != null);
const withDomain = candidates.find((id) => findDomainByCityId(id));
return withDomain ?? candidates[0] ?? null;
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import citiesData from "@/data/city.json";
import { ROOT_CITY_ID } from "@/lib/rootCity";
import {
extractEntityCityId,
findDomainByCityId,
resolveCityDisplayName,
} from "@/lib/domainHelpers";
const YASUJ = citiesData.find((c) => c.domain === "yasuj-nobat.ir");
const ROOT = citiesData.find((c) => Number(c.id) === ROOT_CITY_ID);
describe("findDomainByCityId", () => {
it("شهر دارای دامنه را به دامنه‌اش نگاشت می‌کند", () => {
expect(findDomainByCityId(YASUJ.id)).toBe("yasuj-nobat.ir");
expect(findDomainByCityId(String(YASUJ.id))).toBe("yasuj-nobat.ir");
});
it("رکورد ریشه شهر نیست", () => {
expect(findDomainByCityId(ROOT_CITY_ID)).toBeNull();
});
it("شهر بدون دامنه یا ورودی نامعتبر → null", () => {
expect(findDomainByCityId(999999)).toBeNull();
expect(findDomainByCityId(null)).toBeNull();
expect(findDomainByCityId("")).toBeNull();
expect(findDomainByCityId("abc")).toBeNull();
});
});
describe("resolveCityDisplayName", () => {
it("رکورد ریشه → ایران، نه نام برند", () => {
expect(resolveCityDisplayName(ROOT)).toBe("ایران");
expect(resolveCityDisplayName(null)).toBe("ایران");
});
it("شهر واقعی → نام خودش", () => {
expect(resolveCityDisplayName(YASUJ)).toBe("یاسوج");
});
});
describe("extractEntityCityId", () => {
it("کلینیک: از آرایهٔ city", () => {
expect(extractEntityCityId({ city: [{ id: String(YASUJ.id) }] })).toBe(String(YASUJ.id));
});
it("پزشک: از address[].city وقتی city خالی است", () => {
const doctor = { city: [], address: [{ city: { id: String(YASUJ.id) } }] };
expect(extractEntityCityId(doctor)).toBe(String(YASUJ.id));
});
it("اولین شهرِ دارای دامنه برنده است", () => {
const doctor = { address: [{ city: { id: 999999 } }, { city: { id: YASUJ.id } }] };
expect(findDomainByCityId(extractEntityCityId(doctor))).toBe("yasuj-nobat.ir");
});
it("بدون شهر → null", () => {
expect(extractEntityCityId({ city: [], address: [] })).toBeNull();
expect(extractEntityCityId(null)).toBeNull();
});
});
+44
View File
@@ -0,0 +1,44 @@
// معیار «محتوای معنادار» برای ایندکس‌پذیری و ورود به sitemap.
// یک منبع مشترک برای صفحات موجودیت و app/sitemap.js تا هرگز واگرا نشوند:
// چیزی که noindex است نباید در sitemap باشد.
const PHONE_LIKE = /^0?9\d{9}$/;
const PLACEHOLDER_NAMES = new Set(["test", "تست", "-", "—"]);
// نام‌های آلوده (شماره‌تلفن، "test") نام واقعی نیستند و صفحه را بی‌ارزش می‌کنند.
function hasRealName(rawName) {
const name = String(rawName || "").trim();
if (!name) return false;
if (PHONE_LIKE.test(name.replace(/[\s-]/g, ""))) return false;
return !PLACEHOLDER_NAMES.has(name.toLowerCase());
}
/**
* پزشک بی‌محتوا. معیار قبلی «owner_status !== claimed» بیش از حد سخت‌گیر بود و
* عملاً کل مارکت‌پلیس را از ایندکس بیرون می‌گذاشت — حالا فقط نبودِ محتوا مهم است.
*/
export function isThinDoctor(doctor) {
if (!doctor?.uuid || !hasRealName(doctor.name)) return true;
const hasSpecialty = Boolean(doctor.specialties?.length);
const hasLocation = Boolean(doctor.address?.length || doctor.city?.length);
return !hasSpecialty && !hasLocation;
}
/**
* کلینیک بی‌محتوا. is_active:false = «موقتاً غیرفعال» تفسیر می‌شود (noindex، نه 410)،
* چون رکورد و نوبت‌های تاریخی‌اش باقی می‌مانند و ممکن است دوباره فعال شود.
*/
export function isThinClinic(clinic) {
if (!clinic?.uuid || !hasRealName(clinic.title || clinic.name)) return true;
if (clinic.is_active === false) return true;
// پاسخ لیست و پاسخ جزئیات فیلدهای متفاوتی دارند — هر سیگنالِ موجود کافی است.
const hasContent =
Boolean(clinic.caption) ||
Boolean(clinic.location) ||
Boolean(clinic.services?.length) ||
Boolean(clinic.specialties?.length) ||
Boolean(clinic.images_clinic?.length) ||
Number(clinic.doctors_count) > 0 ||
Boolean(clinic.doctor_list?.length || clinic.doctors?.length);
return !hasContent;
}
+98 -7
View File
@@ -1,13 +1,30 @@
import { headers } from "next/headers";
import { findDomainByCityId } from "@/lib/domainHelpers";
import specialtiesData from "@/data/specialties.json";
// استراتژی canonical: محتوای همه‌ی دامنه‌های شهری از nobat724.com تغذیه می‌شود —
// پس canonical هر صفحه (روی هر دامنه‌ای) به همان مسیر روی دامنه‌ی اصلی اشاره می‌کند.
// دامنه‌ی اصلی هم self-canonical می‌گیرد.
// توجه: buildCanonicalUrl per-host است و برای JSON-LD/sitemap/robots استفاده می‌شود — به آن دست نزن.
// استراتژی canonical سه‌دسته:
// C1-a per-domain : /، /doctors، /clinics، /specialties، /specialties/[slug]، /blogs
// → self-canonical روی همان دامنهٔ شهری (هر شهر محتوای خودش را دارد)
// C1-b موجودیت : /doctor/[uuid]، /clinic/[uuid]، /blog/[slug]
// → دامنهٔ شهرِ همان موجودیت (getEntityCanonical)
// C1-c استاتیک : /about-us، /contact-us، … → دامنهٔ اصلی
// (محتوا روی همهٔ دامنه‌ها یکسان است — تجمیع عمدی اعتبار)
const DEFAULT_HOST = "nobat724.com";
const MAIN_ORIGIN = "https://nobat724.com";
// C1-c — صفحات با محتوای یکسان روی همهٔ دامنه‌ها
const STATIC_SHARED_PAGES = new Set([
"/about-us",
"/contact-us",
"/terms",
"/privacy",
]);
// تنها queryهایی که صفحه را از نظر محتوایی متمایز می‌کنند و باید در canonical بمانند.
// بقیه (فیلتر چندگانه، sort، name) حذف می‌شوند تا duplicate نسازند.
const CANONICAL_QUERY_KEYS = ["page"];
export function normalizeHost(rawHost) {
return (rawHost || DEFAULT_HOST).toLowerCase().replace(/^www\./, "");
}
@@ -33,7 +50,7 @@ export async function getRequestOrigin() {
const headersList = await headers();
return buildCanonicalUrl(headersList.get("host"), "/").replace(/\/$/, "");
} catch {
return "https://nobat724.com";
return MAIN_ORIGIN;
}
}
@@ -41,14 +58,88 @@ export function buildMainCanonicalUrl(pathname) {
return `${MAIN_ORIGIN}${buildCanonicalPath(pathname)}`;
}
/**
* تصمیم M3-B: صفحات ۲+ لیست خودشان canonical دارند تا ایندکس‌پذیر بمانند.
* (یاسوج به‌تنهایی ۳۸ صفحه دارد؛ collapse به صفحهٔ ۱ همهٔ آن‌ها را از ایندکس حذف می‌کرد.)
*/
// /doctors?specialty=X با تک‌فیلتر، همان صفحهٔ فرود تخصص است — اعتبارش آنجا تجمیع شود.
// city/state تزریق‌شده از host اینجا هم «فیلتر کاربر» حساب نمی‌شوند (هم‌راستا با listingRobots).
const HOST_INJECTED_KEYS = new Set(["city", "state", "specialty", "page"]);
export function buildSpecialtyCanonicalPath(pathname, search) {
if (buildCanonicalPath(pathname) !== "/doctors" || !search) return null;
const params = new URLSearchParams(search);
const specialtyName = params.get("specialty");
if (!specialtyName) 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;
}
export function buildCanonicalQuery(search) {
if (!search) return "";
const params = new URLSearchParams(search);
const kept = new URLSearchParams();
for (const key of CANONICAL_QUERY_KEYS) {
const value = params.get(key);
if (key === "page" && (!value || Number(value) <= 1)) continue;
if (value) kept.set(key, value);
}
const query = kept.toString();
return query ? `?${query}` : "";
}
/**
* canonical یک موجودیت (پزشک/کلینیک/پست بلاگ): دامنهٔ شهرِ خودش.
* شهر بدون دامنهٔ اختصاصی یا موجودیت بدون شهر → self-canonical (هرگز canonical شکسته نده).
*/
export function getEntityCanonical(cityId, pathname, currentHost) {
const cityDomain = findDomainByCityId(cityId);
return buildCanonicalUrl(cityDomain || currentHost, pathname);
}
/**
* origin مقصد canonical یک موجودیت — تا url/@id در JSON-LD و آیتم‌های BreadcrumbList
* همان دامنه‌ای را اعلام کنند که canonical به آن اشاره می‌کند (رفع سیگنال متناقض).
*/
export async function getEntityOrigin(cityId) {
let currentHost = DEFAULT_HOST;
try {
const headersList = await headers();
currentHost = headersList.get("host") || DEFAULT_HOST;
} catch {
// خارج از scope درخواست — دامنهٔ اصلی fallback امنی است.
}
return getEntityCanonical(cityId, "/", currentHost).replace(/\/$/, "");
}
/**
* canonical پیش‌فرض لایهٔ layout — برای صفحاتی که خودشان alternates.canonical نمی‌دهند.
* صفحات موجودیت canonical خودشان را در generateMetadata صفحه تعیین می‌کنند.
*/
export async function getCanonicalUrl() {
try {
const headersList = await headers();
const pathname = headersList.get("x-pathname");
// مسیرهای خارج از matcher میدل‌ور (panel/dashboard/login) این هدر را ندارند —
// مسیرهای خارج از matcher میدل‌ور (dashboard/login) این هدر را ندارند —
// آن‌ها noindex هستند و نباید canonical جعلی به ریشه بگیرند.
if (!pathname) return null;
return buildMainCanonicalUrl(pathname);
const path = buildCanonicalPath(pathname);
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 query = buildCanonicalQuery(search);
return `${buildCanonicalUrl(headersList.get("host"), path)}${query}`;
} catch (error) {
console.error("Error generating canonical URL:", error);
return null;
+33
View File
@@ -0,0 +1,33 @@
// متن مقدمهٔ یکتای صفحات لیست (/doctors و /clinics).
// canonical درست، محتوای یکسان را غیرتکراری نمی‌کند — این متن همان نقش مکمل را دارد.
// مثل صفحات تخصص، ساختار جمله‌بندی هر شهر با هش نام شهر انتخاب می‌شود تا ۳۵ دامنه
// ۳۵ متن با ساختار متفاوت داشته باشند، نه یک قالب با نام جای‌گذاری‌شده.
function hash(value) {
let out = 0;
for (let i = 0; i < value.length; i++) out = (out * 31 + value.charCodeAt(i)) >>> 0;
return out;
}
// seed همیشه unsigned — با >> علامت‌دار، هش‌های بزرگ ایندکس منفی می‌ساختند.
const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
const DOCTOR_INTROS = (city) => [
`فهرست پزشکان ${city} در این صفحه گردآوری شده است؛ از پزشک عمومی تا فوق‌تخصص‌های حوزه‌های مختلف. هر پروفایل شامل تخصص، کد نظام پزشکی، محل مطب و بازخورد بیماران پیشین است. با فیلترهای بالای صفحه می‌توانید نتایج را بر اساس تخصص، جنسیت پزشک یا درجهٔ تخصصی محدود کنید تا سریع‌تر به گزینهٔ مناسب برسید. پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ نیازی به تماس تلفنی یا مراجعهٔ حضوری برای گرفتن نوبت نیست. اطلاعات هر پزشک به‌صورت دوره‌ای بازبینی می‌شود تا ساعات کاری و محل پذیرش با وضعیت واقعی مطب هم‌خوان بماند. اگر ترجیح می‌دهید جست‌وجو را از یک حوزهٔ درمانی مشخص شروع کنید، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در ${city} نشان می‌دهد و از هر رشته مستقیم به پزشکان همان حوزه می‌رسید. هزینهٔ هر خدمت نیز پیش از تأیید نهایی نمایش داده می‌شود.`,
`اینجا می‌توانید متخصصان فعال ${city} را با هم مقایسه کنید. اطلاعات هر پزشک — رشتهٔ تخصصی، سابقهٔ فعالیت، نشانی محل پذیرش و امتیاز مراجعان — به‌شکل یکسان نمایش داده می‌شود تا مقایسه منصفانه باشد. امتیازها از بیماران واقعی همان پزشک جمع‌آوری شده‌اند و نه از منابع عمومی، بنابراین تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند. برخی پزشکان بیش از یک محل پذیرش دارند و ساعات کاری هر محل جداگانه اعلام شده است؛ هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبت با برنامهٔ روزانه‌تان هماهنگ باشد. رزرو به‌صورت شبانه‌روزی انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من امکان‌پذیر است و آن بازهٔ زمانی برای بیمار دیگری آزاد می‌شود.`,
`دسترسی به پزشک مناسب در ${city} با این فهرست ساده‌تر می‌شود. به‌جای جست‌وجو میان منابع پراکنده، همهٔ متخصصان شهر را با اطلاعات ساختاریافته یک‌جا می‌بینید و می‌توانید بر اساس تخصص یا نزدیکی محل مطب انتخاب کنید. رزرو نوبت به‌صورت آنلاین و شبانه‌روزی انجام می‌شود: تاریخ و ساعت خالی را انتخاب می‌کنید، تأیید می‌گیرید و پیامک تأییدیه دریافت می‌کنید. در صورت تغییر برنامه نیز لغو نوبت از حساب کاربری‌تان ممکن است. سابقهٔ نوبت‌های شما در همان حساب باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. مراکز درمانی و کلینیک‌های ${city} هم صفحهٔ اختصاصی خود را دارند؛ اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از آن مسیر هم می‌توانید به همین پزشکان برسید.`,
];
const CLINIC_INTROS = (city) => [
`کلینیک‌ها و مراکز درمانی ${city} در این صفحه فهرست شده‌اند. برای هر مرکز، تخصص‌های ارائه‌شده، پزشکان همکار، نشانی و اطلاعات تماس نمایش داده می‌شود. اگر ترجیح می‌دهید به‌جای انتخاب پزشک، ابتدا مرکز درمانی را انتخاب کنید، این فهرست نقطهٔ شروع مناسبی است. از صفحهٔ هر کلینیک می‌توانید پزشکان همان مرکز را ببینید و مستقیم برایشان نوبت بگیرید؛ همهٔ مراحل آنلاین و بدون تماس تلفنی انجام می‌شود. صفحهٔ هر مرکز موقعیت روی نقشه و ساعات کاری را هم نشان می‌دهد تا پیش از مراجعه، مسیر و زمان را بدانید. مراکزی که به‌صورت شبانه‌روزی پذیرش دارند نیز مشخص شده‌اند. برای مقایسهٔ مستقیم پزشکان به‌جای مراکز، فهرست پزشکان ${city} گزینهٔ دیگری در اختیارتان می‌گذارد.`,
`این صفحه مراکز درمانی فعال ${city} را معرفی می‌کند — از کلینیک‌های تخصصی تا درمانگاه‌های چندتخصصی. هر مرکز صفحهٔ اختصاصی خود را دارد که در آن خدمات، بیمه‌های طرف قرارداد، تخصص‌های موجود و فهرست پزشکان همکار آمده است. جست‌وجو را می‌توانید با فیلتر تخصص محدود کنید تا فقط مراکزی را ببینید که خدمت موردنظرتان را ارائه می‌دهند. رزرو نوبت از همان صفحهٔ مرکز و به‌صورت آنلاین انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. اطلاعات تماس و نشانی هر مرکز به‌صورت دوره‌ای بازبینی می‌شود. اگر مرکزی بیش از یک شعبه دارد، هر شعبه رکورد جداگانهٔ خود را با ساعات کاری مستقل دارد تا انتخاب دقیق‌تری داشته باشید.`,
`برای یافتن مرکز درمانی مناسب در ${city}، فهرست زیر گزینه‌های موجود را با اطلاعات یکسان کنار هم می‌گذارد. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک صفحهٔ کامل وصل است: موقعیت روی نقشه، ساعات کاری، تخصص‌ها و پزشکانی که در آن مرکز پذیرش دارند. پس از انتخاب مرکز و پزشک، نوبت‌های خالی را می‌بینید و رزرو در همان لحظه نهایی می‌شود. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و بازهٔ آزادشده بلافاصله در اختیار بیمار دیگری قرار می‌گیرد.`,
];
export function buildDoctorsIntro(cityName) {
return pick(DOCTOR_INTROS(cityName), hash(cityName));
}
export function buildClinicsIntro(cityName) {
return pick(CLINIC_INTROS(cityName), hash(cityName) >>> 2);
}
+20
View File
@@ -0,0 +1,20 @@
// سیاست ایندکس صفحات لیست (/doctors و /clinics) — یک پیاده‌سازی مشترک تا دو مسیر واگرا نشوند.
//
// جستجوی داخلی (name) و ترکیب چند فیلتر واقعی نباید ایندکس شوند (crawl budget و duplicate).
// اما city/state روی دامنه‌های شهری خودکار از host تزریق می‌شوند و «فیلتر انتخابی کاربر»
// نیستند؛ شمردنشان باعث می‌شد صفحهٔ اصلی لیست روی هر ۳۵ دامنه همیشه noindex شود.
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]);
// فقط 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 {};
}
+41
View File
@@ -0,0 +1,41 @@
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);
});
it("city/state تزریق‌شده از host شمرده نمی‌شوند (باگ noindex سیستمیک)", () => {
const injected = { city: "یاسوج", state: "کهگیلویه و بویراحمد" };
expect(isNoindex(listingRobots(injected, scope))).toBe(false);
});
it("تک‌فیلتر تخصص ایندکس‌پذیر می‌ماند", () => {
expect(isNoindex(listingRobots({ specialty: "پوست و مو" }, scope))).toBe(false);
});
it("جستجوی نام همیشه noindex است", () => {
expect(isNoindex(listingRobots({ name: "احمدی" }, scope))).toBe(true);
});
it("دو فیلتر واقعی کاربر noindex می‌گیرد", () => {
expect(isNoindex(listingRobots({ specialty: "پوست و مو", gender: "woman" }, scope))).toBe(true);
});
it("city متفاوت از دامنه یک فیلتر واقعی است", () => {
const params = { city: "تهران", specialty: "پوست و مو" };
expect(isNoindex(listingRobots(params, scope))).toBe(true);
});
it("روی دامنهٔ ریشه (بدون شهر) city صریح کاربر شمرده می‌شود", () => {
const params = { city: "یاسوج", specialty: "پوست و مو" };
expect(isNoindex(listingRobots(params, {}))).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
// متن مقدمه و سوالات متداول صفحات فرود تخصص.
//
// دو الزام SEO اینجا با هم حل می‌شوند:
// ۱. رفع Thin Content — canonical درست، محتوای تکراری را غیرتکراری نمی‌کند.
// ۲. تنوع ساختاری — متن هر شهر/تخصص باید ساختار جمله‌بندی متفاوتی داشته باشد،
// نه یک قالب واحد با جای‌گذاری نام. برای همین از سه بلوکِ مستقل با گونه‌های
// متفاوت استفاده می‌شود که با هشِ (تخصص، شهر) انتخاب می‌شوند — خروجی پایدار
// (بین دو رندر تغییر نمی‌کند) ولی بین صفحات مختلف واگراست.
function hash(...parts) {
const key = parts.join("|");
let value = 0;
for (let i = 0; i < key.length; i++) {
value = (value * 31 + key.charCodeAt(i)) >>> 0;
}
return value;
}
// seed همیشه unsigned گرفته می‌شود؛ با >> علامت‌دار، هش‌های بزرگ‌تر از 2^31 منفی
// می‌شدند و ایندکس منفی به undefined می‌رسید.
const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
function openingVariants(specialty, city) {
return [
`اگر به دنبال متخصص ${specialty} در ${city} هستید، این صفحه فهرست کاملی از پزشکان فعال این حوزه را در اختیار شما می‌گذارد. هر پروفایل شامل تخصص‌ها، سوابق، محل مطب و امتیاز واقعی بیماران است تا بتوانید پیش از رزرو نوبت، انتخاب آگاهانه‌تری داشته باشید.`,
`انتخاب پزشک ${specialty} در ${city} وقتی ساده‌تر می‌شود که همهٔ گزینه‌ها را یک‌جا و با اطلاعات یکسان ببینید. در این صفحه، متخصصان ${specialty} شهر ${city} همراه با نشانی مطب، ساعات پذیرش و بازخورد مراجعان گردآوری شده‌اند.`,
`دسترسی به متخصص ${specialty} در ${city} دیگر نیازی به تماس‌های پی‌درپی و مراجعهٔ حضوری برای گرفتن نوبت ندارد. فهرست زیر پزشکان این تخصص را در ${city} نشان می‌دهد؛ از هر پروفایل می‌توانید مستقیم و آنلاین نوبت بگیرید.`,
`این صفحه به معرفی پزشکان ${specialty} در ${city} اختصاص دارد. هدف، کوتاه‌کردن مسیر میان «جست‌وجوی یک متخصص خوب» و «داشتن نوبت قطعی» است؛ بنابراین اطلاعات هر پزشک به‌شکلی چیده شده که مقایسه میان گزینه‌ها سریع انجام شود.`,
];
}
function bodyVariants(specialty, city, doctorCount) {
const countPhrase =
doctorCount > 0
? `در حال حاضر ${doctorCount} پزشک ${specialty} در ${city} در این سامانه ثبت شده‌اند`
: `فهرست پزشکان ${specialty} در ${city} به‌طور مداوم به‌روزرسانی می‌شود`;
return [
`${countPhrase}. برای هر پزشک، مواردی مانند مدرک و درجهٔ تخصصی، کد نظام پزشکی، خدمات قابل ارائه و موقعیت مکانی مطب نمایش داده می‌شود. امتیازها و نظرها از مراجعان واقعی همان پزشک جمع‌آوری شده‌اند، نه از منابع عمومی؛ به همین دلیل تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند.`,
`${countPhrase} و اطلاعات آن‌ها به‌صورت دوره‌ای بازبینی می‌شود. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک پروفایل کامل وصل است: خدمات، محل‌های پذیرش، ساعات کاری هر محل و امکان رزرو مستقیم. نتیجه آنکه مقایسهٔ چند پزشک در چند دقیقه ممکن می‌شود.`,
`${countPhrase}. برخی از این پزشکان بیش از یک محل پذیرش دارند — مطب شخصی و یک یا چند کلینیک — و ساعات کاری هر محل جداگانه اعلام شده است. هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبتی که می‌گیرید با برنامهٔ رفت‌وآمد شما هماهنگ باشد.`,
];
}
function closingVariants(specialty, city) {
return [
`پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ پیامک تأیید نیز برایتان ارسال خواهد شد. اگر برنامه‌تان تغییر کرد، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و آن بازهٔ زمانی بلافاصله برای بیمار دیگری آزاد می‌شود. اگر تخصص موردنظرتان ${specialty} نیست، از فهرست تخصص‌های ${city} می‌توانید حوزهٔ درمانی دیگری را دنبال کنید و همان مسیر رزرو را طی کنید.`,
`نوبت‌گیری در همین صفحه انجام می‌شود: تاریخ و ساعت خالی را می‌بینید، انتخاب می‌کنید و تأیید نهایی را دریافت می‌کنید. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. برای حوزه‌های درمانی دیگر در ${city}، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در اختیارتان می‌گذارد و از هر رشته می‌توانید مستقیم به پزشکان همان حوزه برسید.`,
`تمام مراحل — از مقایسهٔ پزشکان ${specialty} تا دریافت تأییدیهٔ نوبت — آنلاین و بدون مراجعهٔ حضوری طی می‌شود؛ نیازی به تماس تلفنی در ساعات اداری نیست و رزرو شبانه‌روزی انجام می‌گیرد. کلینیک‌ها و مراکز درمانی ${city} نیز صفحهٔ اختصاصی خود را دارند و اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از همان مسیر قابل رزرو هستند.`,
`برای رزرو کافی است روی پروفایل پزشک بزنید و از میان نوبت‌های خالی یکی را انتخاب کنید؛ تأییدیه بلافاصله صادر و پیامک می‌شود. سابقهٔ نوبت‌های شما در حساب کاربری باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. اگر ترجیح می‌دهید بر اساس مرکز درمانی جست‌وجو کنید، فهرست کلینیک‌های ${city} گزینهٔ مناسبی برای شروع است.`,
];
}
/** متن مقدمهٔ یکتا (۱۵۰–۳۰۰ کلمه) برای یک تخصص در یک شهر. */
export function buildSpecialtyIntro(specialtyName, cityName, doctorCount = 0) {
const seed = hash(specialtyName, cityName);
return [
pick(openingVariants(specialtyName, cityName), seed),
pick(bodyVariants(specialtyName, cityName, doctorCount), seed >>> 3),
pick(closingVariants(specialtyName, cityName), seed >>> 7),
].join(" ");
}
/** سوالات متداول صفحهٔ پزشک — منبع مشترک بلوک بصری و FAQPage schema. */
export function buildDoctorFaq(doctorName, specialtyName, cityName) {
const who = `دکتر ${doctorName}`;
return [
{
question: `چگونه از ${who} نوبت بگیرم؟`,
answer: `از همین صفحه، محل مراجعه و سپس تاریخ و ساعت خالی موردنظرتان را انتخاب کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک خواهد شد.`,
},
...(specialtyName
? [
{
question: `تخصص ${who} چیست؟`,
answer: `${who} در حوزهٔ ${specialtyName} فعالیت می‌کند. فهرست کامل خدمات قابل ارائه در همین صفحه آمده است.`,
},
]
: []),
...(cityName
? [
{
question: `مطب ${who} در کدام شهر است؟`,
answer: `محل یا محل‌های پذیرش ${who} در ${cityName} به همراه نشانی دقیق و ساعات کاری هر محل، در بخش موقعیت مکانی همین صفحه نمایش داده شده است.`,
},
]
: []),
{
question: `امکان لغو نوبت وجود دارد؟`,
answer: `بله. از بخش نوبت‌های من در حساب کاربری‌تان می‌توانید نوبت را لغو کنید؛ لغو زودهنگام آن بازهٔ زمانی را برای بیمار دیگری آزاد می‌کند.`,
},
];
}
/** سوالات متداول صفحهٔ تخصص — منبع مشترک بلوک بصری و FAQPage schema. */
export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0) {
return [
{
question: `چطور از متخصص ${specialtyName} در ${cityName} نوبت بگیرم؟`,
answer: `از فهرست همین صفحه پزشک موردنظرتان را انتخاب کنید، وارد پروفایل او شوید و از تقویم نوبت‌های خالی، تاریخ و ساعت دلخواه را ثبت کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک می‌شود.`,
},
{
question: `چند پزشک ${specialtyName} در ${cityName} در دسترس است؟`,
answer:
doctorCount > 0
? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${cityName} در این سامانه ثبت شده‌اند. این عدد با افزوده‌شدن پزشکان جدید به‌روزرسانی می‌شود.`
: `فهرست پزشکان ${specialtyName} در ${cityName} در حال تکمیل است و به‌مرور پزشکان جدید به آن افزوده می‌شوند.`,
},
{
question: `هزینهٔ ویزیت متخصص ${specialtyName} چقدر است؟`,
answer: `هزینهٔ ویزیت به نوع خدمت، درجهٔ تخصصی پزشک و پوشش بیمه بستگی دارد. مبلغ هر خدمت پیش از تأیید نهایی نوبت، در همان صفحهٔ رزرو نمایش داده می‌شود.`,
},
{
question: `اگر نتوانم در زمان نوبت مراجعه کنم چه کنم؟`,
answer: `از بخش نوبت‌های من در حساب کاربری‌تان می‌توانید نوبت را لغو کنید. لغو زودهنگام باعث می‌شود آن بازهٔ زمانی برای بیمار دیگری آزاد شود.`,
},
];
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
import { buildClinicsIntro, buildDoctorsIntro } from "@/lib/listingIntro";
const words = (text) => text.trim().split(/\s+/).length;
const CITIES = ["تهران", "یاسوج", "یزد", "تبریز", "اصفهان", "مشهد", "ایران"];
const SPECIALTIES = ["پوست و مو", "قلب و عروق", "داخلی", "اطفال"];
describe("متن مقدمهٔ تخصص (M2)", () => {
it("هر ترکیب تخصص/شهر بین ۱۵۰ تا ۳۰۰ کلمه است", () => {
for (const specialty of SPECIALTIES) {
for (const city of CITIES) {
const count = words(buildSpecialtyIntro(specialty, city, 12));
expect(count, `${specialty}/${city} = ${count}`).toBeGreaterThanOrEqual(150);
expect(count, `${specialty}/${city} = ${count}`).toBeLessThanOrEqual(300);
}
}
});
it("متن شهرهای مختلف یکسان نیست", () => {
const texts = CITIES.map((city) => buildSpecialtyIntro("پوست و مو", city, 5));
expect(new Set(texts).size).toBe(CITIES.length);
});
it("خروجی پایدار است (بین دو فراخوانی تغییر نمی‌کند)", () => {
expect(buildSpecialtyIntro("داخلی", "یزد", 3)).toBe(buildSpecialtyIntro("داخلی", "یزد", 3));
});
});
describe("متن مقدمهٔ صفحات لیست (M2)", () => {
it("بین ۱۵۰ تا ۳۰۰ کلمه", () => {
for (const city of CITIES) {
for (const text of [buildDoctorsIntro(city), buildClinicsIntro(city)]) {
expect(words(text), `${city} = ${words(text)}`).toBeGreaterThanOrEqual(100);
expect(words(text), `${city} = ${words(text)}`).toBeLessThanOrEqual(300);
}
}
});
it("ساختار متن بین شهرها متفاوت است", () => {
const texts = CITIES.map(buildDoctorsIntro);
expect(new Set(texts).size).toBe(CITIES.length);
});
});
describe("سوالات متداول تخصص (M1)", () => {
it("هر سوال متن پاسخ دارد", () => {
const faq = buildSpecialtyFaq("قلب و عروق", "تهران", 7);
expect(faq.length).toBeGreaterThanOrEqual(4);
for (const item of faq) {
expect(item.question).toBeTruthy();
expect(item.answer.length).toBeGreaterThan(30);
}
});
it("تعداد پزشک صفر، جملهٔ متفاوت می‌سازد", () => {
const withCount = buildSpecialtyFaq("داخلی", "یزد", 9)[1].answer;
const without = buildSpecialtyFaq("داخلی", "یزد", 0)[1].answer;
expect(withCount).not.toBe(without);
expect(withCount).toContain("9");
});
});