Implement multi-domain SEO improvements:

- Add metadata to login and login-verify pages to prevent indexing.
- Update robots.txt to disallow additional sensitive paths.
- Enhance sitemap generation to filter by city and include accurate last modified dates.
- Refactor canonical URL generation to support multi-domain architecture, ensuring self-canonicalization for city domains.
- Remove deprecated CanonicalHandler component and streamline canonical URL handling.
- Introduce safe JSON-LD output to prevent XSS vulnerabilities.
- Add payment layout with appropriate metadata to prevent indexing.
- Conduct a comprehensive technical SEO audit and implement necessary fixes across the application.
This commit is contained in:
hamed
2026-07-05 15:47:47 +03:30
parent 522caf6b1b
commit b04bb45ca1
24 changed files with 595 additions and 329 deletions
@@ -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
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
```
- 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(/</g, '\\u003c')` یا sanitize موجود در `lib/sanitize.js` در برابر XSS امن کن.
### ۵. Soft 404 و status codes
- در `app/doctor/[slug]/page.js`، `app/clinic/[slug]/page.js`، `app/blog/[slug]/page.js`: اگر fetch نتیجه null داد `notFound()` صدا بزن (از `next/navigation`). `generateMetadata` در حالت null هم `robots: { index: false }` یا metadata حداقلی برگرداند.
- `app/not-found.js` و `app/error.js` را بررسی کن که متای noindex و status صحیح داشته باشند.
### ۶. ممیزی rendering و performance
- برای هر route مشخص کن الان static است یا dynamic (خروجی `npm run build` را بخوان). صفحات محتوایی (about-us, contact-us, specialties, blogs) نباید بی‌دلیل dynamic باشند — دقت کن `headers()` در layout همه‌چیز را dynamic می‌کند؛ این trade-off معماری چند-دامنه‌ای است، مستندش کن و جایی که ممکن است ISR per-route حفظ شود.
- LCP: تصویر hero/بنر صفحه اصلی `priority` داشته باشد؛ استفاده از `next/image` را در کامپوننت‌های اصلی (home, doctor card, doctor page) بررسی کن — هر `<img>` خام را گزارش و به `next/image` با `sizes` مناسب تبدیل کن (ابعاد مشخص → جلوگیری از CLS).
- فونت Vazir: `font-display: swap` موجود است؛ preload فایل woff2 اصلی را در layout اضافه کن.
- اسکریپت GA با `@next/third-parties` لود می‌شود (بهینه است — دست نزن).
### ۷. Pagination و صفحات پارامتری
- در لیست doctors/clinics: لینک‌های صفحه بعد/قبل باید `<a href>` واقعی قابل crawl باشند (نه فقط onClick). بررسی کن `components/doctors/` چطور pagination می‌سازد؛ در صورت client-only بودن، به `<Link>` با 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).