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

13 KiB
Raw Permalink Blame History

رفع لینک /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 userhttps://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 اختیاری

// 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>
// app/component/ItemDoctor.js:89
<Link
  href={`/doctor/${doctor?.uuid}`}
  aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
>

اسکلتی که uuid ندارد

// app/doctors/loading.js
{Array.from({ length: 6 }).map((_, idx) => (
  <ItemDoctor key={idx} doctor={{ id: idx }} loading setDoctors={() => {}} />
))}

تنها بخش شهرمحور صفحهٔ اصلی

// components/home/TextHeader.js
const { matchedCity } = await getStateInfo();
...
<p className="…">با {matchedCity?.site_name}</p>
<h1 className="…">{matchedCity?.slogan}</h1>
// 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>

الگوی متنِ یکتا که از قبل هست و باید تقلید شود

// 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> رندر شود.

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 واقعی:

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.jsfetchReq بگیر، نه با کلاینت axios.

محتوا:

  • یک پاراگراف یکتا با همان الگوی buildSpecialtyIntro: تابع تازه‌ای کنارش در lib/specialtyContent.js به نام buildCityIntro(cityName, doctorCount, topSpecialties) بنویس. الگو Variant-pick با hash است، نه متن ثابت — دلیلش همان دلیل تابع موجود: متنِ یکسان روی ۳۰ دامنه دوباره همان duplicate را می‌سازد؛ seed از نام شهر می‌آید تا هر شهر واگرا شود و خروجی هم پایدار بماند.
  • شمار واقعی: «N پزشک در M تخصص در <شهر>».
  • شش تا هشت تخصصِ پرپزشکِ همان شهر با شمارشان، لینک به /specialties/<slug>. این جای FrequentSearches را نمی‌گیرد؛ آن فهرست ثابت است و این یکی شهرمحور.

روی دامنهٔ ریشه (isRootCity) کل بخش رندر نشود — آنجا شهری وجود ندارد.

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:
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 سه خطا در فایل‌های بی‌ربط. این‌ها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.