# رفع لینک `/doctor/undefined` و متمایزسازی صفحهٔ اصلی شهرها ## پروژه `nobat724_front` — سایت عمومی. بدون تغییر بک‌اند. سند همراه: `docs/seo/gsc-expected-exclusions.md` — آن خطاهای Search Console که **باگ نیستند** و نباید «رفع» شوند. این پرامپت فقط دو موردِ واقعاً کدی را می‌سازد. ## زمینه Search Console روی `behbahan-nobat.ir` و `yasuj-nobat.ir` شش دستهٔ مسئله نشان می‌دهد. چهار دسته رفتار عمدی سیستم‌اند و در سند بالا توضیح داده شده‌اند. دو دسته باگ واقعی‌اند: - **Not found (404)** — `https://behbahan-nobat.ir/doctor/undefined`، آخرین crawl ۳۰ ژوئیه - **Duplicate, Google chose different canonical than user** — `https://behbahan-nobat.ir/` ## مشکل / هدف ### ۱. لینک `/doctor/undefined` اسکلت بارگذاری صفحهٔ پزشکان، شش کارت با آبجکتِ بدون `uuid` رندر می‌کند. `ItemDoctor` لینک را با `doctor?.uuid` می‌سازد، پس در HTML شش `` می‌نشیند و Googlebot همان را crawl می‌کند. زنده تأیید شد: ``` curl -o /dev/null -w "%{http_code}" https://behbahan-nobat.ir/doctor/undefined 404 ``` ### ۲. صفحهٔ اصلی هر شهر تقریباً کپی بقیه است canonical درست و self است — این را از خودِ سایت زنده گرفتم: ``` https://behbahan-nobat.ir/ → https://yasuj-nobat.ir/ → ``` پس گوگل canonical ما را **رد کرده**، نه اینکه ما اشتباه اعلام کرده باشیم. دلیلش اندازه‌گیری شد: ``` شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی: ۹۹.۰٪ ``` تنها چیزِ شهرمحورِ صفحهٔ اصلی، `site_name` و `slogan` از `data/city.json` است. بقیه — بنر، کادر جستجو، «جستجوهای پرتکرار»، فوتر — روی همهٔ دامنه‌ها یکسان است. تا محتوا متمایز نشود هیچ تگی جلوی تجمیع را نمی‌گیرد؛ همین ریشهٔ بخش بزرگی از `Discovered - currently not indexed` هم هست. ## معیار پذیرش - ✅ موفق: در HTML صفحهٔ `/doctors` (چه در حالت اسکلت چه با داده) هیچ `href="/doctor/undefined"` نباشد. - ✅ موفق: صفحهٔ اصلی هر شهر یک بخش متنی و آماری دارد که با شهر دیگر فرق می‌کند — شمار پزشک و کلینیک همان شهر، و تخصص‌های پرتکرارِ همان شهر. - ✅ موفق: شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی به زیر ۸۵٪ برسد (سنجهٔ عینی، دستور پایین). - ❌ خطا: اگر API پاسخ ندهد یا شهر پزشکی نداشته باشد، صفحهٔ اصلی نباید بشکند و نباید عدد صفر یا «undefined» نشان دهد — بخش آمار حذف می‌شود و بقیهٔ صفحه سالم می‌ماند. - ❌ خطا: کارت اسکلت (بدون `uuid`) نباید اصلاً لینک باشد. - ⚠️ مرزی: دامنهٔ ریشه `nobat724.com` شهر ندارد؛ متن و آمار شهری نباید آنجا رندر شود. - ⚠️ مرزی: شهری که تازه اضافه شده و صفر پزشک دارد — پیام بی‌جایگزین نه، بلکه حذف بخش آمار. - ⚠️ مرزی: `loading.js` روی مسیر `/doctors` است؛ اسکلت‌های دیگری که `ItemDoctor` را با دادهٔ ناقص رندر می‌کنند هم باید همین رفتار را بگیرند. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `app/component/ItemDoctor.js` | سازندهٔ لینک پزشک — تنها جای پروژه | | `app/doctors/loading.js` | اسکلتی که آبجکت بدون `uuid` می‌دهد | | `components/home/index.js` | چیدمان صفحهٔ اصلی | | `components/home/TextHeader.js` | تنها بخش شهرمحور فعلی | | `components/home/FrequentSearches.js` | فهرست ثابت، یکسان روی همهٔ دامنه‌ها | | `lib/getStateInfo.js` | تشخیص شهر از subdomain (server) | | `lib/rootCity.js` | `isRootCity` — تشخیص دامنهٔ ریشه | | `lib/specialtyContent.js` | `buildSpecialtyIntro` — الگوی متنِ یکتای موجود | | `services/response.js` | `getSpecialtyDoctorCounts` — شمار پزشک هر تخصص در یک شهر | ## وضعیت فعلی ### لینک با uuid اختیاری ```jsx // app/component/ItemDoctor.js:41

{doctor?.display_name || doctor?.name}

``` ```jsx // app/component/ItemDoctor.js:89 ``` ### اسکلتی که uuid ندارد ```jsx // app/doctors/loading.js {Array.from({ length: 6 }).map((_, idx) => ( {}} /> ))} ``` ### تنها بخش شهرمحور صفحهٔ اصلی ```jsx // components/home/TextHeader.js const { matchedCity } = await getStateInfo(); ...

با {matchedCity?.site_name}

{matchedCity?.slogan}

``` ```jsx // components/home/index.js
``` ### الگوی متنِ یکتا که از قبل هست و باید تقلید شود ```js // lib/specialtyContent.js:57 export function buildSpecialtyIntro(specialtyName, cityName, doctorCount = 0) { const seed = hash(specialtyName, cityName); return [ pick(openingVariants(specialtyName, cityName), seed), pick(bodyVariants(specialtyName, cityName, doctorCount), seed >>> 3), pick(closingVariants(specialtyName, cityName), seed >>> 7), ].join(" "); } ``` ## وظایف ### ۱. کارت بدون `uuid` نباید لینک باشد در `app/component/ItemDoctor.js` هر دو `Link` مشروط شوند. وقتی `uuid` نیست — اسکلت بارگذاری، یا رکورد ناقص — همان محتوا بدون `
` رندر شود. ```jsx const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null; ``` - عنوان: اگر `href` تهی بود، `

` بدون `Link`. - دکمهٔ فلش: اگر `href` تهی بود، دکمه رندر نشود (در اسکلت هم معنایی ندارد). `?.` را از `doctor?.uuid` داخل رشتهٔ الگو حذف کن؛ همان بود که `undefined` را به URL می‌برد. **نحوه تست:** تست کامپوننتی در `app/component/ItemDoctor.test.js` — پزشک با `uuid` دو لینک به `/doctor/` دارد؛ آبجکت `{ id: 0 }` هیچ `role="link"` ندارد و رشتهٔ `undefined` در `container.innerHTML` نیست. سپس build و بررسی HTML واقعی: ```bash NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build npm run start & curl -s http://localhost:3000/doctors | grep -c 'doctor/undefined' # باید 0 باشد ``` ### ۲. بخش شهرمحور صفحهٔ اصلی کامپوننت تازه `components/home/CityHighlights.js` — سرور-کامپوننت، زیر `FrequentSearches`. داده از `GET /api/v1/specialties/doctor-counts?city_id=` می‌آید که از قبل هست و `services/response.js` هم wrapper دارد (`getSpecialtyDoctorCounts`). برای صفحهٔ اصلی server-side است، پس با `lib/req.js` → `fetchReq` بگیر، نه با کلاینت axios. محتوا: - یک پاراگراف یکتا با همان الگوی `buildSpecialtyIntro`: تابع تازه‌ای کنارش در `lib/specialtyContent.js` به نام `buildCityIntro(cityName, doctorCount, topSpecialties)` بنویس. **الگو Variant-pick با hash است، نه متن ثابت** — دلیلش همان دلیل تابع موجود: متنِ یکسان روی ۳۰ دامنه دوباره همان duplicate را می‌سازد؛ seed از نام شهر می‌آید تا هر شهر واگرا شود و خروجی هم پایدار بماند. - شمار واقعی: «N پزشک در M تخصص در <شهر>». - شش تا هشت تخصصِ پرپزشکِ **همان شهر** با شمارشان، لینک به `/specialties/`. این جای `FrequentSearches` را نمی‌گیرد؛ آن فهرست ثابت است و این یکی شهرمحور. روی دامنهٔ ریشه (`isRootCity`) کل بخش رندر نشود — آنجا شهری وجود ندارد. ```jsx const { matchedCity, isRoot } = await getStateInfo(); if (isRoot || !matchedCity?.id) return null; const counts = await safeCounts(matchedCity.id); // خطا → آرایهٔ خالی if (counts.length === 0) return null; // شهر بدون پزشک → بخش حذف ``` خطا را همین‌جا ببلع و `null` برگردان. صفحهٔ اصلی مهم‌ترین صفحهٔ سایت است و نباید با قطعی API سفید شود؛ این همان مرز واقعی error handling است که پروژه می‌پذیرد. **نحوه تست:** - unit test برای `buildCityIntro` در `lib/specialtyContent.test.js`: دو شهر متفاوت متن متفاوت بدهند؛ یک شهر در دو فراخوانی متن یکسان بدهد (پایداری)؛ `doctorCount` صفر جمله را نشکند. - تست کامپوننتی `components/home/CityHighlights.test.js` با mock روی `getStateInfo` و `fetchReq`: دامنهٔ شهری بخش را می‌سازد؛ دامنهٔ ریشه `null`؛ خطای شبکه `null`؛ فهرست خالی `null`. - سنجهٔ عینیِ معیار پذیرش، بعد از deploy: ```bash a=$(curl -s https://behbahan-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ') b=$(curl -s https://yasuj-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ') python3 -c " import sys, difflib print('%.1f%%' % (difflib.SequenceMatcher(None, sys.argv[1], sys.argv[2]).ratio()*100)) " "$a" "$b" ``` پیش از تغییر ۹۹٫۰٪ بود؛ باید زیر ۸۵٪ برود. ### ۳. `generateMetadata` صفحهٔ اصلی `app/page.js` الان `generateMetadata` ندارد و از layout ارث می‌برد. توضیحات متا هم باید شهرمحور باشد، وگرنه snippet هر ۳۰ دامنه یکی است. `description` را از همان `buildCityIntro` بساز (کوتاه‌شده)، و `alternates.canonical` را دست نزن — لایهٔ layout با `getCanonicalUrl` درستش می‌کند و صفحهٔ اصلی C1-a است. **نحوه تست:** `curl -s https://-nobat.ir/ | grep -oE '