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