Files
nobat724_front/.claude/prompt/gsc-undefined-link-and-home-uniqueness.md
hamed b494473e40 feat: رفع لینک /doctor/undefined و متمایزسازی صفحهٔ اصلی شهرها
- ایجاد پرامپت برای رفع خطای 404 ناشی از لینک `/doctor/undefined` در سایت‌های بهبهان و یاسوج.
- افزودن کامپوننت `CityHighlights` برای نمایش محتوای یکتا و آمار پزشکان و تخصص‌های پرمراجعه در صفحهٔ اصلی هر شهر.
- به‌روزرسانی تست‌های مربوط به کامپوننت‌های `ItemDoctor` و `CityHighlights` برای اطمینان از عدم وجود لینک‌های نامعتبر و نمایش صحیح اطلاعات.
- ایجاد سند جدید برای مستندسازی خطاهای Search Console که باگ نیستند و نیاز به رفع ندارند.
- افزودن تست‌های واحد برای توابع `buildCityIntro` و دیگر توابع مرتبط با تولید متن یکتا برای صفحات لیست.
2026-08-08 19:14:50 +03:30

251 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رفع لینک `/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 شش `<a href="/doctor/undefined">` می‌نشیند و
Googlebot همان را crawl می‌کند.
زنده تأیید شد:
```
curl -o /dev/null -w "%{http_code}" https://behbahan-nobat.ir/doctor/undefined
404
```
### ۲. صفحهٔ اصلی هر شهر تقریباً کپی بقیه است
canonical درست و self است — این را از خودِ سایت زنده گرفتم:
```
https://behbahan-nobat.ir/ → <link rel="canonical" href="https://behbahan-nobat.ir">
https://yasuj-nobat.ir/ → <link rel="canonical" href="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
<Link href={`/doctor/${doctor?.uuid}`}>
<p className="text-[#3B3B3B] text-[14px] font-bold">
{doctor?.display_name || doctor?.name}
</p>
</Link>
```
```jsx
// app/component/ItemDoctor.js:89
<Link
href={`/doctor/${doctor?.uuid}`}
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
>
```
### اسکلتی که uuid ندارد
```jsx
// app/doctors/loading.js
{Array.from({ length: 6 }).map((_, idx) => (
<ItemDoctor key={idx} doctor={{ id: idx }} loading setDoctors={() => {}} />
))}
```
### تنها بخش شهرمحور صفحهٔ اصلی
```jsx
// components/home/TextHeader.js
const { matchedCity } = await getStateInfo();
...
<p className="…">با {matchedCity?.site_name}</p>
<h1 className="…">{matchedCity?.slogan}</h1>
```
```jsx
// components/home/index.js
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
<TextHeader />
<SearchBar />
<FrequentSearches />
</div>
```
### الگوی متنِ یکتا که از قبل هست و باید تقلید شود
```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` نیست — اسکلت
بارگذاری، یا رکورد ناقص — همان محتوا بدون `<a>` رندر شود.
```jsx
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
```
- عنوان: اگر `href` تهی بود، `<p>` بدون `Link`.
- دکمهٔ فلش: اگر `href` تهی بود، دکمه رندر نشود (در اسکلت هم معنایی ندارد).
`?.` را از `doctor?.uuid` داخل رشتهٔ الگو حذف کن؛ همان بود که `undefined` را به URL می‌برد.
**نحوه تست:** تست کامپوننتی در `app/component/ItemDoctor.test.js`
پزشک با `uuid` دو لینک به `/doctor/<uuid>` دارد؛ آبجکت `{ 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=<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/<slug>`.
این جای `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://<city>-nobat.ir/ | grep -oE '<meta name="description" content="[^"]*"'`
روی دو دامنه، و متفاوت بودنشان.
## نکات مهم
- **قرارداد API عوض نمی‌شود.** `doctor-counts` از قبل وجود دارد و مستند است. هیچ کاری در
`clinicpro` لازم نیست.
- **الگو: Variant-pick با hash** — همان که `lib/specialtyContent.js` برای صفحات تخصص
دارد. دلیل انتخابش این است که مسئله دقیقاً همان است: یک قالب روی ده‌ها دامنه که اگر
ثابت بماند دوباره duplicate می‌سازد. متن تصادفیِ ناپایدار هم بدتر است، چون هر
rebuild محتوا را عوض می‌کند؛ seed از نام شهر هر دو را حل می‌کند.
- **بخش آمار نباید عدد صفر نشان دهد.** شهرِ بدون پزشک با «۰ پزشک» بدتر از نبودِ بخش است.
- **`FrequentSearches` دست‌نخورده بماند.** آن فهرست ثابتِ تخصص‌هاست و کارکرد ناوبری دارد؛
بخش تازه مکمل آن است نه جایگزینش.
- **این تغییر SEO است و اثرش فوری نیست.** بعد از deploy باید در Search Console دوباره
ایندکس درخواست شود و هفته‌ها طول می‌کشد. در گزارش پایانی این را صریح بنویس تا انتظار
اشتباه ساخته نشود.
- **`Validate fix` را برای دسته‌های دیگر نزن.** دلیلش در سند همراه آمده؛ زدنش روی
noindex عمدی همیشه شکست می‌خورد و در Search Console نویز می‌سازد.
- خط پایهٔ فعلی ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبل‌موجود در
`lib/lib.test.js` و `lib/getStateInfo.test.js`، و `npm run lint` سه خطا در فایل‌های
بی‌ربط. این‌ها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.