diff --git a/.claude/prompt/technical-seo-audit-multidomain.md b/.claude/prompt/technical-seo-audit-multidomain.md new file mode 100644 index 0000000..9ff87e1 --- /dev/null +++ b/.claude/prompt/technical-seo-audit-multidomain.md @@ -0,0 +1,211 @@ +# ممیزی و رفع کامل Technical SEO با محوریت معماری چند-دامنه‌ای + +## پروژه + +`nobat724_front` + +## نقش + +تو یک متخصص ارشد Technical SEO با تسلط کامل بر Next.js 15 App Router (SSR/SSG/ISR/RSC/Streaming/PPR)، Metadata API، Core Web Vitals، Structured Data، Crawlability و International/Multi-domain SEO هستی. فقط از قابلیت‌های رسمی و غیرمنسوخ Next.js استفاده کن و آخرین Best Practiceهای Google Search و Vercel را مبنا قرار بده. + +## زمینه + +سایت نوبت‌دهی پزشکی «نوبت 724» یک deployment واحد Next.js 15 است که **ده‌ها دامنه شهری مستقل** را سرو می‌کند (مثل `arak-nobat.ir`، `yazd-nobat.ir`، ...) به‌علاوه دامنه اصلی `nobat724.com`. تشخیص شهر از روی `host` header انجام می‌شود (`lib/getStateInfo.js` سمت سرور، `context/ProvinceProvider.js` سمت کلاینت). منبع داده دامنه‌ها و متادیتای هر شهر `data/city.json` است (فیلدهای `domain`، `title`، `description`, `keywords`, `site_name`). زبان کل سایت فارسی، RTL، تقویم جلالی است. slug پزشک/کلینیک همان `uuid` است (`/doctor/${uuid}`). + +وضعیت فعلی چند تناقض معماری جدی دارد که عملاً SEO دامنه‌های شهری را از کار می‌اندازد (شرح در «وضعیت فعلی»). هدف این پرامپت: ممیزی کامل، تصمیم‌گیری صریح درباره استراتژی چند-دامنه‌ای، و پیاده‌سازی اصلاحات. + +## مشکل / هدف + +1. تعیین تکلیف استراتژی canonical در معماری چند-دامنه‌ای (مهم‌ترین تصمیم — بقیه کارها به آن وابسته‌اند). +2. رفع مشکلات شناسایی‌شده در sitemap، robots، metadata، JSON-LD، status codeها و rendering. +3. ممیزی سیستماتیک بقیه حوزه‌ها (تصاویر، performance، pagination، accessibility، caching، internal linking) و رفع موارد یافت‌شده. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `app/layout.js` | metadata سطح ریشه، canonical، JSON-LD سازمان/وب‌سایت، viewport، GA | +| `lib/getCanonicalUrl.js` | تولید canonical — **همه دامنه‌های شهری را به دامنه اصلی canonical می‌کند** | +| `components/CanonicalHandler.js` | تزریق canonical سمت کلاینت با DOM (آنتی‌پترن) | +| `middleware.js` | تزریق `x-pathname` برای canonical | +| `app/sitemap.js` + `utils/sitemap.js` | sitemap داینامیک per-domain | +| `app/robots.js` | robots per-domain + گیت `DEV_MODE` | +| `next.config.js` | security headers (CSP/HSTS/...)، images remotePatterns | +| `app/doctor/[slug]/page.js` | صفحه پزشک: `generateMetadata` + JSON-LD Physician | +| `app/clinic/[slug]/page.js` | صفحه کلینیک: مشابه پزشک (MedicalClinic) | +| `app/blog/[slug]/page.js` | صفحه بلاگ (Article) | +| `app/doctors/page.js`، `app/clinics/page.js` | لیست‌های فیلترشونده با searchParams | +| `app/page.js`، `app/specialties/page.js`، `app/blogs/page.js`، `app/about-us/page.js`، `app/contact-us/` | سایر صفحات عمومی | +| `data/city.json`، `data/state.json` | منبع دامنه/متادیتای شهرها | +| `lib/getStateInfo.js`، `lib/rootCity.js` | تشخیص شهر از host | + +## وضعیت فعلی (یافته‌های ممیزی اولیه — از کد واقعی) + +### ۱. Canonical همه دامنه‌های شهری را نابود می‌کند (Critical) + +`lib/getCanonicalUrl.js` هر دامنه‌ای غیر از `nobat724.com` را به دامنه اصلی canonical می‌کند: + +```js +// lib/getCanonicalUrl.js +if (normalizedHost === "nobat724.com") { + return null; // No canonical needed for main domain +} +// ... +if (matchedCity || normalizedHost !== "nobat724.com") { + return `${MAIN_DOMAIN}${cleanPathname}`; // MAIN_DOMAIN = "https://nobat724.com" +} +``` + +یعنی `arak-nobat.ir/doctors` به گوگل می‌گوید «نسخه اصلی من `nobat724.com/doctors` است» — هیچ دامنه شهری هرگز ایندکس/رتبه نمی‌گیرد. ضمناً: +- دامنه اصلی **هیچ self-canonical ندارد** (`return null`). +- شرط `matchedCity || normalizedHost !== "nobat724.com"` همیشه true است (شاخه دوم)، پس `matchedCity` بی‌اثر است. +- canonical فقط در layout ست می‌شود؛ صفحات dynamic (doctor/clinic/blog) که `generateMetadata` خودشان را دارند و `alternates` برنمی‌گردانند، متادیتای layout را override می‌کنند و **عملاً بدون canonical می‌مانند**. +- query stringها در canonical مدیریت نمی‌شوند (`x-pathname` فقط pathname است — صفحات پارامتری `/doctors?specialty=...` بدون canonical). +- `components/CanonicalHandler.js` با `useEffect` تگ canonical به DOM تزریق می‌کند — گوگل ممکن است ببیند یا نبیند؛ آنتی‌پترن و باید حذف شود. + +### ۲. Sitemap (High) + +```js +// app/sitemap.js +const NOW = new Date(); // module-level — در build/بوت ثابت می‌شود؛ lastmod جعلی +function getCurrentDomain() { + const headersList = headers(); // Next 15: باید await شود + ... +} +``` + +- `lastModified` برای doctor/clinic همیشه `NOW` است (جعلی — گوگل lastmod غیرقابل‌اعتماد را کلاً نادیده می‌گیرد). +- sitemap هر دامنه شهری **همه** پزشکان/کلینیک‌های کشور را لیست می‌کند (fetch بدون فیلتر شهر) — در حالی که صفحات لیست همان دامنه به شهر فیلتر می‌شوند؛ تناقض با استراتژی چند-دامنه‌ای. +- `headers()` بدون `await` (در Next 15 deprecated و در نسخه‌های بعدی می‌شکند). +- fetch با `limit=2000` تک‌صفحه‌ای — بالای ۲۰۰۰ رکورد silent truncation. +- `utils/sitemap.js` دارای `escapeXml` و `createSitemapUrl` است که با Metadata API Route (`app/sitemap.js`) لازم نیستند (Next خودش escape می‌کند) — کد مرده/گمراه‌کننده. + +### ۳. Robots (High) + +```js +// app/robots.js +return { + rules: { userAgent: '*', allow: '/', disallow: ['/dashboard'] }, + sitemap: `${baseUrl}/sitemap.xml`, +}; +``` + +- `/panel`، `/login`، `/login-verify`، `/payment`، `/appointment` (فلوی رزرو کاربر) disallow نشده‌اند. +- `headers()` بدون `await`. +- در حالت `DEV_MODE=TRUE` مسیر sitemap هم حذف می‌شود (درست) ولی صفحات auth-gated فقط با robots بلاک می‌شوند، متای `noindex` per-page ندارند. + +### ۴. Metadata سطح ریشه (High) + +```js +// app/layout.js + + +``` + +- viewport با تگ دستی به‌جای `export const viewport` (روش رسمی Next 15). +- **`metadataBase` هیچ‌جا تعریف نشده** — در معماری چند-دامنه‌ای باید per-request از host ساخته شود تا URLهای نسبی OG/canonical درست resolve شوند. +- تصاویر OG/Twitter همه صفحات لوگوی `https://www.nobat724.com/assets/images/logo.png` است (با `www.` — در حالی که canonical بدون `www` است؛ ناسازگاری هاست). +- `keywords` meta استفاده شده (بی‌اثر برای گوگل؛ تصمیم بگیر نگه‌داری یا حذف). +- JSON-LD `Organization` و `WebSite` روی **همه دامنه‌ها** به `https://www.nobat724.com` اشاره می‌کند — روی `arak-nobat.ir` داده ساختاریافته متعلق به دامنه دیگر است. `SearchAction` هم فقط به دامنه اصلی. + +### ۵. صفحات dynamic — soft 404 و metadata ناقص (High) + +```js +// app/doctor/[slug]/page.js +const doctor = await getDoctor(slug); +if (!doctor) return {}; // metadata خالی +``` + +- وقتی پزشک پیدا نشود `generateMetadata` آبجکت خالی برمی‌گرداند؛ بررسی کن آیا کامپوننت صفحه `notFound()` صدا می‌زند یا با status 200 صفحه نیمه‌خالی رندر می‌شود (**soft 404**). الگوی درست: در صورت null بودن، `notFound()` در body صفحه. +- هیچ‌کدام از صفحات doctor/clinic/blog در `generateMetadata` خود `alternates.canonical` برنمی‌گردانند. +- ISR با `revalidate: 3600` وجود دارد (خوب) — ولی صحت `dynamicParams` و رفتار برای slugهای نامعتبر باید بررسی شود. + +### ۶. صفحات لیستی و پارامتری (Medium) + +`app/doctors/page.js` با `searchParams` رندر SSR می‌شود (force-dynamic ضمنی). صفحات فیلترشده (`?specialty=...&state=...`) hیچ canonical/robots مشخصی ندارند → ریسک ایندکس بی‌نهایت URL پارامتری duplicate. pagination (اگر با پارامتر `page` است) نه `rel prev/next` دارد نه canonical. + +### ۷. Security headers — وضعیت خوب + +`next.config.js` HSTS/X-Frame-Options/CSP/Referrer-Policy/Permissions-Policy دارد. فقط `'unsafe-inline' 'unsafe-eval'` در script-src از دید سختگیرانه ضعیف است — گزارش بده ولی تغییر آن اولویت SEO نیست. + +## وظایف + +### ۰. تصمیم استراتژی چند-دامنه‌ای (پیش‌نیاز همه‌چیز) + +دو استراتژی ممکن را مقایسه و **گزینه A را پیاده‌سازی کن** (مگر اینکه در حین کار شواهدی خلافش پیدا کنی؛ در آن صورت قبل از ادامه به کاربر گزارش بده): + +- **گزینه A — دامنه‌های شهری first-class:** هر دامنه شهری self-canonical دارد و مستقل ایندکس می‌شود. لازمه‌اش: canonical per-host، sitemap فیلترشده به همان شهر، JSON-LD با URL همان دامنه، محتوای متمایز per-city (که با title/description/فیلتر شهریِ موجود در `city.json` و لیست‌های فیلترشده فراهم است). این با معماری فعلی محصول (فیلتر خودکار شهر در `app/doctors/page.js` بر اساس دامنه) سازگار است. +- **گزینه B — تجمیع روی دامنه اصلی:** وضعیت فعلی canonical، ولی آنگاه وجود دامنه‌های شهری از نظر SEO بی‌معناست. + +خروجی این وظیفه: بازنویسی `lib/getCanonicalUrl.js` به یک `buildCanonical(pathname)` که **همیشه** self-canonical روی هاست جاری برمی‌گرداند (بدون www، lowercase، بدون trailing slash، بدون query string به‌جز پارامترهای معنادار whitelisted). حذف کامل `components/CanonicalHandler.js` و همه usageهایش. + +### ۱. `metadataBase` و canonical per-page + +- در `app/layout.js` از host جاری `metadataBase` بساز: + +```js +export async function generateMetadata() { + const headersList = await headers(); + const host = (headersList.get("host") || "nobat724.com").toLowerCase().replace(/^www\./, ""); + const metadataBase = new URL(`https://${host}`); + // ... alternates: { canonical: pathname } — با metadataBase به URL مطلق resolve می‌شود +} +``` + +- در `generateMetadata` تک‌تک صفحات (doctor/clinic/blog/doctors/clinics/specialties/blogs/about-us/contact-us/home) `alternates.canonical` نسبی اضافه کن (مثلاً `/doctor/${slug}`). +- صفحات پارامتری: canonical به نسخه بدون پارامتر (یا فقط با پارامترهای whitelisted مثل `specialty`). +- `viewport` را به `export const viewport` منتقل کن و تگ دستی را حذف کن. + +### ۲. اصلاح sitemap + +- `headers()` را `await` کن. +- sitemap هر دامنه شهری را به همان شهر فیلتر کن (همان پارامترهای `state`/`city` که `app/doctors/page.js` استفاده می‌کند)؛ دامنه اصلی همه را بگیرد. اگر API فیلتر شهر برای doctors/clinics دارد از همان استفاده کن — قرارداد را از `buildDoctorParams` در `helper` و `services/` استخراج کن. +- `lastModified` جعلی (`NOW`) را حذف کن: اگر فیلد تاریخ واقعی (`updated`/`created`) در پاسخ API هست استفاده کن، وگرنه `lastModified` را برای آن entry اصلاً نفرست. +- pagination فراخوانی API (به‌جای `limit=2000` تک‌صفحه) یا حداقل log هشدار در truncation. +- توابع بلااستفاده `escapeXml`/`createSitemapUrl` در `utils/sitemap.js` را حذف یا مستند کن. + +### ۳. اصلاح robots + +- `await headers()`. +- disallow: `/panel`, `/dashboard`, `/login`, `/login-verify`, `/payment`, `/appointment` (بررسی کن `appointment` صفحه عمومی SEO-دار نباشد — اگر فلوی رزرو شخصی است بلاک شود). +- به صفحات auth-gated (panel/dashboard/login) `robots: { index: false }` per-page اضافه کن (layout آن route group). + +### ۴. JSON-LD چند-دامنه‌ای + +- `Organization`/`WebSite` در `app/layout.js`: `url` و `SearchAction.target` را از host جاری بساز؛ `name` از `matchedCity.site_name`. +- صفحه doctor: schema `Physician` موجود را validate کن (فیلدهای `address`, `medicalSpecialty`, `image`, `url` مطلق روی دامنه جاری). clinic (`MedicalClinic`) و blog (`Article`) همین‌طور. +- `BreadcrumbList` به صفحات doctor/clinic/blog اضافه کن (خانه → لیست → آیتم) با URLهای دامنه جاری. +- خروجی JSON-LD را با `JSON.stringify(...).replace(/` خام را گزارش و به `next/image` با `sizes` مناسب تبدیل کن (ابعاد مشخص → جلوگیری از CLS). +- فونت Vazir: `font-display: swap` موجود است؛ preload فایل woff2 اصلی را در layout اضافه کن. +- اسکریپت GA با `@next/third-parties` لود می‌شود (بهینه است — دست نزن). + +### ۷. Pagination و صفحات پارامتری + +- در لیست doctors/clinics: لینک‌های صفحه بعد/قبل باید `` واقعی قابل crawl باشند (نه فقط onClick). بررسی کن `components/doctors/` چطور pagination می‌سازد؛ در صورت client-only بودن، به `` با href پارامتردار تبدیل کن. +- صفحات با فیلترهای ترکیبی: `robots: { index: false, follow: true }` برای ترکیب‌های بیش از یک فیلتر، تا crawl budget هدر نرود (الگوی رایج سایت‌های listing). + +### ۸. گزارش نهایی ممیزی + +برای هر مشکل یافت‌شده/رفع‌شده گزارش بده با: عنوان، علت، تأثیر بر SEO، اولویت (Critical/High/Medium/Low)، راه‌حل اعمال‌شده، فایل‌های تغییرکرده. مواردی که عمداً تغییر ندادی (مثل `'unsafe-inline'` در CSP یا `keywords` meta) را با دلیل در بخش «بررسی شد — تغییر لازم نیست/تصمیم محصولی» لیست کن. حوزه‌های چک‌لیست که مشکلی نداشتند (mobile viewport، compress، poweredByHeader، HSTS و…) را هم یک‌خطی تأیید کن. + +## نکات مهم + +- **همه تغییرات باید نسبت به host جاری relative باشند** — هیچ URL هاردکد `nobat724.com` در metadata/JSON-LD/sitemap باقی نماند مگر آگاهانه (لوگوی fallback OG اشکالی ندارد ولی ترجیحاً از دامنه جاری سرو شود اگر asset موجود است). +- ناسازگاری `www.nobat724.com` (در تصاویر OG و JSON-LD) با `nobat724.com` (در canonical) را یکدست کن — نسخه بدون `www` مبنا. +- Next.js 15: `params`، `searchParams` و `headers()` همگی **باید await شوند**. +- hreflang کاربردی ندارد (همه دامنه‌ها fa-IR هستند) — به‌جایش تمایز محتوایی per-city ملاک است؛ hreflang اضافه نکن. +- `DEV_MODE=TRUE` رفتار noindex سراسری دارد — این مکانیزم را نشکن؛ در همه تغییرات robots/metadata حفظش کن. +- تست‌های موجود (`lib/getStateInfo.test.js`, `lib/multiDomainClient.test.js`, `services.test.js`, vitest) را بعد از تغییرات اجرا کن: `npm run test` (یا `npx vitest run`). برای `getCanonicalUrl` بازنویسی‌شده تست بنویس (هاست شهری، هاست اصلی، www، پورت dev، query string). +- تست دستی چند-دامنه‌ای: dev server با `HOST=yazd-nobat.localhost` بالا می‌آید (`npm run dev`)؛ برای دامنه دیگر `HOST` را موقتاً عوض کن. خروجی `curl -s http://yazd-nobat.localhost:3000 | grep -i canonical` و `/sitemap.xml` و `/robots.txt` را برای حداقل دو هاست مقایسه کن. +- بعد از اتمام، `npm run build` باید بدون خطا پاس شود و در خروجی build بررسی کن هیچ صفحه‌ای ناخواسته از static به dynamic (یا برعکس) جابه‌جا نشده باشد. +- استایل/زبان: همه رشته‌های جدید فارسی، RTL؛ کد مطابق الگوهای موجود پروژه (jsx، بدون TypeScript). diff --git a/app/about-us/page.js b/app/about-us/page.js index 763e2d8..9f96861 100644 --- a/app/about-us/page.js +++ b/app/about-us/page.js @@ -7,7 +7,7 @@ export async function generateMetadata() { const siteName = matchedCity?.site_name || "نوبت 724"; const title = `درباره ما | ${siteName}`; const description = `آشنایی با ${siteName}، سیستم آنلاین نوبت‌دهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`; - const image = "https://www.nobat724.com/assets/images/logo.png"; + const image = "https://nobat724.com/assets/images/logo.png"; return { title, description, diff --git a/app/appointment/[doctorId]/page.js b/app/appointment/[doctorId]/page.js index f4ff9c0..82be7d6 100644 --- a/app/appointment/[doctorId]/page.js +++ b/app/appointment/[doctorId]/page.js @@ -2,6 +2,10 @@ import AppointmentPage from "@/components/appointment"; import { getStateInfo } from "@/lib/getStateInfo"; import { axiosInstance } from "@/lib/req"; +export const metadata = { + robots: { index: false, follow: false }, +}; + async function Appointment({ params }) { const { doctorId } = await params; const { matchedCity } = await getStateInfo(); diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js index 256f44f..319b753 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.js @@ -1,11 +1,14 @@ import { cache } from "react"; import BlogPage from "@/components/blog"; 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 { safeJsonLd } from "@/lib/sanitize"; import { normalizeBlog, imageUrl } from "@/helper"; -const FALLBACK_IMG = "https://www.nobat724.com/assets/images/logo.png"; +const FALLBACK_IMG = "https://nobat724.com/assets/images/logo.png"; const API_URL = process.env.NEXT_PUBLIC_API_URL; const getBlog = cache(async (slug) => { @@ -40,6 +43,8 @@ export async function generateMetadata({ params }) { return { title, description, + // محتوای بلاگ روی همه‌ی دامنه‌های شهری یکسان است — تجمیع اعتبار روی دامنه‌ی اصلی + alternates: { canonical: `https://nobat724.com/blog/${slug}` }, openGraph: { title, description, @@ -63,8 +68,10 @@ export async function generateMetadata({ params }) { async function Blog({ params }) { const { slug } = await params; + const origin = await getRequestOrigin(); const blog = normalizeBlog(await getBlog(slug)); + if (!blog) notFound(); let relatedBlogs = []; const relatedResponse = await fetchReq( @@ -93,8 +100,8 @@ async function Blog({ params }) { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" }, - { "@type": "ListItem", position: 2, name: "مقالات", item: "https://www.nobat724.com/blogs" }, + { "@type": "ListItem", position: 1, name: "خانه", item: origin }, + { "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` }, { "@type": "ListItem", position: 3, name: blog.title }, ], } @@ -105,13 +112,13 @@ async function Blog({ params }) { {jsonLd && (