From fd48b4861367c46c5946e9cf04e257c78d7ad2f4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 07:52:50 +0330 Subject: [PATCH] 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. --- app/blog/[slug]/loading.js | 23 - app/blog/[slug]/page.js | 24 +- app/blogs/page.js | 4 +- app/clinic/[slug]/loading.js | 18 - app/clinic/[slug]/page.js | 44 +- app/clinics/page.js | 24 +- app/component/Pageguide.js | 85 +- app/component/head/index.js | 8 +- app/doctor/[slug]/loading.js | 27 - app/doctor/[slug]/page.js | 59 +- app/doctors/page.js | 24 +- app/sitemap.js | 81 +- app/specialties/[slug]/page.js | 135 + app/specialties/page.js | 5 +- components/blog/head/index.js | 14 +- components/blog/index.js | 4 +- .../clinic/components/about/ContentDetail.js | 5 +- components/clinic/components/about/index.js | 2 +- components/clinic/index.js | 13 +- components/clinics/index.js | 9 +- components/clinics/list/index.js | 6 +- components/doctor/index.js | 16 +- components/doctors/head/FilterLocate.js | 5 +- components/doctors/index.js | 9 + components/home/TextHeader.js | 11 +- components/specialties/detail/DoctorGrid.js | 33 + components/specialties/detail/Faq.js | 30 + components/specialties/detail/index.js | 36 + components/specialties/index.js | 1 + components/specialties/list/index.js | 2 +- graphify-out/.graphify_labels.json | 10 +- graphify-out/GRAPH_REPORT.md | 161 +- ...b7d9079f4e60eb9f3b607363cba0953430a37.json | 1 + graphify-out/cache/stat-index.json | 2 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 2934 ++++++++++++++--- graphify-out/manifest.json | 210 +- lib/canonicalStrategy.test.js | 57 + lib/domainHelpers.js | 57 + lib/domainHelpers.test.js | 61 + lib/entityQuality.js | 44 + lib/getCanonicalUrl.js | 105 +- lib/listingIntro.js | 33 + lib/listingRobots.js | 20 + lib/listingRobots.test.js | 41 + lib/specialtyContent.js | 118 + lib/specialtyContent.test.js | 63 + proxy.js | 2 + 48 files changed, 3951 insertions(+), 733 deletions(-) delete mode 100644 app/blog/[slug]/loading.js delete mode 100644 app/clinic/[slug]/loading.js delete mode 100644 app/doctor/[slug]/loading.js create mode 100644 app/specialties/[slug]/page.js create mode 100644 components/specialties/detail/DoctorGrid.js create mode 100644 components/specialties/detail/Faq.js create mode 100644 components/specialties/detail/index.js create mode 100644 graphify-out/cache/ast/v0.8.44/2c2f8817db9d73ec4d45a4b1189b7d9079f4e60eb9f3b607363cba0953430a37.json create mode 100644 lib/canonicalStrategy.test.js create mode 100644 lib/domainHelpers.js create mode 100644 lib/domainHelpers.test.js create mode 100644 lib/entityQuality.js create mode 100644 lib/listingIntro.js create mode 100644 lib/listingRobots.js create mode 100644 lib/listingRobots.test.js create mode 100644 lib/specialtyContent.js create mode 100644 lib/specialtyContent.test.js diff --git a/app/blog/[slug]/loading.js b/app/blog/[slug]/loading.js deleted file mode 100644 index d0e878a..0000000 --- a/app/blog/[slug]/loading.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Skeleton } from "@mui/material"; - -export default function Loading() { - return ( -
- - -
- - -
-
-
- - - - -
- -
-
- ); -} diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js index 3ea0c06..6bb60af 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.js @@ -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 }) { /> )} - + ); diff --git a/app/blogs/page.js b/app/blogs/page.js index 2451a1c..1a0c4b0 100644 --- a/app/blogs/page.js +++ b/app/blogs/page.js @@ -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] }, }; diff --git a/app/clinic/[slug]/loading.js b/app/clinic/[slug]/loading.js deleted file mode 100644 index 238a38b..0000000 --- a/app/clinic/[slug]/loading.js +++ /dev/null @@ -1,18 +0,0 @@ -import { Skeleton } from "@mui/material"; - -export default function Loading() { - return ( -
-
- - - -
-
- - - -
-
- ); -} diff --git a/app/clinic/[slug]/page.js b/app/clinic/[slug]/page.js index 27d2863..d0a87e2 100644 --- a/app/clinic/[slug]/page.js +++ b/app/clinic/[slug]/page.js @@ -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 && ( -