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>
40 KiB
تقسیم ممیزی 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 و هیچ
FAQPageschema در کل سایت نیست. - بلاگ شهر-محور نیست با اینکه هر شهر نمایندهٔ محتوایی دارد.
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 اسکلتها خالی/بدون لینک شود.