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:
+98
-7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user