- ایجاد پرامپت برای رفع خطای 404 ناشی از لینک `/doctor/undefined` در سایتهای بهبهان و یاسوج. - افزودن کامپوننت `CityHighlights` برای نمایش محتوای یکتا و آمار پزشکان و تخصصهای پرمراجعه در صفحهٔ اصلی هر شهر. - بهروزرسانی تستهای مربوط به کامپوننتهای `ItemDoctor` و `CityHighlights` برای اطمینان از عدم وجود لینکهای نامعتبر و نمایش صحیح اطلاعات. - ایجاد سند جدید برای مستندسازی خطاهای Search Console که باگ نیستند و نیاز به رفع ندارند. - افزودن تستهای واحد برای توابع `buildCityIntro` و دیگر توابع مرتبط با تولید متن یکتا برای صفحات لیست.
251 lines
13 KiB
Markdown
251 lines
13 KiB
Markdown
# رفع لینک `/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` سه خطا در فایلهای
|
||
بیربط. اینها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.
|