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
-23
View File
@@ -1,23 +0,0 @@
import { Skeleton } from "@mui/material";
export default function Loading() {
return (
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
<Skeleton variant="rounded" width={300} height={16} />
<Skeleton variant="rounded" width={400} height={28} className="mt-[24px] sm:mt-[29px] md:mt-[35px] lg:mt-[40px]" />
<div className="flex items-center gap-[35px] mt-[16px] sm:mt-[18px] md:mt-[21px] lg:mt-[24px]">
<Skeleton variant="rounded" width={120} height={16} />
<Skeleton variant="rounded" width={140} height={16} />
</div>
<div className="flex flex-col gap-y-[32px] lg:flex-row items-start justify-center gap-[24px] mt-[16px] sm:mt-[19px] md:mt-[21px] lg:mt-[24px]">
<div className="w-full flex flex-col gap-3">
<Skeleton variant="rounded" height={280} className="!rounded-lg" />
<Skeleton variant="rounded" height={20} className="!w-full" />
<Skeleton variant="rounded" height={20} className="!w-full" />
<Skeleton variant="rounded" height={20} width={300} />
</div>
<Skeleton variant="rounded" width={320} height={300} className="!rounded-lg shrink-0" />
</div>
</div>
);
}
+18 -6
View File
@@ -4,7 +4,8 @@ import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
import { safeJsonLd } from "@/lib/sanitize";
import { normalizeBlog, imageUrl } from "@/helper";
@@ -38,11 +39,14 @@ export async function generateMetadata({ params }) {
const description = rawText.slice(0, 160) || blog.title;
const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG;
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛
// پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical می‌ماند.
const origin = await getEntityOrigin(extractEntityCityId(blog));
return {
title,
description,
// محتوای بلاگ روی همه‌ی دامنه‌های شهری یکسان است — تجمیع اعتبار روی دامنه‌ی اصلی
alternates: { canonical: `https://nobat724.com/blog/${slug}` },
alternates: { canonical: `${origin}/blog/${slug}` },
openGraph: {
title,
description,
@@ -59,18 +63,22 @@ export async function generateMetadata({ params }) {
images: [image],
},
};
} catch {
} catch (error) {
console.error(`[blog/${slug}] generateMetadata failed:`, error);
return {};
}
}
async function Blog({ params }) {
const { slug } = await params;
const origin = await getRequestOrigin();
const blog = normalizeBlog(await getBlog(slug));
if (!blog) notFound();
const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId);
const origin = await getEntityOrigin(blogCityId);
let relatedBlogs = [];
const relatedResponse = await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6`
@@ -90,6 +98,10 @@ async function Blog({ params }) {
datePublished: new Date(blog.created * 1000).toISOString(),
}),
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
// پست شهریافته حوزهٔ جغرافیایی‌اش را اعلام می‌کند؛ پست سراسری این فیلد را ندارد.
...(blogCity && {
spatialCoverage: { "@type": "Place", name: blogCity.name },
}),
}
: null;
@@ -120,7 +132,7 @@ async function Blog({ params }) {
/>
)}
<Layout>
<BlogPage blog={blog} blogs={relatedBlogs} />
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
</Layout>
</>
);
+2 -2
View File
@@ -11,8 +11,8 @@ export async function generateMetadata() {
return {
title,
description,
// مقالات روی همه‌ی دامنه‌ها یکسان‌اند — تجمیع روی دامنه‌ی اصلی
alternates: { canonical: "https://nobat724.com/blogs" },
// C1-a — لیست بلاگ per-domain است (پست‌های همان شهر + سراسری)؛
// canonical لایهٔ layout (self روی همان دامنه) درست است.
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
-18
View File
@@ -1,18 +0,0 @@
import { Skeleton } from "@mui/material";
export default function Loading() {
return (
<div className="pt-[40px] sm:pt-[70px] md:pt-[100px] lg:pt-[136px] mt-[40px] padding-responsive">
<div className="flex gap-6">
<Skeleton variant="rounded" width={100} height={32} />
<Skeleton variant="rounded" width={100} height={32} />
<Skeleton variant="rounded" width={100} height={32} />
</div>
<div className="mt-[24px] sm:mt-[27px] md:mt-[30px] lg:mt-[32px] flex flex-col gap-4">
<Skeleton variant="rounded" height={200} className="!rounded-lg" />
<Skeleton variant="rounded" height={20} className="!w-full" />
<Skeleton variant="rounded" height={20} width={300} />
</div>
</div>
);
}
+22 -22
View File
@@ -4,7 +4,9 @@ import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
import { extractEntityCityId } from "@/lib/domainHelpers";
import { isThinClinic } from "@/lib/entityQuality";
import { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
@@ -38,9 +40,15 @@ export async function generateMetadata({ params }) {
? [imageUrl(clinic.images_clinic[0].url)]
: ["/assets/images/og-image.png"];
// C1-b — کلینیک به دامنهٔ شهر خودش canonical می‌شود.
const origin = await getEntityOrigin(extractEntityCityId(clinic));
return {
title,
description,
alternates: { canonical: `${origin}/clinic/${clinic.uuid}` },
// H7 — کلینیک غیرفعال/بی‌محتوا نباید ایندکس شود (تقارن با پزشک بی‌محتوا)
...(isThinClinic(clinic) && { robots: { index: false, follow: true } }),
openGraph: {
title,
description,
@@ -54,7 +62,8 @@ export async function generateMetadata({ params }) {
images: image,
},
};
} catch {
} catch (error) {
console.error(`[clinic/${slug}] generateMetadata failed:`, error);
return {};
}
}
@@ -74,13 +83,15 @@ function computeClinicRating(doctors) {
async function Clinic({ params, searchParams }) {
const { slug } = await params;
const origin = await getRequestOrigin();
const sp = await searchParams;
const page = Number(sp?.page) || 1;
const limit = 50;
const clinic = await getClinic(slug);
if (!clinic) notFound();
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
const origin = await getEntityOrigin(extractEntityCityId(clinic));
const reqDoctors = await fetchReq(
`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`
);
@@ -149,18 +160,6 @@ async function Clinic({ params, searchParams }) {
}
: null;
const breadcrumbJsonLd = clinic
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ "@type": "ListItem", position: 2, name: "کلینیک‌ها", item: `${origin}/clinics` },
{ "@type": "ListItem", position: 3, name: clinic.title },
],
}
: null;
return (
<>
{jsonLd && (
@@ -169,14 +168,15 @@ async function Clinic({ params, searchParams }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
{breadcrumbJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
)}
{/* BreadcrumbList از خودِ کامپوننت Pageguide می‌آید — یک منبع داده برای بصری و schema */}
<Layout>
<ClinicPage data={clinic} doctors={doctors} slug={slug} pages={pagedoctors} />
<ClinicPage
data={clinic}
doctors={doctors}
slug={slug}
pages={pagedoctors}
origin={origin}
/>
</Layout>
</>
);
+8 -16
View File
@@ -3,24 +3,14 @@ import ClinicsPage from "@/components/clinics";
import Layout from "@/components/layout/StLayout";
import { getStateInfo } from "@/lib/getStateInfo";
import { buildClinicParams } from "@/helper";
const FILTER_KEYS = ["specialty", "state", "city", "gender", "degree", "active", "sort", "name"];
// جستجوی داخلی و ترکیب چند فیلتر نباید ایندکس شوند — crawl budget و duplicate
function listingRobots(params) {
const activeFilters = FILTER_KEYS.filter((key) => params?.[key]);
if (params?.name || activeFilters.length >= 2) {
return { robots: { index: false, follow: true } };
}
return {};
}
import { listingRobots } from "@/lib/listingRobots";
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
const { matchedCity, matchedState, repContext } = await getStateInfo();
const { matchedCity, matchedState, isRoot, repContext } = await getStateInfo();
const siteName = matchedCity?.site_name || repContext?.full_name || "نوبت 724";
const cityName = matchedCity?.name || matchedState?.name || "";
// روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
const cityName = isRoot ? "" : matchedCity?.name || matchedState?.name || "";
const title = cityName
? `کلینیک‌های ${cityName} | جستجو و رزرو نوبت | ${siteName}`
: `جستجوی کلینیک و مراکز درمانی | ${siteName}`;
@@ -31,7 +21,7 @@ export async function generateMetadata({ searchParams }) {
return {
title,
description,
...listingRobots(awaitedParams),
...listingRobots(awaitedParams, { matchedCity, matchedState }),
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
@@ -47,7 +37,9 @@ export default async function Clinics({ searchParams }) {
let clinics;
try {
// Params
let newSearchParams = awaitedSearchParams;
// کپی — awaitedSearchParams همان شیئی است که generateMetadata هم می‌خواند؛
// تزریق city/state روی خودش، سیاست robots را به noindex می‌برد.
const newSearchParams = { ...awaitedSearchParams };
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود.
if (stateParams) newSearchParams.state = stateParams;
+68 -17
View File
@@ -1,21 +1,72 @@
function Pageguide({ list }) {
import Link from "next/link";
import { safeJsonLd } from "@/lib/sanitize";
// Breadcrumb واحد سایت: ظاهر دقیقاً همان نسخهٔ قبلی (همان کلاس‌ها، همان جداکنندهٔ «>»)
// ولی ساختار سمانتیک درست (nav/ol/a) و BreadcrumbList از همان یک منبع داده تولید می‌شود
// تا نسخهٔ بصری و schema هرگز واگرا نشوند.
//
// list: Array<string | { name, href }> — آیتم آخر همیشه صفحهٔ جاری است (بدون لینک).
const ITEM_CLASS = "text-[16px] font-normal";
function normalize(list) {
return list
.map((item) => (typeof item === "string" ? { name: item } : item))
.filter((item) => item?.name);
}
function Pageguide({ list = [], origin }) {
const items = normalize(list);
if (items.length === 0) return null;
const jsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, idx) => ({
"@type": "ListItem",
position: idx + 1,
name: item.name,
...(item.href && origin && { item: `${origin}${item.href}` }),
})),
};
return (
<ul className="flex items-center justify-start gap-1">
{list.map((item, idx) => (
<li key={idx} className="flex items-center justify-start gap-1">
<h2
className={`text-[16px] font-normal ${ list.length > idx + 1 ? "text-[#9B9B9B]" : "text-[#525252]" } `}
>
{item}
</h2>
<span
className={` text-[#9B9B9B] text-[16px] font-normal ${list.length > idx + 1 ? "flex" : "hidden"} `}
>
{">"}
</span>
</li>
))}
</ul>
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
<nav aria-label="breadcrumb">
<ol className="flex items-center justify-start gap-1">
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
const colorClass = isLast ? "text-[#525252]" : "text-[#9B9B9B]";
return (
<li key={idx} className="flex items-center justify-start gap-1">
{item.href && !isLast ? (
<Link href={item.href} className={`${ITEM_CLASS} ${colorClass}`}>
{item.name}
</Link>
) : (
<span
className={`${ITEM_CLASS} ${colorClass}`}
{...(isLast && { "aria-current": "page" })}
>
{item.name}
</span>
)}
<span
className={`text-[#9B9B9B] ${ITEM_CLASS} ${isLast ? "hidden" : "flex"}`}
aria-hidden="true"
>
{">"}
</span>
</li>
);
})}
</ol>
</nav>
</>
);
}
+5 -3
View File
@@ -1,12 +1,14 @@
function HeadPageList({ children, title, detail }) {
// titleTag: صفحاتی که h1 خودشان را جای دیگری دارند «p» می‌فرستند تا صفحه دو h1 نگیرد.
// کلاس‌ها مستقل از تگ‌اند، پس ظاهر در هر دو حالت یکسان است.
function HeadPageList({ children, title, detail, titleTag: TitleTag = "h1" }) {
return (
<div className="bg-head-menu !bg-no-repeat !bg-cover !bg-center">
<div
className=" padding-responsive flex flex-col items-center gap-[25px] pt-[calc(12px_+_75px_+_32px)] sm:pt-[calc(21px_+_75px_+_45px)] md:pt-[calc(30px_+_75px_+_58px)] lg:pt-[calc(40px_+_75px_+_78px)] pb-[62px] sm:pb-[69px] md:pb-[76px] lg:pb-[85px] "
>
<h1 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] text-center font-bold">
<TitleTag className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] text-center font-bold">
{title}
</h1>
</TitleTag>
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] text-center font-normal">
{detail}
</p>
-27
View File
@@ -1,27 +0,0 @@
import CircularLoading from "@/app/component/loading/Circular";
import CustomLoading from "@/app/component/loading/Custom";
import TextLoading from "@/app/component/loading/Text";
export default function Loading() {
return (
<div className="padding-responsive pt-[95px] sm:pt-[120px]">
<div className="flex flex-col lg:flex-row items-center justify-start gap-[8px] lg:gap-[32px]">
<CircularLoading
loading
width={100}
height={100}
className="w-[40px] sm:w-[81px] md:w-[123px] lg:w-[164px] h-[40px] sm:h-[81px] md:h-[123px] lg:h-[164px]"
/>
<div className="flex flex-col items-center lg:items-start gap-[8px] lg:gap-[16px]">
<TextLoading loading width={90} height={20} />
<TextLoading loading width={100} height={20} />
<div className="flex items-center justify-start gap-11">
<TextLoading loading width={60} height={20} />
<TextLoading loading width={120} height={20} />
</div>
<CustomLoading loading width={160} height={25} />
</div>
</div>
</div>
);
}
+48 -11
View File
@@ -3,7 +3,11 @@ import DoctorPage from "@/components/doctor";
import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
import { buildDoctorFaq } from "@/lib/specialtyContent";
import { isThinDoctor } from "@/lib/entityQuality";
import specialtiesData from "@/data/specialties.json";
import { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
@@ -77,12 +81,14 @@ export async function generateMetadata({ params }) {
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
).slice(0, 160);
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
const isUnclaimed = doctor.owner_status !== "claimed";
// C1-b — پزشک به دامنهٔ شهر خودش canonical می‌شود، روی هر دامنه‌ای که سرو شود.
const origin = await getEntityOrigin(extractEntityCityId(doctor));
return {
title,
description,
...(isUnclaimed && { robots: { index: false, follow: true } }),
alternates: { canonical: `${origin}/doctor/${doctor.uuid}` },
...(isThinDoctor(doctor) && { robots: { index: false, follow: true } }),
openGraph: {
title,
description,
@@ -96,20 +102,24 @@ export async function generateMetadata({ params }) {
images: [image],
},
};
} catch {
} catch (error) {
// بازگشت خاموش، canonical و robots صفحه را بی‌صدا به fallback لایه می‌برد — لاگ لازم است.
console.error(`[doctor/${slug}] generateMetadata failed:`, error);
return {};
}
}
async function Doctor({ params }) {
const { slug } = await params;
const origin = await getRequestOrigin();
let comments = null;
let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
const doctor = await getDoctor(slug);
if (!doctor) notFound();
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
const origin = await getEntityOrigin(extractEntityCityId(doctor));
if (doctor) {
try {
const [resComments, resRate] = await Promise.all([
@@ -229,23 +239,43 @@ async function Doctor({ params }) {
: null;
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمی‌گردیم.
const specialtySlug = specialtiesData.find(
(item) => item.name === specialtyLabel && item.status === 1
)?.slug;
const specialtyItem = specialtySlug
? `${origin}/specialties/${specialtySlug}`
: `${origin}/doctors`;
const breadcrumbJsonLd = doctor
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{
"@type": "ListItem",
position: 2,
name: specialtyLabel,
item: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
},
{ "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
],
}
: null;
// FAQPage عمداً script مستقل است — اسکیماهای Physician/Review/Geo نباید تحت تأثیر باشند.
const doctorCity = findCityById(extractEntityCityId(doctor));
const faq = doctor
? buildDoctorFaq(doctor.name, specialtyNames, doctorCity?.name)
: [];
const faqJsonLd = faq.length
? {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
}
: null;
return (
<>
{jsonLd && (
@@ -254,6 +284,12 @@ async function Doctor({ params }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
{faqJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
/>
)}
{breadcrumbJsonLd && (
<script
type="application/ld+json"
@@ -269,6 +305,7 @@ async function Doctor({ params }) {
bookableAddressUuids={[...bookableAddressUuids]}
bookingLocations={bookingLocations}
slug={slug}
faq={faq}
/>
</Layout>
</>
+8 -16
View File
@@ -3,24 +3,14 @@ import Layout from "@/components/layout/StLayout";
import { buildDoctorParams } from "@/helper";
import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req";
const FILTER_KEYS = ["specialty", "state", "city", "gender", "degree", "active", "sort", "name"];
// جستجوی داخلی و ترکیب چند فیلتر نباید ایندکس شوند — crawl budget و duplicate
function listingRobots(params) {
const activeFilters = FILTER_KEYS.filter((key) => params?.[key]);
if (params?.name || activeFilters.length >= 2) {
return { robots: { index: false, follow: true } };
}
return {};
}
import { listingRobots } from "@/lib/listingRobots";
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
const { matchedCity, matchedState, repContext } = await getStateInfo();
const { matchedCity, matchedState, isRoot, repContext } = await getStateInfo();
const siteName = matchedCity?.site_name || repContext?.full_name || "نوبت 724";
const cityName = matchedCity?.name || matchedState?.name || "";
// روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
const cityName = isRoot ? "" : matchedCity?.name || matchedState?.name || "";
const title = cityName
? `پزشکان ${cityName} | جستجو و رزرو نوبت | ${siteName}`
: `جستجوی پزشک و رزرو نوبت آنلاین | ${siteName}`;
@@ -31,7 +21,7 @@ export async function generateMetadata({ searchParams }) {
return {
title,
description,
...listingRobots(awaitedParams),
...listingRobots(awaitedParams, { matchedCity, matchedState }),
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
@@ -47,7 +37,9 @@ async function Doctors({ searchParams }) {
const cityParams = awaitedSearchParams.city;
try {
let newSearchParams = awaitedSearchParams;
// کپی — awaitedSearchParams همان شیئی است که generateMetadata هم می‌خواند؛
// تزریق city/state روی خودش، سیاست robots را به noindex می‌برد.
const newSearchParams = { ...awaitedSearchParams };
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود.
if (stateParams) newSearchParams.state = stateParams;
+70 -11
View File
@@ -3,6 +3,9 @@ import { getBaseUrl } from '../utils/sitemap';
import citiesData from '@/data/city.json';
import statesData from '@/data/state.json';
import { isRootCity } from '@/lib/rootCity';
import { extractEntityCityId, findDomainByCityId } from '@/lib/domainHelpers';
import { isThinClinic, isThinDoctor } from '@/lib/entityQuality';
import specialtiesData from '@/data/specialties.json';
// sitemap باید در زمان اجرا (per-request) روی سرور production تولید شود، نه در build.
// در build کانتینر به API دسترسی شبکه ندارد → fetch failed. force-dynamic این را قطعی می‌کند.
@@ -11,8 +14,11 @@ export const revalidate = 3600;
const MAIN_DOMAIN = 'nobat724.com';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const PAGE_LIMIT = 500;
const MAX_PAGES = 40;
// backend صفحه را به ۵۰ رکورد سقف می‌زند و limit بزرگ‌تر را نادیده می‌گیرد.
// شرط توقف باید meta.totalPages باشد، نه «کمتر از limit برگشت» — وگرنه حلقه در
// همان صفحهٔ اول می‌شکند و sitemap روی ۵۰ رکورد بریده می‌شود.
const PAGE_LIMIT = 50;
const MAX_PAGES = 200;
function toSafeDate(value) {
if (!value) return null;
@@ -78,8 +84,13 @@ async function fetchAllPages(path, extraParams = {}) {
const items = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
if (items.length === 0) break;
results.push(...items);
const total = json?.meta?.totalRecords;
if (items.length < PAGE_LIMIT || (total && results.length >= Number(total))) break;
const meta = json?.meta ?? raw?.meta;
const totalPages = Number(meta?.totalPages) || 0;
const totalRecords = Number(meta?.totalRecords) || 0;
if (totalPages && page >= totalPages) break;
if (totalRecords && results.length >= totalRecords) break;
// بدون meta تنها نشانهٔ پایان، صفحهٔ خالی است (در تکرار بعدی).
}
} catch (error) {
console.error(`[sitemap] failed to fetch ${path} (page skipped):`, error?.message || error);
@@ -95,10 +106,33 @@ function cityFilterParams(scope) {
return params;
}
// شهرهایی که دامنهٔ اختصاصی دارند — موجودیت‌هایشان canonical روی همان دامنه دارند و
// نباید در sitemap دامنهٔ اصلی تکرار شوند.
const CITY_IDS_WITH_DOMAIN = citiesData
.filter((c) => !isRootCity(c) && c.domain)
.map((c) => c.id);
/**
* پاسخ لیست پزشکان شهر ندارد؛ پس تفکیک با فیلتر سمت API انجام می‌شود:
* روی دامنهٔ شهری با city_id، و روی دامنهٔ اصلی «همه منهای پزشکانِ شهرهای دامنه‌دار».
*/
async function getDoctorsForScope(scope) {
if (!scope.isRoot) return fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
const [all, ...perCity] = await Promise.all([
fetchAllPages('/api/v1/doctors'),
...CITY_IDS_WITH_DOMAIN.map((cityId) =>
fetchAllPages('/api/v1/doctors', { city_id: String(cityId) })
),
]);
const ownedByCityDomain = new Set(perCity.flat().map((d) => d?.uuid).filter(Boolean));
return all.filter((d) => !ownedByCityDomain.has(d?.uuid));
}
async function getDoctorUrls(baseUrl, scope) {
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
const doctors = await getDoctorsForScope(scope);
return doctors
.filter((d) => d?.uuid && d?.owner_status === 'claimed')
.filter((d) => !isThinDoctor(d))
.map((d) =>
withLastModified(
{
@@ -114,7 +148,9 @@ async function getDoctorUrls(baseUrl, scope) {
async function getClinicUrls(baseUrl, scope) {
const clinics = await fetchAllPages('/api/v1/clinics', cityFilterParams(scope));
return clinics
.filter((c) => c?.uuid)
.filter((c) => !isThinClinic(c))
// پاسخ لیست کلینیک‌ها شهر دارد — کلینیکِ شهرِ دامنه‌دار فقط در sitemap همان دامنه.
.filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))
.map((c) =>
withLastModified(
{
@@ -127,10 +163,27 @@ async function getClinicUrls(baseUrl, scope) {
);
}
async function getBlogUrls(baseUrl) {
// صفحات فرود تخصص per-domain — روی هر دامنه «تخصص + آن شهر» است، پس همه‌جا self.
function getSpecialtyUrls(baseUrl) {
return specialtiesData
.filter((s) => s?.slug && s.status === 1)
.map((s) => ({
url: `${baseUrl}/specialties/${s.slug}`,
changeFrequency: 'weekly',
priority: 0.8,
}));
}
// هر پست فقط در sitemap دامنهٔ canonical خودش: پست شهریافته → دامنهٔ آن شهر،
// پست سراسری (بدون شهر) → دامنهٔ اصلی.
async function getBlogUrls(baseUrl, scope, currentDomain) {
const blogs = await fetchAllPages('/api/v1/blogs');
return blogs
.filter((b) => b?.slug || b?.uuid)
.filter((b) => {
const cityDomain = findDomainByCityId(extractEntityCityId(b));
return cityDomain ? cityDomain === currentDomain : scope.isRoot;
})
.map((b) =>
withLastModified(
{
@@ -148,15 +201,21 @@ export default async function sitemap() {
const domain = await getCurrentDomain();
const baseUrl = getBaseUrl(domain);
const scope = getCityScope(domain);
const normalizedDomain = domain.toLowerCase().replace(/^www\./, '').split(':')[0];
const [doctorUrls, clinicUrls, blogUrls] = await Promise.all([
getDoctorUrls(baseUrl, scope),
getClinicUrls(baseUrl, scope),
// محتوای بلاگ روی همه‌ی دامنه‌ها یکسان است — فقط دامنه‌ی اصلی آن را در sitemap اعلام می‌کند
scope.isRoot ? getBlogUrls(baseUrl) : Promise.resolve([]),
getBlogUrls(baseUrl, scope, normalizedDomain),
]);
const allUrls = [...getStaticPages(baseUrl), ...doctorUrls, ...clinicUrls, ...blogUrls];
const allUrls = [
...getStaticPages(baseUrl),
...getSpecialtyUrls(baseUrl),
...doctorUrls,
...clinicUrls,
...blogUrls,
];
const seen = new Set();
return allUrls.filter((item) => {
+135
View File
@@ -0,0 +1,135 @@
import { notFound } from "next/navigation";
import Layout from "@/components/layout/StLayout";
import SpecialtyDetailPage from "@/components/specialties/detail";
import specialtiesData from "@/data/specialties.json";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
import { safeJsonLd } from "@/lib/sanitize";
import { fetchReq } from "@/lib/req";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const DOCTOR_LIMIT = 12;
const findSpecialty = (slug) =>
specialtiesData.find((item) => item.slug === slug && item.status === 1) ?? null;
// روی هر دامنهٔ شهری این صفحه خودکار «تخصص + همان شهر» است — شهر از host می‌آید.
async function getDoctors(specialtyId, cityId) {
try {
const res = await fetchReq(`${API_URL}/api/v1/doctors`, {
params: {
specialty_id: specialtyId,
...(cityId && { city_id: cityId }),
page: 1,
limit: DOCTOR_LIMIT,
},
});
return {
items: res?.data?.data ?? res?.data ?? [],
total: Number(res?.data?.meta?.totalRecords ?? res?.meta?.totalRecords ?? 0),
};
} catch {
return { items: [], total: 0 };
}
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
const { matchedCity, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const cityName = resolveCityDisplayName(isRoot ? null : matchedCity);
const title = `متخصص ${specialty.name} در ${cityName} | رزرو نوبت آنلاین | ${siteName}`;
const description = `لیست بهترین پزشکان ${specialty.name} در ${cityName} همراه با نشانی مطب، ساعات کاری و امتیاز بیماران. رزرو نوبت آنلاین متخصص ${specialty.name}.`;
const image = "/assets/images/og-image.png";
return {
title,
description,
// C1-a — صفحهٔ فرود تخصص per-domain است؛ canonical لایهٔ layout (self) درست است.
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
}
async function SpecialtyDetail({ params }) {
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
const { matchedCity, isRoot } = await getStateInfo();
const origin = await getRequestOrigin();
const cityName = resolveCityDisplayName(isRoot ? null : matchedCity);
const { items: doctors, total } = await getDoctors(
specialty.id,
isRoot ? null : matchedCity?.id
);
const intro = buildSpecialtyIntro(specialty.name, cityName, total);
const faq = buildSpecialtyFaq(specialty.name, cityName, total);
const pageUrl = `${origin}/specialties/${specialty.slug}`;
const pageJsonLd = {
"@context": "https://schema.org",
"@type": "MedicalWebPage",
"@id": `${pageUrl}#webpage`,
url: pageUrl,
name: `متخصص ${specialty.name} در ${cityName}`,
description: intro.slice(0, 300),
about: { "@type": "MedicalSpecialty", name: specialty.name },
...(doctors.length > 0 && {
mainEntity: {
"@type": "ItemList",
numberOfItems: doctors.length,
itemListElement: doctors.map((doctor, idx) => ({
"@type": "ListItem",
position: idx + 1,
url: `${origin}/doctor/${doctor.uuid}`,
name: doctor.name,
})),
},
}),
};
// FAQPage عمداً یک script مستقل است تا اسکیماهای موجود صفحه را نشکند.
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(pageJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
/>
<Layout name="/specialties">
<SpecialtyDetailPage
specialtyName={specialty.name}
cityName={cityName}
doctors={doctors}
intro={intro}
faq={faq}
origin={origin}
/>
</Layout>
</>
);
}
export default SpecialtyDetail;
+3 -2
View File
@@ -3,9 +3,10 @@ import SpecialtiesPage from "@/components/specialties";
import { getStateInfo } from "@/lib/getStateInfo";
export async function generateMetadata() {
const { matchedCity } = await getStateInfo();
const { matchedCity, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const cityName = matchedCity?.name || "";
// روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
const cityName = isRoot ? "" : matchedCity?.name || "";
const title = cityName
? `تخصص‌های پزشکی در ${cityName} | ${siteName}`
: `تخصص‌های پزشکی | ${siteName}`;