chore(prompt): record SEO audit and split remaining work

P1-P11 of the live SEO audit are implemented and verified against a local
production build, so the audit file becomes a reference rather than a
task list: it now records what shipped, the root causes that differed
from the original hypotheses, and the deliberate trade-offs.

Remaining work is split into smaller prompts, ordered by dependency:

- seo-post-deploy-verification: the acceptance criteria were "curl on
  production" but were only run against a local build
- blog-city-scoping-activate: blocked on the backend blog city column
- sitemap-simplify-with-city: drops the 35-sweep workaround once the
  doctors list exposes city

Each names its blocking dependency and carries reference numbers so a
regression is visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-19 08:09:21 +03:30
co-authored by Claude Opus 4.8
parent c64a4a7d69
commit 21f5f07bc6
4 changed files with 892 additions and 0 deletions
@@ -0,0 +1,156 @@
<div dir="rtl" markdown="1">
# فعال‌سازی بلاگ شهر-محور (بعد از افزودن شهر در backend)
## پروژه
`nobat724_front`
**پیش‌نیاز قطعی:** `clinicpro/.claude/prompt/blog-city-field.md` اجرا و deploy شده باشد.
تا وقتی `GET /api/v1/blogs` فیلد شهر برنگرداند، این پرامپت کاری برای انجام ندارد.
## زمینه
منطق شهر-محورِ بلاگ سمت فرانت **از قبل نوشته شده** ولی چون داده‌اش وجود ندارد غیرفعال است. این پرامپت آن را فعال، تکمیل و تست می‌کند.
وضعیت داده هنگام نگارش: `GET /api/v1/blogs` صفر رکورد دارد (`totalRecords: 0`) و هیچ فیلد شهری ندارد.
## وضعیت فعلی (کدی که آمادهٔ فعال‌شدن است)
`app/blog/[slug]/page.js` — canonical و شهر از همان هلپر مشترک:
```js
const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId);
const origin = await getEntityOrigin(blogCityId);
return {
title,
description,
alternates: { canonical: `${origin}/blog/${slug}` },
// ...
};
```
JSON-LD مقاله فقط با وجود شهر `spatialCoverage` می‌گیرد:
```js
...(blogCity && {
spatialCoverage: { "@type": "Place", name: blogCity.name },
}),
```
`components/blog/head/index.js` — برچسب بصری، فقط با `cityName`:
```jsx
{cityName && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
مخصوص شهر:
</p>
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
{cityName}
</p>
</div>
)}
```
`lib/domainHelpers.js` — استخراج شهر، هر سه شکل پاسخ را می‌پذیرد:
```js
export function extractEntityCityId(entity) {
if (!entity) return null;
const candidates = [
entity.city_id,
...(Array.isArray(entity.city) ? entity.city.map((c) => c?.id) : [entity.city?.id]),
...(Array.isArray(entity.address) ? entity.address.map((a) => a?.city?.id) : []),
].filter((id) => id != null);
const withDomain = candidates.find((id) => findDomainByCityId(id));
return withDomain ?? candidates[0] ?? null;
}
```
`app/sitemap.js` — مسیریابی پست به sitemap دامنهٔ canonical خودش:
```js
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;
})
// ...
}
```
## وظایف
### ۱. تأیید سازگاری شکل پاسخ
اول پاسخ واقعی را ببین:
```bash
curl -s "https://clinic-pro.ir/api/v1/blogs?page=1&limit=5" | jq '.data.data[0]'
```
بررسی کن `extractEntityCityId` روی شکل واقعی جواب می‌دهد. اگر backend شکل دیگری داد (مثلاً `city` رشته به‌جای آبجکت)، `extractEntityCityId` را گسترش بده — **نه** یک استخراج جدا برای بلاگ بنویس.
`normalizeBlog` در `helper/index.js` نباید فیلد شهر را حذف کند؛ اگر فیلدها را صریح map می‌کند، `city` را اضافه کن.
### ۲. لیست بلاگ per-domain
`app/blogs/page.js` روی هر دامنهٔ شهری باید پست‌های آن شهر + سراسری را بگیرد. با پارامتر `city_id` که backend اضافه می‌کند:
- روی دامنهٔ شهری: `city_id` شهر همان دامنه (از `getStateInfo`)
- روی دامنهٔ ریشه (`isRoot`): بدون پارامتر — همهٔ پست‌ها
canonical لیست بلاگ **self روی همان دامنه** بماند (C1-a). این از قبل درست است — کانونیکال hardcode شده به دامنهٔ اصلی قبلاً حذف شده:
```js
// C1-a — لیست بلاگ per-domain است (پست‌های همان شهر + سراسری)؛
// canonical لایهٔ layout (self روی همان دامنه) درست است.
```
### ۳. عنوان و متادیتای شهرمحور
`generateMetadata` صفحهٔ پست: اگر پست شهر دارد، نام شهر در `title`/`description` بیاید. اگر سراسری است، هیچ نام شهری اضافه نشود (نه نام شهرِ دامنهٔ سرو‌کننده).
### ۴. تست
با دادهٔ واقعی (حداقل یک پست شهری + یک پست سراسری) تأیید کن:
```bash
S=<slug-پست-یاسوجی>
G=<slug-پست-سراسری>
# پست شهری: canonical به دامنهٔ شهر، روی هر دامنه‌ای
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
curl -s "https://nobat724.com/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o "یاسوج" # نام شهر در HTML قابل‌مشاهده
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'spatialCoverage'
# پست سراسری: self-canonical، بدون نام شهر
curl -s "https://nobat724.com/blog/$G" | grep -o 'rel="canonical" href="[^"]*"' # → nobat724.com
curl -s "https://yasuj-nobat.ir/blog/$G" | grep -c 'spatialCoverage' # → 0
# sitemap: هر پست فقط در دامنهٔ canonical خودش
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "$S" # → 1
curl -s https://nobat724.com/sitemap.xml | grep -c "$S" # → 0
curl -s https://nobat724.com/sitemap.xml | grep -c "$G" # → 1
curl -s https://nobat724.com/sitemap.xml | grep -c "/blog/" # → بیشتر از ۰
```
تست واحد برای `extractEntityCityId` روی شکل واقعی پاسخ بلاگ به `lib/domainHelpers.test.js` اضافه کن.
## نکات مهم
- **پست سراسری حالت دائمی است، نه استثنا.** پستی بدون شهر باید روی دامنهٔ اصلی self-canonical شود و در همهٔ لیست‌ها دیده شود. هرگز شهرِ دامنهٔ سرو‌کننده را به پست بدون شهر نسبت نده.
- `findCityById` رکورد ریشه (`id: 600` = `nobat724.com`) را شهر حساب نمی‌کند و `null` برمی‌گرداند — یعنی پستی که به‌اشتباه به رکورد ریشه وصل شده، سراسری تلقی می‌شود. رفتار درست است.
- برچسب بصری شهر با تایپوگرافی «نویسنده/تاریخ» هم‌خانواده است؛ عنصر بصری ناهماهنگ اضافه نکن.
- الگوی Soft-404 (`app/blog/[slug]/page.js`) بعد از تغییرات همچنان باید کار کند:
`curl -o /dev/null -w "%{http_code}" https://nobat724.com/blog/invalid-xxx``404`.
توجه: `app/blog/[slug]/loading.js` عمداً حذف شده — دوباره اضافه‌اش نکن، وگرنه Soft-404 برمی‌گردد.
</div>
@@ -0,0 +1,464 @@
<div dir="rtl" markdown="1">
# تقسیم ممیزی SEO به پرامپت‌های کوچک اجرایی — nobat724
> **وضعیت: P1 تا P11 پیاده و روی build محلی production تأیید شده‌اند (هنوز deploy نشده‌اند).**
> این فایل حالا **مرجع ممیزی** است، نه فهرست کار. کارهای باقی‌مانده به پرامپت‌های کوچک‌تر تقسیم شده‌اند — جدول «کارهای باقی‌مانده» پایین‌تر.
> بخش‌های P1–P11 برای ثبت تصمیم‌ها و یافته‌های اصلی نگه داشته شده‌اند.
## چک‌لیست کلی — انجام‌شده
| # | پرامپت | وضعیت | نتیجه |
| --- | --------------------------------------- | ----- | ----- |
| P1 | بررسی robots.txt | ✅ | مشکلی نبود؛ همهٔ مسیرها مجاز، sitemap معرفی شده. بدون تغییر کد |
| P2 | هلپرهای مشترک | ✅ | `lib/domainHelpers.js` (+ `findCityById`، `extractEntityCityId`) |
| P3 | رفع noindex سیستمیک لیست‌ها | ✅ | علت واقعی: **mutate شدن شیء `searchParams` مشترک** بین بدنهٔ صفحه و `generateMetadata` — نه منطق شمارنده. `lib/listingRobots.js` |
| P4 | استراتژی canonical سه‌دسته + pagination | ✅ | `lib/getCanonicalUrl.js` بازنویسی شد؛ `x-search` به `proxy.js` اضافه شد؛ تناقض canonical/JSON-LD رفع شد |
| P5 | بازسازی sitemap | ✅ | معیار «claimed» → `lib/entityQuality.js`؛ **باگ کشف‌شده:** سقف خاموش ۵۰ رکوردی API، یاسوج ۴۸ → ۴۵۴ پزشک |
| P6 | صفحات موجودیت: 404 و noindex | ✅ | علت واقعی Soft-404: **`loading.js`** (Suspense → استریم ۲۰۰). هر سه حذف شدند |
| P7 | اصلاح H1ها | ✅ | همهٔ صفحات دقیقاً یک h1؛ کلینیک ۴ → ۱؛ Title ریشه اصلاح شد |
| P8 | یکپارچه‌سازی Breadcrumb | ✅ | `Pageguide` سمانتیک شد + `BreadcrumbList` از همان منبع داده |
| P9 | صفحات فرود تخصص | ✅ | `app/specialties/[slug]/` + مهاجرت ~۹۰ لینک کارت |
| P10 | بلاگ شهر-محور | ⚠️ | **کد آماده ولی غیرفعال** — API بلاگ صفر رکورد دارد و فیلد شهر ندارد. نیازمند تغییر backend |
| P11 | FAQ schema + متن مقدمه | ✅ | `FAQPage` جدا در صفحات پزشک و تخصص؛ متن مقدمهٔ یکتا با تست شمارش کلمه |
## کارهای باقی‌مانده — پرامپت‌های کوچک
به ترتیب وابستگی. **backend اول.**
| # | پرامپت | چرا |
|---|--------|-----|
| ۱ | `clinicpro/.claude/prompt/doctors-list-city-and-limit.md` | افزودن `city` به پاسخ لیست پزشکان + شفاف‌کردن سقف `limit` |
| ۲ | `clinicpro/.claude/prompt/blog-city-field.md` | افزودن شهر به Entity بلاگ — پیش‌نیاز P10 |
| ۳ | `clinicpro/.claude/prompt/cleanup-polluted-doctor-clinic-records.md` | پاک‌سازی رکوردهای `name = شماره‌تلفن` و `"test"` + اعتبارسنجی |
| ۴ | `nobat724_front/.claude/prompt/seo-post-deploy-verification.md` | اجرای معیارهای پذیرش روی دامنه‌های واقعی بعد از deploy |
| ۵ | `nobat724_front/.claude/prompt/blog-city-scoping-activate.md` | فعال‌سازی P10 — وابسته به ۲ |
| ۶ | `nobat724_front/.claude/prompt/sitemap-simplify-with-city.md` | حذف ۳۵ sweep پرهزینه — وابسته به ۱ |
پرامپت ۴ مستقل است و می‌تواند بلافاصله بعد از deploy اجرا شود.
## تصمیم‌های آگاهانه (side-effect نیستند)
- **M3-B:** صفحات `?page=2+` خودشان canonical دارند و ایندکس‌پذیر می‌مانند (collapse به صفحهٔ ۱ انتخاب نشد).
- **sitemap-index پیاده نشد:** ~۲٬۵۰۰ URL در هر دامنه، بسیار زیر سقف ۵۰k. جزئیات در پرامپت ۶.
- **`loading.js` سه مسیر موجودیت حذف شد:** بهای رفع Soft-404. اسکلت لودینگ هنگام ناوبری کلاینتی از دست رفت.
- **«خانه» به breadcrumb صفحهٔ `/clinics` اضافه شد:** تغییر محتوایی عمدی، هم‌راستا با schema که از قبل روی `/clinic/[id]` بود.
- **h1 صفحهٔ اصلی روی شعار نشست** (نه یک جملهٔ کیوردی جدید): شعار هر شهر در `city.json` از قبل شهرمحور و کیورددار است.
---
## بلوک زمینهٔ مشترک (ابتدای هر پرامپت کپی شود)
```
## نقش
تو یک متخصص ارشد Technical SEO + مهندس Next.js 15 App Router هستی. هر تغییر با کمترین ریسک و مطابق آخرین Best Practiceهای Google Search و Next.js. فقط قابلیت‌های رسمی و غیرمنسوخ Next.js. زبان خروجی و رشته‌ها فارسی، RTL، تقویم جلالی.
## زمینه
پروژه `nobat724_front`: یک deployment واحد Next.js 15 که ۳۵ دامنهٔ شهری مستقل (data/city.json — مثل tehran-nobat.ir, yazd-nobat.ir) + دامنهٔ اصلی nobat724.com را سرو می‌کند. تشخیص شهر از host header (lib/getStateInfo.js سرور، context/ProvinceProvider.js کلاینت). رکورد id:600 در city.json دامنهٔ ریشه است نه شهر (lib/rootCity.js). slug پزشک/کلینیک = uuid. صفحات SSR (streaming با Suspense).
## فایل‌های کلیدی
lib/getCanonicalUrl.js (تولید canonical — ریشهٔ باگ canonical) · app/sitemap.js (sitemap per-domain) · app/doctors/page.js (metadata لیست + listingRobots — باگ noindex سیستمیک) · app/clinics/page.js + components/clinics/ · app/doctor/[slug]/page.js (Physician schema + noindex برای unclaimed) · app/clinic/[slug]/page.js (canonical متناقض با JSON-LD، بدون noindex برای کلینیک خالی، چند h1) · app/specialties/page.js + components/specialties/ · app/blog/[slug]/page.js + app/blog/page.js · app/page.js + components/home/ · data/city.json + data/specialties.json + data/state.json · next.config.js (security headers — دست‌نخورده)
## محدودیت‌های عمومی
- security headers و CSP در next.config.js را نشکن.
- هر تغییر canonical/robots را با curl روی حداقل یک دامنهٔ شهری + دامنهٔ اصلی تأیید کن.
- خارج از دامنهٔ اپ اقدام نکن (تنظیمات CDN/ArvanCloud = DevOps، فقط در گزارش یادآوری کن).
- پس از تغییر کد، `graphify update .` را اجرا کن.
- در پایان: گزارش کوتاه تغییرات + خروجی curl معیارهای پذیرش.
## محدودیت‌های UI (در همهٔ پرامپت‌ها الزامی)
- **UI سایت نباید به‌هم بریزد.** تغییرات SEO (تعویض تگ h1↔span/p/h2، canonical، robots، schema) نباید هیچ تغییر بصری ایجاد کنند — هنگام تعویض تگ، className و استایل‌های عنصر قبلی را عیناً روی تگ جدید نگه‌دار.
- **صفحهٔ جدید = کامپوننت‌های موجود.** برای هر صفحهٔ جدید (صفحات تخصص، breadcrumb، بلاگ) از کامپوننت‌های موجود پروژه استفاده کن (کارت پزشک، لیست، دکمه‌ها، layout و header/footer فعلی) — کامپوننت جدید فقط وقتی بساز که هیچ معادل موجودی نباشد، و در آن صورت با همان design tokenها و کلاس‌های Tailwind پروژه.
- **ساختار صفحات جدید دقیقاً شبیه UI فعلی باشد.** صفحهٔ /specialties/[slug] باید از نظر چیدمان، فاصله‌گذاری، تایپوگرافی و رنگ مثل /doctors فعلی به نظر برسد؛ کاربر نباید حس کند وارد بخش متفاوتی از سایت شده.
- بعد از هر تغییر، صفحات تغییریافته را از نظر بصری با نسخهٔ قبل مقایسه کن (اسکرین‌شات یا بازبینی دستی) و در گزارش تأیید کن که رگرسیون بصری نداریم.
## نقاط قوت فعلی (تخریب نشوند)
اسکیمای Physician/Review/Geo · هدرهای امنیتی HSTS/CSP · ریدایرکت www→non-www با 301 · صفحهٔ ۴۰۴ عمومی درست.
```
---
## مرجع: فهرست کامل یافته‌های زندهٔ ممیزی (تأییدشده با HTML واقعی)
این بخش مرجع است؛ هر پرامپت یافته‌های مرتبط خودش را جداگانه دارد، اما تصویر کامل اینجاست:
- `yazd-nobat.ir/` و `yazd-nobat.ir/doctors` → canonical به `nobat724.com` (باگ سراسری canonical).
- `yasuj-nobat.ir/doctors` **بدون هیچ query**`robots: noindex, follow` + canonical به دامنهٔ اصلی. علت: `city`/`state` خودکار از host تزریق می‌شوند و شمارندهٔ `listingRobots` همیشه ۲+ می‌شود ⇒ صفحهٔ اصلی لیست روی **همهٔ ۳۵ دامنه همیشه noindex** است.
- `yasuj-nobat.ir/clinics` → همان دو مشکل (noindex + canonical غلط) ⇒ باگ فقط `/doctors` نیست.
- `yasuj-nobat.ir/specialties` → canonical غلط اما **بدون** noindex ⇒ `listingRobots` بین doctors/clinics مشترک است، specialties مسیر جداست. دو `<h1>` بدون نام شهر. ~۹۰ لینک کارت تخصص به `/doctors?specialty=X&city=…&state=…`.
- `yasuj-nobat.ir/clinic/9c163d69-…`**تناقض زنده:** canonical به `nobat724.com` اما `MedicalClinic.url` و کل `BreadcrumbList` به `yasuj-nobat.ir`. کلینیک `is_active:false` با نام «09398631203» بدون noindex ایندکس‌پذیر (نامتقارن با پزشکان). چهار `<h1>` (نام + سه عنوان بخش). schema هست ولی breadcrumb بصری نیست (معکوس `/clinics`). Title = «09398631203 | یاسوج نوبت».
- `yasuj-nobat.ir/contact-us` → canonical به دامنهٔ اصلی، **بدون** noindex — برای صفحات استاتیک مشترک این canonical احتمالاً درست است (استثنای C1-c).
- `yasuj-nobat.ir/about-us` → canonical مشابه اما **با** noindex ⇒ robots صفحات استاتیک ناسازگار است.
- دادهٔ embedded یاسوج: `totalRecords: 456, totalPages: 38` ⇒ با query-stripping فعلی، صفحات ۲ تا ۳۸ به صفحهٔ ۱ collapse می‌شوند.
- HTML اولیه ۶ کارت اسکلت با `href="/doctor/undefined"` دارد ⇒ رندر streaming با Suspense است، نه SSR همزمان کامل.
- رکوردهای دادهٔ آلوده: پزشک با `name:"09390039833"` و `name:"test"` با `owner_status:"claimed"` در نتایج عمومی.
- `nobat724.com/doctors` → بدون هیچ `<h1>`. H1 صفحهٔ اصلی = «با نوبت 724». Title روت /doctors = «پزشکان نوبت 724 …» (نام رکورد root به‌جای شهر).
- `/doctor/<uuid-نامعتبر>` → کد 200 (Soft-404).
- `nobat724.com/sitemap.xml` → فقط ۱۳ URL (۷ استاتیک + ۴ پزشک claimed + ۲ کلینیک + ۰ بلاگ).
- هیچ روت تمیز specialty/city و هیچ `FAQPage` schema در کل سایت نیست.
- بلاگ شهر-محور نیست با اینکه هر شهر نمایندهٔ محتوایی دارد.
---
## P1 — بررسی robots.txt (پیش‌نیاز اعتبارسنجی)
> پیش‌نیاز: هیچ. اجرا: قبل از همه — اگر مسیرها مسدود باشند بقیهٔ کارها بی‌اثرند.
```
[بلوک زمینهٔ مشترک]
## تسک
فقط بررسی و گزارش — بدون تغییر کد مگر مشکل پیدا شود.
1. robots.txt دامنهٔ اصلی و حداقل دو دامنهٔ شهری را بگیر:
curl -s https://nobat724.com/robots.txt
curl -s https://yasuj-nobat.ir/robots.txt
curl -s https://tehran-nobat.ir/robots.txt
2. تأیید کن:
- مسیرهای /doctors, /clinics, /specialties, /blog, /doctor/, /clinic/ مسدود (Disallow) نیستند.
- sitemap.xml با خط Sitemap: معرفی شده است.
- مسیرهای پنل/لاگین (در صورت وجود) Disallow هستند.
3. اگر مسیری به‌اشتباه مسدود است، فایل/روت تولیدکنندهٔ robots.txt را پیدا و اصلاح کن (احتمالاً app/robots.js یا فایل استاتیک public/robots.txt).
## معیار پذیرش
خروجی کامل robots.txt هر سه دامنه در گزارش نقل شود + جدول «مسیر × وضعیت (مجاز/مسدود)». اگر اصلاحی انجام شد، curl بعد از deploy هم ضمیمه شود.
```
---
## P2 — هلپرهای مشترک (زیرساخت)
> پیش‌نیاز: هیچ. دو تابع که چند پرامپت بعدی مصرف می‌کنند — دو پیاده‌سازی جدا ریسک واگرایی دارد، پس اول و یک‌بار ساخته می‌شوند.
```
[بلوک زمینهٔ مشترک]
## تسک
دو هلپر مشترک بساز (محل پیشنهادی: lib/domainHelpers.js یا کنار getStateInfo):
### ۱) findDomainByCityId(cityId)
- ورودی: city_id یک موجودیت (پزشک/کلینیک/پست بلاگ).
- خروجی: دامنهٔ شهری متناظر از data/city.json (مثلاً "yasuj-nobat.ir")، یا null اگر شهر دامنهٔ اختصاصی ندارد.
- رکورد id:600 (دامنهٔ ریشه) هرگز به‌عنوان «شهر» برنگردد.
- مصرف‌کنندگان آینده: canonical موجودیت‌ها (P4)، sitemap (P5)، بلاگ (P10).
### ۲) resolveCityDisplayName(cityInfo)
- ورودی: آبجکت شهر از getStateInfo.
- خروجی: نام قابل‌نمایش شهر برای H1/Title. اگر isRoot (رکورد id:600) → «ایران»، نه نام رکورد root («نوبت 724»).
- مصرف‌کنندگان آینده: H1 صفحات لیست (P7)، صفحات تخصص (P9).
## نکته
یافتهٔ زنده: Title روت /doctors الان «پزشکان نوبت 724 …» رندر می‌شود چون نام رکورد root به‌جای شهر می‌نشیند — resolveCityDisplayName دقیقاً برای رفع این است.
## معیار پذیرش
- تست واحد یا اسکریپت کوچک: findDomainByCityId برای city_id یاسوج → "yasuj-nobat.ir"؛ برای city_id بدون دامنه → null؛ برای id:600 → null.
- resolveCityDisplayName برای رکورد root → «ایران»؛ برای یاسوج → «یاسوج».
```
---
## P3 — رفع noindex سیستمیک صفحات لیست (C5 — بدترین باگ)
> پیش‌نیاز: P2 (برای تشخیص «شهر host»). مهم‌ترین فیکس تک‌خطی‌نما با بیشترین اثر.
```
[بلوک زمینهٔ مشترک]
## یافتهٔ زندهٔ تأییدشده
- yasuj-nobat.ir/doctors و yasuj-nobat.ir/clinics — هر دو بدون هیچ query string — robots: noindex, follow دارند.
- علت: city/state روی سرور خودکار از host استنتاج و به شیء پارامترها تزریق می‌شوند؛ منطق listingRobots («۲+ پارامتر → noindex») همیشه این دو را می‌شمارد و همیشه ۲+ می‌شود.
- نتیجه: صفحات اصلی لیست پزشکان و کلینیک‌ها روی هر ۳۵ دامنه همیشه noindex هستند — مهم‌ترین صفحات هر دامنه برای «پزشک + شهر».
- /specialties این باگ را ندارد (مسیر جدا) — یعنی listingRobots بین /doctors و /clinics مشترک است؛ همهٔ مصرف‌کنندگانش را پوشش بده، نه فقط app/doctors/page.js.
- یافتهٔ مرتبط: about-us هم noindex دارد ولی contact-us ندارد — robots در کل اپ ناسازگار است؛ هنگام فیکس، همهٔ نقاط اعمال robots را یک‌جا فهرست و بازبینی کن.
## تسک
1. در listingRobots: پارامترهای city/state وقتی مقدارشان برابر شهر/استان استنتاج‌شده از host فعلی است، از شمارش «۲+ فیلتر» کاملاً حذف شوند — چه از query آمده باشند چه تزریق سمت سرور.
2. فقط فیلترهای صریحاً کاربر-انتخاب‌شده بشمارند: تخصص، جنسیت، مرتب‌سازی غیرپیش‌فرض، city/state متفاوت از host.
3. قاعدهٔ اصلی (noindex برای جستجوی نام یا فیلتر واقعی چندگانه) حفظ شود.
4. robots صفحهٔ about-us را هم به index برگردان (صفحهٔ استاتیک نباید noindex باشد؛ canonical آن در P4 تعیین می‌شود).
## معیار پذیرش
curl -s https://yasuj-nobat.ir/doctors | grep -i robots # → index یا غایب
curl -s https://yasuj-nobat.ir/clinics | grep -i robots # → index یا غایب
curl -s https://tehran-nobat.ir/doctors | grep -i robots # → index یا غایب
curl -s https://yazd-nobat.ir/doctors | grep -i robots # → index یا غایب
curl -s https://yasuj-nobat.ir/about-us | grep -i robots # → index یا غایب
curl -s "https://yasuj-nobat.ir/doctors?city=تهران&specialty=X" | grep -i robots # → noindex بماند
تست حداقل روی ۳ دامنهٔ متفاوت و هر دو مسیر /doctors و /clinics — باگ سیستمیک است، یک URL کافی نیست.
```
---
## P4 — استراتژی canonical سه‌دسته + تصمیم pagination (C1-a/b/c + M3-B)
> پیش‌نیاز: P2 (findDomainByCityId)، P3 (تا canonical و robots هم‌جهت شوند). بزرگ‌ترین پرامپت — قلب فیکس.
```
[بلوک زمینهٔ مشترک]
## یافته‌های زندهٔ تأییدشده
- getCanonicalUrl() در lib/getCanonicalUrl.js همهٔ صفحات روی همهٔ دامنه‌ها را به nobat724.com canonical می‌کند (buildMainCanonicalUrl). تأییدشده روی: /doctors، /clinics، /specialties، /clinic/[id]، /contact-us، /about-us.
- تناقض زنده در clinic/9c163d69: canonical به nobat724.com اما MedicalClinic.url و BreadcrumbList به yasuj-nobat.ir — سیگنال متناقض به گوگل.
- pagination واقعی: یاسوج totalPages: 38 — با query-stripping فعلی همهٔ صفحات ۲+ به صفحهٔ ۱ collapse می‌شوند.
## قاعدهٔ سه‌دسته
| دسته | صفحات | canonical |
|------|-------|-----------|
| C1-a: per-domain | / ، /doctors ، /clinics ، /specialties ، /specialties/[slug] ، /blog (لیست) | self-canonical روی همان دامنه |
| C1-b: موجودیت | /doctor/[uuid] ، /clinic/[uuid] ، /blog/[slug] شهریافته | دامنهٔ شهرِ همان موجودیت (با findDomainByCityId از P2) |
| C1-c: استاتیک مشترک | /contact-us ، /about-us ، /terms ، /privacy ، … | دامنهٔ اصلی nobat724.com (استثنای عمدی — محتوا روی همهٔ دامنه‌ها یکسان است) |
## تسک
1. getCanonicalUrl را بازنویسی کن تا searchParams هم بپذیرد (الان فقط x-pathname می‌خواند و query را کامل نادیده می‌گیرد):
- STATIC_SHARED_PAGES (فهرست C1-c، با تیم نهایی شود) → buildMainCanonicalUrl.
- /doctors با searchParams فقط specialty (تک‌فیلتر) → canonical به /specialties/[slug] (مقصد در P9 ساخته می‌شود؛ فعلاً تابع buildSpecialtyCanonicalUrl را آماده کن و پشت feature-flag یا شرط وجود روت بگذار).
- پارامتر page>1 → تصمیم M3-B: گزینهٔ پیشنهادی page را در canonical نگه‌دار (?page=2 خود canonical) تا صفحات ۲+ ایندکس‌پذیر بمانند. اگر گزینهٔ collapse به صفحهٔ ۱ انتخاب شد، در گزارش صریحاً به‌عنوان تصمیم آگاهانه مستند کن، نه side-effect.
- سایر queryها (فیلتر چندگانه، sort، نام) → حذف از canonical (رفتار فعلی درست است).
- سایر مسیرها → self-canonical با host فعلی.
2. برای موجودیت‌ها تابع getEntityCanonical(entity, pathname, currentHost) بساز:
- cityDomain = findDomainByCityId(entity?.city_id)؛ اگر null → self-canonical (fallback؛ هرگز canonical شکسته نده).
- حالت‌های لبه‌ای: پزشک چند-شهری → یک شهر اصلی (اولین/پرترافیک‌ترین مطب) روی همهٔ دامنه‌ها؛ شهر بدون دامنه → nobat724.com و self.
- در app/doctor/[slug]/page.js و app/clinic/[slug]/page.js اعمال کن.
3. JSON-LD را هماهنگ کن: فیلدهای url/@id در Physician و MedicalClinic و آیتم‌های BreadcrumbList باید همان مقصد canonical را منعکس کنند (تناقض زندهٔ بالا را رفع و تست کن).
4. buildMainCanonicalUrl را فقط اگر جای دیگری (JSON-LD/sitemap) مصرف نمی‌شود حذف کن؛ وگرنه دست‌نخورده.
5. گزینهٔ 301 به‌جای canonical برای موجودیت‌ها را فقط در گزارش پیشنهاد بده (عیب‌ها: redirect chain بک‌لینک‌ها، پرش دامنه در UX پورتال). بدون تأیید صریح پیاده نکن.
## معیار پذیرش
# C1-a
curl -s https://yazd-nobat.ir/doctors | grep canonical # → yazd-nobat.ir/doctors
curl -s https://nobat724.com/doctors | grep canonical # → nobat724.com/doctors
curl -s https://yasuj-nobat.ir/specialties | grep canonical # → yasuj-nobat.ir/specialties
# C1-b
curl -s https://yasuj-nobat.ir/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → self
curl -s https://nobat724.com/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → yasuj-nobat.ir
curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep canonical # → self (شهر=یاسوج)
# C1-c
curl -s https://yasuj-nobat.ir/contact-us | grep canonical # → nobat724.com/contact-us
curl -s https://yasuj-nobat.ir/about-us | grep canonical # → nobat724.com/about-us
# M3-B (اگر گزینهٔ پیشنهادی)
curl -s "https://yasuj-nobat.ir/doctors?page=2" | grep canonical # → …/doctors?page=2
# هماهنگی JSON-LD
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -o '"url":"[^"]*"' # → همان دامنهٔ canonical
```
---
## P5 — بازسازی sitemap (C2)
> پیش‌نیاز: P4 (sitemap باید فقط URLهای canonical را سابمیت کند).
```
[بلوک زمینهٔ مشترک]
## یافتهٔ زنده
nobat724.com/sitemap.xml فقط ۱۳ URL دارد (۷ استاتیک + ۴ پزشک + ۲ کلینیک + ۰ بلاگ). علت: فیلتر owner_status === 'claimed' در getDoctorUrls (app/sitemap.js). یک مارکت‌پلیس عملاً ۴ صفحهٔ پزشک ایندکس‌پذیر دارد.
## تسک
1. معیار ورود به sitemap: از «claimed» به «دارای حداقل دادهٔ معنادار» (uuid + حداقل یک تخصص یا آدرس/شهر).
2. در app/doctor/[slug]/page.js شرط isUnclaimed → noindex را نرم کن: فقط پزشکان بدون هیچ محتوای معنادار noindex بمانند.
3. هماهنگی با canonical (P4): getDoctorUrls از همان findDomainByCityId استفاده کند. sitemap هر دامنه فقط موجودیت‌هایی را شامل شود که canonical آن‌ها همان دامنه است. sitemap دامنهٔ اصلی فقط موجودیت‌های بدون دامنهٔ شهری.
4. برای مقیاس بالای ~۵۰k URL، sitemap-index با فایل‌های جدا (doctors/clinics/blogs/static) بساز.
## معیار پذیرش
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>" # بسیار بیشتر از ۱۳
curl -s https://nobat724.com/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → خالی (canonical این پزشک یاسوج است)
curl -s https://yasuj-nobat.ir/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → موجود
پزشک معتبر unclaimed دیگر noindex نگیرد (curl روی یک نمونه).
```
---
## P6 — صفحات موجودیت: Soft-404 و noindex کلینیک خالی (H4 + H7)
> پیش‌نیاز: P4 (تا سیاست robots/canonical موجودیت‌ها معلوم باشد). دو فیکس کوچک هم‌حوزه.
```
[بلوک زمینهٔ مشترک]
## یافته‌های زنده
- /doctor/<uuid-نامعتبر> کد 200 می‌دهد (Soft-404) — API برای uuid ناموجود 200 با data:null برمی‌گرداند و کد به notFound() نمی‌رسد.
- clinic/9c163d69 با is_active:false، نام «09398631203» (شماره‌تلفن)، بدون caption/logo/services — کاملاً ایندکس‌پذیر است. منطق «thin → noindex» که برای پزشک unclaimed هست، برای کلینیک اصلاً وجود ندارد (نامتقارن).
## تسک
### H4 — Soft-404
1. در getDoctor (app/doctor/[slug]/page.js): null-check کامل روی json?.data?.data → notFound().
2. همان الگو برای app/clinic/[slug]/page.js و app/blog/[slug]/page.js.
(روت /specialties/[slug] هنوز ساخته نشده — در P9 همین الگو اعمال می‌شود.)
### H7 — noindex کلینیک خالی
1. در app/clinic/[slug]/page.js: کلینیک با is_active:false یا بدون محتوای معنادار (نام واقعی/خدمات/توضیح) → noindex.
2. تصمیم بگیر و مستند کن: is_active:false یعنی «حذف‌شده» (→ 404/410) یا «موقتاً غیرفعال» (→ noindex)؟
3. هماهنگی با P5: موجودیت noindex نباید در sitemap باشد.
4. در گزارش برای تیم عملیات: رکوردهای آلوده (نام = شماره‌تلفن، name:"test" با owner_status:"claimed") باید در لایهٔ داده پاک شوند — خارج از کد.
## معیار پذیرش
curl -o /dev/null -w "%{http_code}" https://nobat724.com/doctor/invalid-xxx # → 404
curl -o /dev/null -w "%{http_code}" https://nobat724.com/clinic/invalid-xxx # → 404
curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep -i robots # → noindex (یا 404/410 طبق تصمیم)
curl -s https://yasuj-nobat.ir/sitemap.xml | grep "9c163d69" # → خالی
```
---
## P7 — اصلاح H1ها در همهٔ صفحات (H2 + H3)
> پیش‌نیاز: P2 (resolveCityDisplayName). مستقل از canonical — می‌تواند موازی P4-P6 اجرا شود.
```
[بلوک زمینهٔ مشترک]
## یافته‌های زنده (الگوی مشترک: Title/meta شهرمحور درست‌اند، H1 از منبع جدا و استاتیک می‌آید)
| صفحه | H1 فعلی | مشکل |
|------|---------|-------|
| / (اصلی) | «با نوبت 724» | برندمحور، بدون کیورد |
| /doctors | «کهگیلویه و بویراحمد / یاسوج» | متن دکمهٔ انتخاب موقعیت داخل <button> — h1 نیست |
| /specialties | دو h1: «تخصص مورد نظر شما چیست؟» + «لیست تخصص ها» | دو h1، بدون نام شهر |
| /clinics | «لیست کلینیک ها و درمانگاه های تخصصی» | استاتیک، بدون نام شهر |
| /clinic/[id] | چهار h1: نام + «بیمه های طرف قرارداد» + «تخصص ها» + «خدمات» | عنوان بخش‌ها با h1 |
| Title روت /doctors | «پزشکان نوبت 724 …» | نام رکورد root به‌جای شهر |
## تسک
1. صفحهٔ اصلی: H1 → «نوبت‌دهی آنلاین پزشکان و کلینیک‌های سراسر ایران» (برند جای دیگر).
2. /doctors: عنصر دکمه از h1 به span/p؛ h1 واقعی بالای لیست: «پزشکان و متخصصان {resolveCityDisplayName} | رزرو نوبت آنلاین» (components/doctors/).
3. /specialties: «تخصص مورد نظر شما چیست؟» غیرعنوانی شود؛ h1 یکتا: «لیست تخصص‌های پزشکی در {resolveCityDisplayName}».
4. /clinics: h1 پویا: «کلینیک‌ها و مراکز درمانی {resolveCityDisplayName}».
5. /clinic/[id]: فقط نام کلینیک h1؛ سه عنوان بخش → h2. همین را در /doctor/[id] هم بررسی و در صورت وجود اصلاح کن.
6. همهٔ الگوهای «پزشکان {cityName}» از resolveCityDisplayName استفاده کنند (رفع Title روت).
7. هر صفحه دقیقاً یک h1 (نه صفر، نه چند).
8. **بدون تغییر بصری:** هر تعویض تگ (h1→span، h1→h2) className و استایل عنصر قبلی را عیناً حفظ کند؛ h1 جدیدی که اضافه می‌شود با تایپوگرافی موجود صفحه هماهنگ باشد. بعد از تغییر، ظاهر صفحات با قبل مقایسه و در گزارش تأیید شود.
## معیار پذیرش
for p in doctors specialties clinics; do
curl -s "https://yasuj-nobat.ir/$p" | grep -c "<h1" # → 1
curl -s "https://yasuj-nobat.ir/$p" | grep -o "<h1[^>]*>[^<]*" # → شامل «یاسوج»
done
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -c "<h1" # → 1 (فعلاً 4)
curl -s https://nobat724.com/ | grep -o "<h1[^>]*>[^<]*" # → کیورددار، نه «با نوبت 724»
curl -s https://nobat724.com/doctors | grep -o "<title>[^<]*" # → بدون تکرار «نوبت 724» به‌جای شهر
```
---
## P8 — یکپارچه‌سازی Breadcrumb (H6)
> پیش‌نیاز: P4 (URLهای schema باید با canonical هم‌جهت باشند)، P7 (h2های اشتباه همان‌جا پاک می‌شوند).
```
[بلوک زمینهٔ مشترک]
## یافته‌های زنده (دو نیمهٔ ناقص یک قابلیت)
- /clinics: breadcrumb بصری با تگ <h2> داخل <ul> ساخته شده («کلینیک ها > یاسوج»)، بدون لینک <a>، بدون BreadcrumbList schema.
- /clinic/[id]: دقیقاً برعکس — BreadcrumbList JSON-LD دارد (به yasuj-nobat.ir اشاره می‌کند) اما هیچ breadcrumb بصری ندارد.
## تسک
1. یک کامپوننت Breadcrumb واحد بساز: <nav aria-label="breadcrumb"> + <ol> + <a> (آیتم فعلی <span>). هیچ h2ای. **ظاهرش دقیقاً همان breadcrumb فعلی /clinics باشد** (همان کلاس‌های Tailwind، فاصله‌ها، جداکنندهٔ «>») — فقط تگ‌ها و ساختار سمانتیک عوض می‌شود، نه شکل بصری.
2. BreadcrumbList JSON-LD همراه همان کامپوننت (script مستقل، با safeJsonLd موجود) — یک منبع داده برای بصری و schema تا هرگز واگرا نشوند.
3. روی /clinics و /clinic/[id] اعمال کن؛ /doctors و /specialties و /doctor/[id] را هم بررسی و در صورت نیاز اضافه کن.
4. URLهای آیتم‌ها با مقصد canonical صفحه (P4) هم‌جهت باشند.
## معیار پذیرش
curl -s https://yasuj-nobat.ir/clinics | grep -o "<h2[^>]*>[^<]*" # → بدون «کلینیک ها»/«یاسوج»
curl -s https://yasuj-nobat.ir/clinics | grep "BreadcrumbList" # → موجود
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -c "aria-label=\"breadcrumb\"" # → 1 (بصری اضافه شد)
```
---
## P9 — صفحات فرود تخصص و «تخصص + شهر» (C3 — بزرگ‌ترین فرصت رشد)
> پیش‌نیاز: P4 (canonical و buildSpecialtyCanonicalUrl)، P5 (sitemap)، P7 (resolveCityDisplayName و الگوی h1).
```
[بلوک زمینهٔ مشترک]
## یافتهٔ زنده
- کیوردهای پرارزش («متخصص پوست تهران») هیچ صفحهٔ فرودی ندارند؛ فقط /doctors?specialty=… (کوئری فارسی).
- صفحهٔ /specialties با ~۹۰ کارت، همه به /doctors?specialty=X&city=یاسوج&state=… لینک می‌دهند — همان الگویی که پیش از P3 باعث noindex می‌شد؛ این ۹۰ لینک باید مقصد تمیز بگیرند.
## تسک
1. روت app/specialties/[slug]/page.js بساز — slug انگلیسی/تمیز از data/specialties.json. روی هر دامنهٔ شهری خودکار «تخصص + آن شهر» می‌شود (شهر از host). **این صفحه از کامپوننت‌های موجود ساخته شود** — همان کارت پزشک، همان layout و header/footer، و همان کامپوننت لیستی که /doctors استفاده می‌کند؛ چیدمان و ظاهر نهایی باید از نظر کاربر دقیقاً هم‌خانوادهٔ /doctors فعلی باشد. هیچ کامپوننت/استایل جدیدی نساز مگر معادل موجود نداشته باشد.
2. هر صفحه شامل:
- h1: «متخصص {specialty} در {resolveCityDisplayName}».
- پاراگراف مقدمهٔ یکتا (تعداد پزشک، میانگین امتیاز شهر، توضیح خدمت).
- لیست پزشکان SSR (/api/v1/doctors با فیلتر تخصص/شهر).
- بلوک FAQ + FAQPage schema (script مستقل).
- کامپوننت Breadcrumb از P8 + ItemList/MedicalWebPage.
- generateMetadata کیوردمحور، canonical self-domain (طبق C1-a در P4).
3. slug نامعتبر → notFound() (الگوی P6).
4. این URLها به sitemap اضافه شوند (P5): ضرب specialties.json × دامنه‌ها.
5. لینک‌های داخلی: کارت‌های /specialties و فوتر → روت جدید. Breadcrumb صفحهٔ پزشک از /doctors?specialty= به URL تمیز.
6. فعال‌سازی canonical کوئری قدیم (آماده‌شده در P4): /doctors?specialty=X تک‌فیلتر → canonical به /specialties/[slug].
## معیار پذیرش
curl -s https://tehran-nobat.ir/specialties/dermatology | grep -c "<h1>" # → 1 (شامل نام شهر)
curl -s https://nobat724.com/sitemap.xml | grep "specialties/" # → موجود
curl -s "https://tehran-nobat.ir/doctors?specialty=dermatology" | grep canonical # → …/specialties/dermatology
curl -o /dev/null -w "%{http_code}" https://tehran-nobat.ir/specialties/invalid-slug # → 404
curl -s https://yasuj-nobat.ir/specialties | grep -c "doctors?specialty=" # → 0 (لینک‌ها مهاجرت کرده‌اند)
```
---
## P10 — بلاگ شهر-محور (C4)
> پیش‌نیاز: P4 (الگوی canonical موجودیت)، P5 (sitemap). ممکن است نیاز به تغییر بک‌اند/مدل داده داشته باشد — اول بررسی، بعد کد.
```
[بلوک زمینهٔ مشترک]
## یافتهٔ زنده
sitemap صفر URL بلاگ دارد. هر شهر نمایندهٔ اختصاصی دارد اما ساختار بلاگ ارتباط پست با شهر را در URL/عنوان/متادیتا منعکس نمی‌کند.
## پیش از کد، بررسی کن (اسکیمای دادهٔ بلاگ مستند نیست)
- آیا پست بلاگ فیلد شهر (city_id یا مشابه) دارد؟ اگر نه، افزودنش قدم اول است (با امکان «سراسری» برای پست‌های عمومی).
- آیا نویسنده/نماینده به شهر مشخصی مرتبط است که بتوان از آن استنتاج کرد؟
## تسک
1. نمایش صریح شهر در HTML قابل‌مشاهده (نه فقط متادیتا): عنوان/زیرعنوان مثل «{title} | مخصوص {cityName}» یا بلوک «این مطلب برای شهر {cityName} نوشته شده». این عنصر با استایل موجود صفحهٔ بلاگ هماهنگ باشد (مثلاً همان کامپوننت badge/برچسبی که در پروژه هست) — عنصر بصری ناهماهنگ با بقیهٔ صفحه اضافه نکن.
2. canonical طبق الگوی موجودیت (P4): پست شهریافته → دامنهٔ همان شهر (با همان findDomainByCityId — تابع مشترک، نه پیاده‌سازی جدا). پست سراسری → self روی دامنهٔ اصلی.
3. لیست بلاگ per-domain: app/blog/page.js روی هر دامنهٔ شهری = پست‌های آن شهر + سراسری؛ خود لیست self-canonical (C1-a).
4. sitemap (P5): هر پست فقط در sitemap دامنهٔ canonical خودش.
5. Soft-404 (الگوی P6): بعد از افزودن فیلد شهر، null-check همچنان درست باشد.
6. JSON-LD: BlogPosting/Article با areaServed یا spatialCoverage (script مستقل).
## معیار پذیرش
curl -s https://nobat724.com/sitemap.xml | grep -c "/blog/" # → بیشتر از ۰
curl -s https://yasuj-nobat.ir/blog/<slug-پست-یاسوجی> | grep -i "یاسوج" # → نام شهر در HTML
curl -s https://yasuj-nobat.ir/blog/<slug-پست-یاسوجی> | grep canonical # → self (دامنهٔ شهر)
curl -s https://nobat724.com/blog/<slug-پست-یاسوجی> | grep canonical # → yasuj-nobat.ir (نه self)
```
---
## P11 — FAQPage schema و متن مقدمهٔ یکتا (M1 + M2)
> پیش‌نیاز: P9 (صفحات تخصص باید وجود داشته باشند). آخرین لایه — بهبود، نه رفع باگ.
```
[بلوک زمینهٔ مشترک]
## تسک
### M1 — FAQPage schema
1. بلوک «سوالات متداول» + FAQPage JSON-LD به صفحات پزشک، تخصص و شهر.
2. FAQPage حتماً یک <script type="application/ld+json"> مستقل باشد (نه ادغام در @graph بدون تست) — schemaهای موجود Physician/Breadcrumb/Review/Geo نقطهٔ قوت‌اند و نباید بشکنند. از safeJsonLd موجود استفاده کن.
### M2 — متن مقدمهٔ یکتا
بالای /doctors و /clinics و صفحات تخصص: ۱۵۰–۳۰۰ کلمه متن یکتای شهری/تخصصی (رفع Thin Content — مکمل canonical؛ محتوای یکسان با canonical درست هنوز duplicate است).
نکته: متن‌ها الگوی خام «{کار} کنید، {کار} کنید» نداشته باشند؛ برای هر شهر/تخصص ساختار جمله متفاوت باشد.
## معیار پذیرش
curl -s https://yasuj-nobat.ir/specialties/dermatology | grep "FAQPage" # → موجود
Rich Results Test گوگل روی یک صفحهٔ پزشک: schemaهای قبلی سالم + FAQPage معتبر.
شمارش کلمات بلوک مقدمه در ۳ شهر مختلف: بین ۱۵۰ تا ۳۰۰ و متن‌ها یکسان نباشند.
```
---
## یادداشت‌های سراسری (در گزارش نهایی هر فاز تکرار شود)
- **کیفیت داده (خارج از کد):** رکوردهای پزشک/کلینیک با name = شماره‌تلفن یا "test" با owner_status:"claimed" باید توسط تیم عملیات پاک‌سازی شوند.
- **DevOps:** بررسی bot-challenge ArvanCloud روی دامنهٔ اصلی — کد نیست، فقط یادآوری.
- **پس از هر deploy مؤثر بر canonical/robots:** در Search Console هر دامنهٔ درگیر، URL Inspection روی صفحات کلیدی + انتظار چند هفته برای re-index.
- **SSR:** رندر لیست‌ها streaming با Suspense است (۶ کارت اسکلت با href="/doctor/undefined" در HTML اولیه). فعلاً مشکل‌ساز نیست اما در گزارش ذکر شود؛ اگر فرصت شد href اسکلت‌ها خالی/بدون لینک شود.
</div>
@@ -0,0 +1,148 @@
<div dir="rtl" markdown="1">
# تأیید زندهٔ فیکس‌های SEO بعد از deploy
## پروژه
`nobat724_front`
## زمینه
فیکس‌های ممیزی SEO (P1–P11 از `seo-critical-fixes-live-audit.md`) پیاده و تست شده‌اند، اما تأیید روی **build محلی production** انجام شد، نه روی دامنه‌های واقعی:
```
NEXT_PUBLIC_API_URL=https://clinic-pro.ir DEV_MODE=FALSE npm run build
NODE_TLS_REJECT_UNAUTHORIZED=0 npx next start -p 3111
# سپس curl با هدر Host برای شبیه‌سازی دامنه‌های شهری
```
معیارهای پذیرش اصلی «curl روی production» بودند. این پرامپت همان‌ها را روی دامنه‌های واقعی اجرا می‌کند.
**دو نکتهٔ حیاتی قبل از تفسیر هر خروجی:**
۱. **`DEV_MODE` باید `FALSE` باشد.** با `DEV_MODE=TRUE` کل سایت `noindex, nofollow, nocache` می‌گیرد و همهٔ تست‌های robots بی‌معنی می‌شوند. این مقدار در `next.config.js` از طریق `env` در **زمان build** درون کد جاسازی می‌شود — تغییر آن در runtime بی‌اثر است و نیاز به build مجدد دارد.
۲. **تغییرات هنوز commit نشده‌اند** (وضعیت کاری). قبل از deploy باید commit و push شوند.
## هدف
اجرای کامل معیارهای پذیرش روی production و ثبت خروجی واقعی؛ و در صورت اختلاف با نتایج محلی، ریشه‌یابی.
## وظایف
### ۱. پیش از deploy
- `git status` را بررسی کن؛ تغییرات SEO را commit کن.
- تأیید کن build با `DEV_MODE=FALSE` انجام می‌شود.
- تأیید کن `NEXT_PUBLIC_API_URL` در محیط production درست است (نه `clinic-pro.ddev.site` که مقدار فعلی `.env` محلی است).
### ۲. اجرای معیارهای پذیرش روی production
بعد از deploy، این‌ها را اجرا کن و **خروجی واقعی هر کدام را در گزارش نقل کن**:
```bash
# P3 — noindex سیستمیک لیست‌ها (باید خالی باشد)
for h in yasuj-nobat.ir tehran-nobat.ir yazd-nobat.ir nobat724.com; do
for p in doctors clinics; do
printf "%s/%s: " "$h" "$p"
curl -s "https://$h/$p" | grep -o '<meta name="robots"[^>]*>' | head -1; echo
done
done
# P3 — فیلتر واقعی باید noindex بماند
curl -s "https://yasuj-nobat.ir/doctors?name=x" | grep -o 'content="noindex[^"]*"'
curl -s "https://yasuj-nobat.ir/doctors?specialty=پوست%20و%20مو&gender=woman" | grep -o 'content="noindex[^"]*"'
# P4 — C1-a self-canonical
for h in yazd-nobat.ir nobat724.com yasuj-nobat.ir; do
for p in "" doctors clinics specialties blogs; do
printf "%s/%s -> " "$h" "$p"
curl -s "https://$h/$p" | grep -o 'rel="canonical" href="[^"]*"'; echo
done
done
# P4 — C1-c استاتیک مشترک → دامنهٔ اصلی
curl -s https://yasuj-nobat.ir/about-us | grep -o 'rel="canonical" href="[^"]*"'
curl -s https://yasuj-nobat.ir/contact-us | grep -o 'rel="canonical" href="[^"]*"'
# P4 — C1-b موجودیت → دامنهٔ شهر خودش
D=beaca548-816f-4613-937d-360db01bd7c8 # شهر: یاسوج
C=9c163d69-0051-4745-a423-c830135b1c01 # شهر: یاسوج
curl -s "https://nobat724.com/doctor/$D" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
curl -s "https://yazd-nobat.ir/doctor/$D" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
curl -s "https://nobat724.com/clinic/$C" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
# P4 — هماهنگی JSON-LD با canonical (تناقض زندهٔ قبلی)
curl -s "https://nobat724.com/clinic/$C" | grep -o '"url":"[^"]*"'
# M3-B — pagination
curl -s "https://yasuj-nobat.ir/doctors?page=2" | grep -o 'rel="canonical" href="[^"]*"' # → ?page=2
curl -s "https://yasuj-nobat.ir/doctors?page=1" | grep -o 'rel="canonical" href="[^"]*"' # → بدون page
# P6 — Soft-404 (هر چهار باید 404 بدهند)
for p in doctor/invalid-xxx clinic/invalid-xxx blog/invalid-xxx specialties/invalid-slug; do
printf "%s -> " "$p"; curl -o /dev/null -s -w "%{http_code}\n" "https://nobat724.com/$p"
done
# و موجودیت معتبر باید 200 بماند
curl -o /dev/null -s -w "valid doctor: %{http_code}\n" "https://nobat724.com/doctor/$D"
# P7 — دقیقاً یک h1 و شهرمحور
for p in doctors clinics specialties; do
printf "%s h1count=" "$p"; curl -s "https://yasuj-nobat.ir/$p" | grep -c "<h1"
curl -s "https://yasuj-nobat.ir/$p" | grep -o "<h1[^>]*>[^<]*"
done
curl -s "https://yasuj-nobat.ir/clinic/$C" | grep -c "<h1" # → 1 (قبلاً 4)
curl -s "https://nobat724.com/doctors" | grep -o "<title>[^<]*" # → بدون «نوبت 724» به‌جای شهر
# P8 — breadcrumb
curl -s https://yasuj-nobat.ir/clinics | grep -c 'aria-label="breadcrumb"'
curl -s https://yasuj-nobat.ir/clinics | grep -c 'BreadcrumbList'
curl -s "https://yasuj-nobat.ir/clinic/$C" | grep -c 'aria-label="breadcrumb"'
# P9 — صفحات فرود تخصص
curl -o /dev/null -s -w "%{http_code}\n" https://tehran-nobat.ir/specialties/dermatology # → 200
curl -o /dev/null -s -w "%{http_code}\n" https://tehran-nobat.ir/specialties/invalid-slug # → 404
curl -s "https://tehran-nobat.ir/doctors?specialty=پوست%20و%20مو" | grep -o 'rel="canonical" href="[^"]*"'
curl -s https://yasuj-nobat.ir/specialties | grep -c "doctors?specialty=" # → 0
# P5 — sitemap
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>"
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "<loc>"
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "$D" # → 1
curl -s https://nobat724.com/sitemap.xml | grep -c "$D" # → 0
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "9c163d69" # → 0 (کلینیک بی‌محتوا)
# P11 — FAQPage بدون شکستن اسکیماهای موجود
curl -s https://tehran-nobat.ir/specialties/dermatology | grep -c FAQPage
curl -s "https://yasuj-nobat.ir/doctor/$D" | grep -c FAQPage
curl -s "https://yasuj-nobat.ir/doctor/$D" | grep -c Physician # باید سالم بماند
```
### ۳. اعتبارسنجی ساختاریافته
- **Rich Results Test گوگل** روی یک صفحهٔ پزشک و یک صفحهٔ تخصص: تأیید کن `Physician` / `MedicalClinic` / `Review` / `BreadcrumbList` قبلی سالم‌اند و `FAQPage` معتبر است.
- تأیید کن همهٔ بلوک‌های JSON-LD پارس می‌شوند (در تست محلی: ۰ خطای پارس، انواع `Organization, WebSite, Physician, FAQPage, BreadcrumbList`).
### ۴. بازبینی بصری
صفحات تغییریافته را با نسخهٔ قبل مقایسه کن. تغییر تگ‌ها باید بدون اثر بصری باشد:
| صفحه | تغییر | انتظار |
|------|-------|--------|
| `/` | خط برند `h1``p`؛ شعار `h2``h1` | ظاهر یکسان |
| `/doctors` | متن دکمهٔ موقعیت `h1``span` + h1 جدید و پاراگراف مقدمه | دکمه بدون تغییر؛ عنوان و متن جدید بالای لیست |
| `/clinics` | عنوان پویا + پاراگراف مقدمه + breadcrumb با «خانه» | «خانه >» به breadcrumb اضافه شده |
| `/clinic/[id]` | ۳ عنوان بخش `h1``h2`؛ breadcrumb بصری جدید | تایپوگرافی یکسان |
### ۵. Search Console
برای هر دامنهٔ درگیر: URL Inspection روی صفحات کلیدی (`/`، `/doctors`، `/clinics`، یک صفحهٔ پزشک، یک صفحهٔ تخصص) و ثبت sitemap. re-index چند هفته طول می‌کشد.
## نکات مهم
- **رگرسیون احتمالی که باید مشخصاً چک شود:** حذف `loading.js` از سه مسیر `doctor/[slug]`، `clinic/[slug]`، `blog/[slug]`. این کار برای رفع Soft-404 لازم بود (Suspense باعث می‌شد پاسخ با ۲۰۰ استریم شود و `notFound()` دیگر نتواند status را عوض کند). هزینه‌اش نبودِ اسکلت لودینگ هنگام ناوبری کلاینتی است — تجربهٔ کاربری این سه مسیر را عملاً بررسی کن.
- اگر `robots` روی همهٔ صفحات `noindex, nofollow, nocache` بود، تقریباً قطعاً `DEV_MODE=TRUE` در build بوده — کد را دنبال نکن، اول build را بررسی کن.
- ArvanCloud ممکن است روی دامنهٔ اصلی bot-challenge داشته باشد؛ اگر curl خروجی غیرمنتظره داد، قبل از دیباگ کد این را رد کن. (خارج از دامنهٔ اپ — DevOps)
- sitemap دامنهٔ اصلی ~۱۳ ثانیه طول می‌کشد (۳۵ sweep برای تفکیک شهر). اگر timeout خورد، این انتظار است نه باگ — رفعش در `sitemap-simplify-with-city.md`.
</div>
@@ -0,0 +1,124 @@
<div dir="rtl" markdown="1">
# ساده‌سازی sitemap با شهرِ موجود در پاسخ + آستانهٔ sitemap-index
## پروژه
`nobat724_front`
**پیش‌نیاز قطعی:** `clinicpro/.claude/prompt/doctors-list-city-and-limit.md` اجرا و deploy شده باشد.
تا وقتی `GET /api/v1/doctors` فیلد `city` برنگرداند، وظیفهٔ ۱ قابل انجام نیست (وظیفهٔ ۲ مستقل است).
## زمینه
sitemap هر دامنه فقط باید موجودیت‌هایی را داشته باشد که canonical آن‌ها همان دامنه است. برای کلینیک‌ها ساده است چون پاسخ لیست `city` دارد. برای پزشکان **پاسخ لیست شهر ندارد**، پس sitemap دامنهٔ اصلی مجبور است این کار پرهزینه را بکند: کل لیست را بگیرد، بعد **۳۵ بار** لیست را با `city_id` هر شهرِ دامنه‌دار بگیرد، و تفاضل بگیرد.
نتیجه: تولید sitemap دامنهٔ اصلی ~۱۳ ثانیه (revalidate ساعتی، پس هزینه مستهلک می‌شود، ولی کد پیچیده و شکننده است).
## وضعیت فعلی
`app/sitemap.js`:
```js
// شهرهایی که دامنهٔ اختصاصی دارند — موجودیت‌هایشان 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));
}
```
کلینیک‌ها از قبل الگوی ساده و درست را دارند (چون `city` در پاسخ هست):
```js
.filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))
```
## وظایف
### ۱. حذف ۳۵ sweep و یکسان‌سازی با الگوی کلینیک
وقتی `city` در پاسخ لیست پزشکان آمد:
- `getDoctorsForScope` و `CITY_IDS_WITH_DOMAIN` حذف شوند.
- روی دامنهٔ شهری: همان `fetchAllPages('/api/v1/doctors', cityFilterParams(scope))` بماند.
- روی دامنهٔ ریشه: یک fetch کامل + همان فیلتر کلینیک‌ها:
```js
async function getDoctorUrls(baseUrl, scope) {
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
return doctors
.filter((d) => !isThinDoctor(d))
.filter((d) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(d)))
.map((d) => withLastModified({ /* ... */ }, d.updated || d.created));
}
```
`extractEntityCityId` بدون تغییر کار می‌کند (شکل `city: {id}` را می‌پذیرد).
**تأیید کن پوشش تغییر نکرده باشد** — قبل و بعد از تغییر تعداد URL هر sitemap را مقایسه کن:
```bash
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "<loc>"
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>"
```
مقدار مرجع در زمان نگارش: یاسوج **۵۵۴** URL (۴۵۴ پزشک)، ریشه **۱۰۰** URL (۰ پزشک — همهٔ پزشکان شهر دامنه‌دار دارند).
### ۲. آستانهٔ sitemap-index (مستقل از backend)
الان همهٔ URLها در یک `sitemap.xml` هستند. سقف استاندارد **۵۰٬۰۰۰ URL** در هر فایل است.
مقیاس فعلی: ۲۳۴۱ پزشک، ۲ کلینیک، ۹۳ صفحهٔ تخصص، ۰ بلاگ ⇒ حداکثر ~۲٬۵۰۰ URL در هر دامنه. **بسیار زیر سقف، پس sitemap-index الان لازم نیست** و عمداً پیاده نشده.
کاری که این وظیفه می‌خواهد:
- یک هشدار صریح اضافه کن که وقتی تعداد URL از یک آستانه (مثلاً ۴۵٬۰۰۰) گذشت، در لاگ دیده شود:
```js
if (allUrls.length > 45000) {
console.warn(`[sitemap] ${domain}: ${allUrls.length} URLs — نزدیک سقف ۵۰k، sitemap-index لازم است`);
}
```
- تصمیم «فعلاً sitemap-index نداریم و چرا» را به‌صورت کامنت کنار همین شرط مستند کن، تا دفعهٔ بعد کسی آن را به‌عنوان قلم‌افتادگی برندارد.
- sitemap-index را **پیاده نکن** مگر عدد واقعی به آستانه نزدیک شده باشد؛ با `generateSitemaps` ساختار URL عوض می‌شود (`/sitemap/[id].xml`) و ارجاع `robots.txt` باید همراهش تغییر کند.
### ۳. بازبینی سقف `limit` بعد از تغییر backend
حلقهٔ صفحه‌بندی الان روی `meta.totalPages` توقف می‌کند (نه روی `items.length < limit`) — این عمدی و درست است:
```js
const PAGE_LIMIT = 50;
const MAX_PAGES = 200;
// ...
if (totalPages && page >= totalPages) break;
if (totalRecords && results.length >= totalRecords) break;
```
اگر backend سقف `limit` را بالا برد، `PAGE_LIMIT` را متناسب بالا ببر و `MAX_PAGES` را بازبینی کن. **شرط توقف را به `items.length < PAGE_LIMIT` برنگردان** — دقیقاً همین باگ باعث شده بود sitemap روی ۵۰ رکورد بریده بماند.
## نکات مهم
- **این تغییر نباید هیچ URLی را از sitemap حذف یا اضافه کند** — فقط راه رسیدن به همان نتیجه ساده‌تر می‌شود. اختلاف در تعداد URL یعنی رگرسیون؛ ریشه‌یابی کن.
- `isThinDoctor` در `lib/entityQuality.js` روی شکل پاسخِ **لیست** کار می‌کند (`specialties` دارد، `address` ندارد). اگر backend شکل پاسخ لیست را عوض کرد، معیار `hasLocation` را بازبینی کن — با آمدن `city` در پاسخ، حالا `hasLocation` می‌تواند از `city` هم سیگنال بگیرد و پزشکان بیشتری وارد sitemap شوند.
- منطق «چه چیزی noindex است» و «چه چیزی در sitemap است» باید یکی بماند؛ هر دو از `lib/entityQuality.js` می‌آیند. اگر یکی را عوض کردی، دیگری خودکار همراه می‌شود — عمداً همین‌طور طراحی شده.
- بعد از تغییر: `graphify update .`
</div>