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:
@@ -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).
|
||||
Reference in New Issue
Block a user