feat: رفع لینک /doctor/undefined و متمایزسازی صفحهٔ اصلی شهرها

- ایجاد پرامپت برای رفع خطای 404 ناشی از لینک `/doctor/undefined` در سایت‌های بهبهان و یاسوج.
- افزودن کامپوننت `CityHighlights` برای نمایش محتوای یکتا و آمار پزشکان و تخصص‌های پرمراجعه در صفحهٔ اصلی هر شهر.
- به‌روزرسانی تست‌های مربوط به کامپوننت‌های `ItemDoctor` و `CityHighlights` برای اطمینان از عدم وجود لینک‌های نامعتبر و نمایش صحیح اطلاعات.
- ایجاد سند جدید برای مستندسازی خطاهای Search Console که باگ نیستند و نیاز به رفع ندارند.
- افزودن تست‌های واحد برای توابع `buildCityIntro` و دیگر توابع مرتبط با تولید متن یکتا برای صفحات لیست.
This commit is contained in:
hamed
2026-08-08 19:14:50 +03:30
parent 79747c443c
commit b494473e40
13 changed files with 1047 additions and 28 deletions
@@ -0,0 +1,250 @@
# رفع لینک `/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` سه خطا در فایل‌های
بی‌ربط. این‌ها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.
+7
View File
@@ -6,6 +6,8 @@ import { getStateInfo } from "@/lib/getStateInfo";
import { buildClinicParams } from "@/helper"; import { buildClinicParams } from "@/helper";
import { listingRobots } from "@/lib/listingRobots"; import { listingRobots } from "@/lib/listingRobots";
import { getRequestOrigin } from "@/lib/getCanonicalUrl"; import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { buildClinicsIntro } from "@/lib/listingIntro";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
export async function generateMetadata({ searchParams }) { export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams; const awaitedParams = await searchParams;
@@ -79,6 +81,11 @@ export default async function Clinics({ searchParams }) {
matchedCity={matchedCity} matchedCity={matchedCity}
matchedState={matchedState} matchedState={matchedState}
origin={origin} origin={origin}
// قرینهٔ /doctors — buildClinicsIntro هم نوشته و تست شده بود ولی رندر نمی‌شد.
intro={buildClinicsIntro(
resolveCityDisplayName(isRoot ? null : matchedCity),
clinics?.meta?.totalRecords
)}
/> />
</Layout> </Layout>
); );
+30 -22
View File
@@ -14,6 +14,12 @@ import LikeD from "@/components/icons/LikeD";
import PointD from "@/components/icons/PointD"; import PointD from "@/components/icons/PointD";
function ItemDoctor({ doctor, loading, setDoctors, priority = false }) { function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
// بدون uuid لینکی وجود ندارد. اسکلتِ بارگذاری آبجکتِ بی‌uuid می‌دهد و پیش از این
// شش `<a href="/doctor/undefined">` در HTML می‌نشست؛ Googlebot همان را crawl می‌کرد
// و ۴۰۴ می‌گرفت (گزارش Search Console، ۳۰ ژوئیه).
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
const label = doctor?.display_name || doctor?.name;
const handleLoading = () => { const handleLoading = () => {
setDoctors((prevDoctors) => setDoctors((prevDoctors) =>
prevDoctors.map((item) => prevDoctors.map((item) =>
@@ -38,11 +44,13 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
</CircularLoading> </CircularLoading>
<div className="flex flex-col items-start justify-center gap-2"> <div className="flex flex-col items-start justify-center gap-2">
<TextLoading loading={loading} width={90} height={15}> <TextLoading loading={loading} width={90} height={15}>
<Link href={`/doctor/${doctor?.uuid}`}> {href ? (
<p className="text-[#3B3B3B] text-[14px] font-bold"> <Link href={href}>
{doctor?.display_name || doctor?.name} <p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
</p> </Link>
</Link> ) : (
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
)}
</TextLoading> </TextLoading>
<TextLoading loading={loading} width={120} height={15}> <TextLoading loading={loading} width={120} height={15}>
<SpecialtyChips specialties={doctor?.specialties} /> <SpecialtyChips specialties={doctor?.specialties} />
@@ -84,23 +92,23 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
: "نوبت‌دهی غیرفعال است"} : "نوبت‌دهی غیرفعال است"}
</p> </p>
</CustomLoading> </CustomLoading>
{/* Arrow */} {/* Arrow — روی کارتِ بدون uuid (اسکلت بارگذاری) اصلاً رندر نمی‌شود؛ دکمه‌ای که
<Link به جایی نمی‌رود، هم بی‌معناست هم لینکِ شکسته در HTML می‌گذارد. */}
href={`/doctor/${doctor?.uuid}`} {href && (
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`} <Link href={href} aria-label={`مشاهده پروفایل ${label || "پزشک"}`}>
> <Button
<Button variant="contained"
variant="contained" onClick={handleLoading}
onClick={handleLoading} disabled={doctor?.loading}
disabled={doctor?.loading} aria-label={`مشاهده پروفایل ${label || "پزشک"}`}
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`} className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit" >
> <div className={doctor && doctor.loading ? "opacity-0" : ""}>
<div className={doctor && doctor.loading ? "opacity-0" : ""}> <ArrowLeftD />
<ArrowLeftD /> </div>
</div> </Button>
</Button> </Link>
</Link> )}
</li> </li>
); );
} }
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import ItemDoctor from "@/app/component/ItemDoctor";
import Loading from "@/app/doctors/loading";
const DOCTOR = {
id: 1,
uuid: "84b7ea9e-2fe1-4413-906d-7a5bf05e6f8f",
name: "محمدباقر جهانتاب",
specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }],
active: 1,
free_turn: "شنبه ۱۷:۴۵",
};
const noop = () => {};
describe("ItemDoctor — لینک پروفایل", () => {
it("پزشک با uuid لینک می‌گیرد", () => {
render(<ItemDoctor doctor={DOCTOR} setDoctors={noop} />);
const links = screen.getAllByRole("link");
expect(links.length).toBeGreaterThan(0);
for (const link of links) {
expect(link).toHaveAttribute("href", `/doctor/${DOCTOR.uuid}`);
}
});
it("کارت بدون uuid هیچ لینکی ندارد", () => {
render(<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("رشتهٔ undefined هرگز در HTML نمی‌نشیند", () => {
// ریشهٔ خطای Not found (404) در Search Console: href="/doctor/undefined".
const { container } = render(
<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />
);
expect(container.innerHTML).not.toContain("undefined");
});
it("نام پزشک حتی بدون لینک نمایش داده می‌شود", () => {
render(<ItemDoctor doctor={{ id: 0, name: "بدون شناسه" }} setDoctors={noop} />);
expect(screen.getByText("بدون شناسه")).toBeInTheDocument();
});
it("display_name بر name مقدم است", () => {
render(
<ItemDoctor
doctor={{ ...DOCTOR, display_name: "دکتر جهانتاب" }}
setDoctors={noop}
/>
);
expect(screen.getByText("دکتر جهانتاب")).toBeInTheDocument();
});
});
describe("اسکلت بارگذاری /doctors", () => {
it("هیچ لینک doctor/undefined تولید نمی‌کند", () => {
const { container } = render(<Loading />);
expect(container.innerHTML).not.toContain("/doctor/undefined");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("همچنان شش کارت اسکلت می‌سازد", () => {
const { container } = render(<Loading />);
expect(container.querySelectorAll("li")).toHaveLength(6);
});
});
+9
View File
@@ -5,6 +5,8 @@ import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req"; import { fetchReq } from "@/lib/req";
import { isNextRedirectError } from "@/lib/maintenance"; import { isNextRedirectError } from "@/lib/maintenance";
import { listingRobots } from "@/lib/listingRobots"; import { listingRobots } from "@/lib/listingRobots";
import { buildDoctorsIntro } from "@/lib/listingIntro";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
export async function generateMetadata({ searchParams }) { export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams; const awaitedParams = await searchParams;
@@ -75,6 +77,13 @@ async function Doctors({ searchParams }) {
params={searchParams} params={searchParams}
matchedCity={matchedCity} matchedCity={matchedCity}
matchedState={matchedState} matchedState={matchedState}
// متن مقدمه سمت سرور ساخته می‌شود تا در HTML اولیه باشد و خزنده ببیندش.
// buildDoctorsIntro از قبل نوشته و تست شده بود اما هیچ‌جا رندر نمی‌شد، پس
// این صفحه روی هر ۳۵ دامنه محتوای یکسان داشت.
intro={buildDoctorsIntro(
resolveCityDisplayName(isRoot ? null : matchedCity),
doctors?.meta?.totalRecords
)}
/> />
</Layout> </Layout>
); );
+6 -1
View File
@@ -14,7 +14,7 @@ import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity"; import { isRootCity } from "@/lib/rootCity";
import { resolveCityDisplayName } from "@/lib/domainHelpers"; import { resolveCityDisplayName } from "@/lib/domainHelpers";
function ClinicsPage({ clinics, matchedCity, matchedState, origin }) { function ClinicsPage({ clinics, matchedCity, matchedState, origin, intro }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [page, setPage] = useState(clinics?.meta?.currentPage || 1); const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
const [filteredClinics, setFilteredClinics] = useState(clinics?.data); const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
@@ -49,6 +49,11 @@ function ClinicsPage({ clinics, matchedCity, matchedState, origin }) {
setFilteredClinics={setFilteredClinics} setFilteredClinics={setFilteredClinics}
/> />
</HeadPageList> </HeadPageList>
{intro && (
<p className="padding-responsive text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[24px] text-justify">
{intro}
</p>
)}
<List <List
limit={12} limit={12}
page={page} page={page}
+6 -1
View File
@@ -9,7 +9,7 @@ import states from "@/data/state.json";
import cities from "@/data/city.json"; import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity"; import { isRootCity } from "@/lib/rootCity";
function DoctorsPage({ matchedCity, matchedState, list }) { function DoctorsPage({ matchedCity, matchedState, list, intro }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [page, setPage] = useState(list?.meta?.currentPage || 1); const [page, setPage] = useState(list?.meta?.currentPage || 1);
const [doctors, setDoctors] = useState(list?.data); const [doctors, setDoctors] = useState(list?.data);
@@ -60,6 +60,11 @@ function DoctorsPage({ matchedCity, matchedState, list }) {
matchedCity={matchedCity} matchedCity={matchedCity}
matchedState={matchedState} matchedState={matchedState}
/> />
{intro && (
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[24px] text-justify">
{intro}
</p>
)}
<ListDoctors <ListDoctors
limit={12} limit={12}
data={list} data={list}
+138
View File
@@ -0,0 +1,138 @@
import Link from "next/link";
import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req";
import { buildCityIntro } from "@/lib/listingIntro";
import { isNoindexDoctor } from "@/lib/entityQuality";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const SHOWN_SPECIALTIES = 8;
const SHOWN_DOCTORS = 4;
/**
* بخش شهرمحور صفحهٔ اصلی — متن یکتا، آمار واقعی و تخصص‌های پرمراجعهٔ همان شهر.
*
* چرا لازم است: صفحهٔ اصلی تا پیش از این فقط `site_name` و `slogan` را شهری می‌کرد و
* متن رندرشدهٔ دو دامنه ۹۹٫۰٪ یکسان بود. گوگل canonicalِ درستِ ما را رد می‌کرد و
* صفحهٔ اصلی شهر را با دامنهٔ دیگر تجمیع می‌کرد. تمایز فقط با تگ حل نمی‌شود؛ محتوا
* باید واقعاً فرق کند، و آمارِ هر شهر خودبه‌خود فرق دارد.
*
* جدا از FrequentSearches است: آن فهرست ثابتِ تخصص‌هاست و روی همهٔ دامنه‌ها یکی؛ این
* یکی از داده‌ی همان شهر ساخته می‌شود.
*/
async function CityHighlights() {
const { matchedCity, isRoot } = await getStateInfo();
// دامنهٔ ریشه شهر نیست؛ آمار و متن شهری آنجا معنا ندارد.
if (isRoot || !matchedCity?.id) return null;
const [counts, doctors] = await Promise.all([
fetchCityCounts(matchedCity.id),
fetchCityDoctors(matchedCity.id),
]);
if (counts.length === 0) return null;
const doctorCount = counts.reduce((sum, s) => sum + s.number_of_doctors, 0);
if (doctorCount === 0) return null;
const top = counts.slice(0, SHOWN_SPECIALTIES);
const cityName = matchedCity.name;
return (
<section className="w-full max-w-[900px] z-[50]">
<p className="text-[#525252] text-[13px] md:text-[15px] font-normal leading-8 text-justify">
{buildCityIntro(cityName, doctorCount, top[0].name)}
</p>
<p className="mt-[20px] text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
تخصصهای پرمراجعه در {cityName}
</p>
<ul className="mt-[12px] flex flex-wrap items-center gap-[8px]">
{top.map((specialty) => (
<li key={specialty.id}>
<Link
href={`/specialties/${specialty.slug}`}
className="flex items-center gap-1.5 py-[6px] px-[12px] rounded-[4px] bg-[#FFF] border border-[#D7D7D7] text-[12px] lg:text-[14px] font-normal text-[#3B3B3B] hover:bg-[#EFEFEF] transition-all duration-500"
>
<span>{specialty.name}</span>
<span className="text-[#7E7E7E]">{specialty.number_of_doctors}</span>
</Link>
</li>
))}
</ul>
{doctors.length > 0 && (
<>
<p className="mt-[20px] text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
پزشکان {cityName}
</p>
<ul className="mt-[12px] flex flex-wrap items-center gap-[8px]">
{doctors.map((doctor) => (
<li key={doctor.uuid}>
<Link
href={`/doctor/${doctor.uuid}`}
className="flex items-center gap-1.5 py-[6px] px-[12px] rounded-[4px] bg-[#FFF] border border-[#D7D7D7] text-[12px] lg:text-[14px] font-normal text-[#3B3B3B] hover:bg-[#EFEFEF] transition-all duration-500"
>
<span>{doctor.display_name || doctor.name}</span>
{doctor.specialty && (
<span className="text-[#7E7E7E]">{doctor.specialty}</span>
)}
</Link>
</li>
))}
</ul>
</>
)}
</section>
);
}
/**
* تخصص‌های همان شهر که پزشک دارند، از پرپزشک به کم‌پزشک.
*
* خطا اینجا بلعیده می‌شود و آرایهٔ خالی برمی‌گردد: صفحهٔ اصلی مهم‌ترین صفحهٔ سایت است
* و قطعیِ API نباید سفیدش کند. این همان مرز واقعیِ error handling است — یک I/O boundary.
*/
/**
* چند پزشکِ همان شهر برای لینک مستقیم از صفحهٔ اصلی.
*
* بک‌اند خودش «دارای نوبت» را بالاتر مرتب می‌کند، پس ترتیب پیش‌فرض همان چیزی است که
* می‌خواهیم. پروفایل noindex کنار گذاشته می‌شود — همان سیاستی که sitemap اعمال
* می‌کند؛ لینک دادن از صفحهٔ اصلی به صفحه‌ای که خودمان از ایندکس بیرونش گذاشته‌ایم،
* سیگنال متناقض است.
*/
async function fetchCityDoctors(cityId) {
const data = await fetchReq(
`${API_URL}/api/v1/doctors?city_id=${cityId}&limit=12`
);
const items = data?.data ?? [];
if (!Array.isArray(items)) return [];
return items
.filter((d) => !isNoindexDoctor(d))
.slice(0, SHOWN_DOCTORS)
.map((d) => ({
uuid: d.uuid,
name: d.name,
display_name: d.display_name,
specialty: d.specialties?.[0]?.name ?? null,
}));
}
async function fetchCityCounts(cityId) {
const data = await fetchReq(
`${API_URL}/api/v1/specialties/doctor-counts?city_id=${cityId}`
);
const items = data?.data?.data ?? [];
if (!Array.isArray(items)) return [];
return items
.map((s) => ({ ...s, number_of_doctors: Number(s?.number_of_doctors) || 0 }))
.filter((s) => s.slug && s.number_of_doctors > 0)
.sort((a, b) => b.number_of_doctors - a.number_of_doctors);
}
export default CityHighlights;
+207
View File
@@ -0,0 +1,207 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
const getStateInfo = vi.fn();
const fetchReq = vi.fn();
vi.mock("@/lib/getStateInfo", () => ({ getStateInfo: (...a) => getStateInfo(...a) }));
vi.mock("@/lib/req", () => ({ fetchReq: (...a) => fetchReq(...a) }));
const { default: CityHighlights } = await import("@/components/home/CityHighlights");
const BEHBAHAN = { id: 41, name: "بهبهان", site_name: "بهبهان نوبت" };
const counts = (rows) => ({ data: { data: rows } });
const ROWS = [
{ id: 1, name: "دندانپزشک", slug: "dentistry", number_of_doctors: 12 },
{ id: 2, name: "داخلی", slug: "internal-medicine", number_of_doctors: 7 },
{ id: 3, name: "بدون اسلاگ", slug: null, number_of_doctors: 9 },
{ id: 4, name: "بدون پزشک", slug: "empty", number_of_doctors: 0 },
];
const DOCTORS = {
data: [
{
uuid: "u-1",
name: "معصومه خدری",
owner_status: "claimed",
specialties: [{ id: "1", name: "داخلی" }],
address: [{ id: 1 }],
},
{
uuid: "u-2",
name: "پروفایل بی‌مالک",
owner_status: "unclaimed",
specialties: [{ id: "1", name: "داخلی" }],
},
],
};
const renderComponent = async () => render(await CityHighlights());
/** مسیرِ فراخوانی را از روی URL تشخیص می‌دهد؛ ترتیب Promise.all نباید تست را بشکند. */
const routeMock = ({ countsBody = counts(ROWS), doctorsBody = DOCTORS } = {}) =>
fetchReq.mockImplementation((url) =>
Promise.resolve(url.includes("doctor-counts") ? countsBody : doctorsBody)
);
beforeEach(() => {
getStateInfo.mockReset();
fetchReq.mockReset();
getStateInfo.mockResolvedValue({ matchedCity: BEHBAHAN, isRoot: false });
routeMock();
});
describe("CityHighlights", () => {
it("متن یکتا و نام شهر را نشان می‌دهد", async () => {
await renderComponent();
expect(screen.getAllByText(/بهبهان/).length).toBeGreaterThan(0);
expect(
screen.getByText("تخصص‌های پرمراجعه در بهبهان")
).toBeInTheDocument();
});
it("تخصص‌ها را از پرپزشک به کم‌پزشک می‌چیند و لینک می‌دهد", async () => {
await renderComponent();
const links = screen.getAllByRole("link");
expect(links[0]).toHaveAttribute("href", "/specialties/dentistry");
expect(links[1]).toHaveAttribute("href", "/specialties/internal-medicine");
});
it("تخصص بدون slug یا بدون پزشک نمایش داده نمی‌شود", async () => {
await renderComponent();
expect(screen.queryByText("بدون اسلاگ")).not.toBeInTheDocument();
expect(screen.queryByText("بدون پزشک")).not.toBeInTheDocument();
});
it("شمار کل پزشکان در متن می‌آید", async () => {
await renderComponent();
// فقط ردیف‌های معتبر: 12 + 7 = 19
expect(screen.getByText(/19/)).toBeInTheDocument();
});
// ── پزشکان شاخص ───────────────────────────────────────────────────────────
it("پزشکان همان شهر را با لینک مستقیم نشان می‌دهد", async () => {
await renderComponent();
expect(screen.getByText("پزشکان بهبهان")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /معصومه خدری/ })).toHaveAttribute(
"href",
"/doctor/u-1"
);
});
it("پروفایل noindex لینک نمی‌گیرد", async () => {
// همان سیاست sitemap؛ لینک از صفحهٔ اصلی به صفحهٔ ایندکس‌نشده سیگنال متناقض است.
await renderComponent();
expect(screen.queryByText("پروفایل بی‌مالک")).not.toBeInTheDocument();
});
it("شهرِ بدون پزشکِ ایندکس‌پذیر، بخش پزشکان را حذف می‌کند ولی تخصص‌ها می‌مانند", async () => {
routeMock({ doctorsBody: { data: [] } });
await renderComponent();
expect(screen.queryByText(/^پزشکان /)).not.toBeInTheDocument();
expect(screen.getByText("تخصص‌های پرمراجعه در بهبهان")).toBeInTheDocument();
});
it("قطعی سرویس پزشکان بقیهٔ بخش را نمی‌شکند", async () => {
routeMock({ doctorsBody: null });
await renderComponent();
expect(screen.getByText("تخصص‌های پرمراجعه در بهبهان")).toBeInTheDocument();
});
it("حداکثر چهار پزشک نشان می‌دهد", async () => {
routeMock({
doctorsBody: {
data: Array.from({ length: 10 }, (_, i) => ({
uuid: `d-${i}`,
name: `پزشک ${i}`,
owner_status: "claimed",
specialties: [{ id: "1", name: "داخلی" }],
})),
},
});
await renderComponent();
const doctorLinks = screen
.getAllByRole("link")
.filter((a) => a.getAttribute("href").startsWith("/doctor/"));
expect(doctorLinks).toHaveLength(4);
});
// ── مسیرهایی که باید چیزی رندر نکنند ──────────────────────────────────────
it("دامنهٔ ریشه هیچ بخشی نمی‌سازد", async () => {
getStateInfo.mockResolvedValue({ matchedCity: { id: 600 }, isRoot: true });
const { container } = await renderComponent();
expect(container).toBeEmptyDOMElement();
expect(fetchReq).not.toHaveBeenCalled();
});
it("قطعی API صفحه را نمی‌شکند", async () => {
// fetchReq در خطا خودش null می‌دهد؛ صفحهٔ اصلی نباید سفید شود.
fetchReq.mockResolvedValue(null);
const { container } = await renderComponent();
expect(container).toBeEmptyDOMElement();
});
it("پاسخ با شکل غیرمنتظره هم کرش نمی‌کند", async () => {
fetchReq.mockResolvedValue({ data: { data: "not-an-array" } });
const { container } = await renderComponent();
expect(container).toBeEmptyDOMElement();
});
it("شهرِ بدون پزشک بخش را حذف می‌کند، نه اینکه صفر نشان دهد", async () => {
fetchReq.mockResolvedValue(
counts([{ id: 9, name: "x", slug: "x", number_of_doctors: 0 }])
);
const { container } = await renderComponent();
expect(container).toBeEmptyDOMElement();
expect(container.textContent).not.toContain("0 پزشک");
});
it("شهرِ بدون شناسه بخش را حذف می‌کند", async () => {
getStateInfo.mockResolvedValue({ matchedCity: null, isRoot: false });
const { container } = await renderComponent();
expect(container).toBeEmptyDOMElement();
});
it("حداکثر هشت تخصص نشان می‌دهد", async () => {
fetchReq.mockResolvedValue(
counts(
Array.from({ length: 20 }, (_, i) => ({
id: i,
name: `تخصص ${i}`,
slug: `s-${i}`,
number_of_doctors: 20 - i,
}))
)
);
await renderComponent();
expect(screen.getAllByRole("link")).toHaveLength(8);
});
});
+2
View File
@@ -3,6 +3,7 @@ import Image from "next/image";
import SearchBar from "./search"; import SearchBar from "./search";
import TextHeader from "./TextHeader"; import TextHeader from "./TextHeader";
import FrequentSearches from "./FrequentSearches"; import FrequentSearches from "./FrequentSearches";
import CityHighlights from "./CityHighlights";
function HomePage() { function HomePage() {
return ( return (
@@ -18,6 +19,7 @@ function HomePage() {
<TextHeader /> <TextHeader />
<SearchBar /> <SearchBar />
<FrequentSearches /> <FrequentSearches />
<CityHighlights />
</div> </div>
</div> </div>
); );
+154
View File
@@ -0,0 +1,154 @@
# خطاهای Search Console که باگ نیستند
هر دامنهٔ شهری در Google Search Console چند دستهٔ «مشکل» نشان می‌دهد که خروجیِ **تصمیم‌های
عمدی** سیستم‌اند، نه نقص. این سند مرجع است تا هر بار کسی داشبورد را باز کرد، دوباره دنبال
رفع چیزی نگردد که نباید رفع شود.
سنجش‌های این سند روی `behbahan-nobat.ir` و `yasuj-nobat.ir` در ۵ اوت ۲۰۲۶ گرفته شده‌اند.
> آنچه **واقعاً** باگ بود و جدا رفع می‌شود، اینجا نیست:
> `/doctor/undefined` و یکسان‌بودن صفحهٔ اصلی شهرها.
> ← `.claude/prompt/gsc-undefined-link-and-home-uniqueness.md`
---
## ۱. Excluded by 'noindex' tag
**نمونه:** ۸۰ صفحه در یاسوج، ۱۲ صفحه در بهبهان — عمدتاً `/doctor/<uuid>`.
**چرا عمدی است**
پروفایل پزشکی که از نظام پزشکی وارد شده و خودِ پزشک آن را claim نکرده، محتوای قابل
اتکا نیست. `lib/entityQuality.js` همین را اعمال می‌کند:
```js
export function isUnclaimedDoctor(doctor) {
return doctor?.owner_status !== "claimed";
}
export function isNoindexDoctor(doctor) {
return isThinDoctor(doctor) || isUnclaimedDoctor(doctor);
}
```
همین تابع هم `robots` صفحهٔ پزشک را می‌سازد و هم ورودی `app/sitemap.js` را فیلتر می‌کند، تا
چیزی که noindex است هرگز در sitemap اعلام نشود.
پزشک «بی‌محتوا» هم noindex می‌شود: بدون نام واقعی (شمارهٔ تلفن یا «تست» به‌جای نام)، یا
بدون هیچ تخصص و مکانی.
**چه چیزی این را برمی‌دارد**
فقط claim شدن پروفایل توسط خودِ پزشک، یا کامل شدن نام و تخصص و مکان. **هیچ تغییر کدی
لازم نیست و هیچ تغییر کدی هم نباید انجام شود.**
**چرا `VALIDATE FIX` شکست می‌خورد**
در یاسوج این اعتبارسنجی روی ۲۸ ژوئیه شروع و ۵ اوت `Validation failed` شد. طبیعی است:
اعتبارسنجی یعنی «به گوگل بگو دیگر noindex نیست» در حالی که صفحات عمداً noindex مانده‌اند.
**این دکمه را برای این دسته نزنید.** هر بار شکست می‌خورد و فقط تاریخچهٔ داشبورد را شلوغ می‌کند.
---
## ۲. Alternate page with proper canonical tag
**نمونه در بهبهان:** `/about-us`، `/contact-us`، و دو `/clinic/<uuid>`.
**چرا عمدی است**
استراتژی canonical سه‌دسته است و در بالای `lib/getCanonicalUrl.js` مستند شده:
| دسته | مسیرها | canonical |
|---|---|---|
| C1-a | `/`، `/doctors`، `/clinics`، `/specialties`، `/blogs` | self روی همان دامنهٔ شهری |
| C1-b | `/doctor/[uuid]`، `/clinic/[uuid]`، `/blog/[slug]` | دامنهٔ شهرِ خودِ موجودیت |
| C1-c | `/about-us`، `/contact-us`، `/terms`، `/privacy` | دامنهٔ اصلی `nobat724.com` |
`/about-us` و `/contact-us` روی همهٔ دامنه‌ها محتوای یکسان دارند، پس اعتبارشان عمداً روی
دامنهٔ اصلی تجمیع می‌شود. کلینیکی هم که شهرش بهبهان نیست، canonical‌اش به دامنهٔ شهر خودش
می‌رود تا یک موجودیت روی دو دامنه دو صفحهٔ رقیب نسازد.
`Alternate page with proper canonical tag` در Search Console **گزارش اطلاعاتی** است، نه خطا.
عبارت `proper` در عنوانش یعنی گوگل canonical ما را پذیرفته. این دقیقاً نتیجهٔ مطلوب است.
---
## ۳. Page with redirect
**نمونه:** `http://behbahan-nobat.ir/` — یک صفحه.
ریدایرکت `http` به `https` است. رفتار درست و لازم. کاری ندارد.
---
## ۴. آدرس‌های قدیمی بلاگ که ۴۰۴ می‌دهند
**نمونه در بهبهان:** چهار آدرس با slug انگلیسی، مثل
`/blog/warning-signs-sleep-elderly` و `/blog/alamat-tahooe-mokarrar-dar-bimaran-sartani`.
**چه اتفاقی افتاده**
فرمت slug مقاله‌ها عوض شده. slug امروز فارسی است با پسوند هگز هشت‌کاراکتری — نمونهٔ
واقعی از API:
```
آلرژی-دارویی-تشخیص-علائم-خطرناک-و-ضرورت-مراجعه-به-پزشک-58d2e880
```
slugهای انگلیسیِ قبلی دیگر وجود ندارند و **نگاشت قدیم‌به‌جدید هیچ‌جا ذخیره نشده**.
**تصمیم: ۴۰۴ پذیرفته می‌شود**
۴۰۴ پاسخ صحیح برای محتوایی است که دیگر با آن نشانی وجود ندارد. گوگل خودش این آدرس‌ها را
از ایندکس حذف می‌کند. ساختن redirect نیازمند پیدا کردن دستیِ معادل هر کدام است و فقط
چهار آدرس در میان است.
بررسی شد که خودِ رفتار ۴۰۴ سالم است — سه نشانیِ ناموجود، هر سه `404`:
```
/blog/warning-signs-sleep-elderly → 404
/blog/definitely-does-not-exist-xyz-123 → 404
/blog/alamat-tahooe-mokarrar-dar-bimaran-sartani → 404
```
**اگر روزی slug دوباره عوض شد**, این‌بار قبلش نگاشت را نگه دارید. تکرار همین وضعیت با
تعداد بیشتر، دیگر با «۴۰۴ بپذیر» حل نمی‌شود.
---
## ۵. Discovered — currently not indexed
**نمونه:** ۷۳ آدرس در یاسوج — صفحات تخصص، کلینیک، پزشک و بلاگ.
**معنایش**
گوگل این آدرس‌ها را پیدا کرده اما هنوز crawl نکرده. تقریباً همیشه یعنی سایت در نظر گوگل
اولویت crawl پایینی دارد، نه اینکه صفحه خراب باشد.
**چرا اینجا رخ می‌دهد**
دو عامل، و هر دو در حال رفع‌اند یا عمدی‌اند:
۱. سهم بزرگی از صفحات دامنه noindex است (بند ۱). سایتی که بیشتر صفحاتش noindex است،
سیگنال ضعیف‌تری برای صرف بودجهٔ crawl می‌دهد.
۲. صفحهٔ اصلی شهرها تا پیش از این ۹۹٪ شبیه هم بود. این مورد **باگ است** و جدا رفع می‌شود
(پرامپت بالای این سند).
**کاری که نباید کرد**
آدرس‌ها را دستی و دسته‌ای برای ایندکس submit نکنید. مشکل کمبودِ کشف نیست؛ اعلام دوبارهٔ
همان آدرس‌ها چیزی را جلو نمی‌اندازد.
---
## خلاصهٔ تصمیم
| دستهٔ Search Console | وضعیت | اقدام |
|---|---|---|
| Excluded by 'noindex' tag | عمدی | هیچ. `Validate fix` نزنید |
| Alternate page with proper canonical | عمدی و مطلوب | هیچ |
| Page with redirect | طبیعی | هیچ |
| Not found (404) — بلاگ‌های قدیمی | پذیرفته‌شده | هیچ |
| Not found (404) — `/doctor/undefined` | **باگ** | پرامپت جداگانه |
| Duplicate, Google chose different canonical | **باگ محتوایی** | پرامپت جداگانه |
| Discovered — currently not indexed | پیامد دو مورد بالا | بعد از رفع آن‌ها دوباره سنجیده شود |
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import cityData from "@/data/city.json";
import {
buildCityIntro,
buildClinicsIntro,
buildDoctorsIntro,
} from "@/lib/listingIntro";
const CITIES = ["بهبهان", "یاسوج", "یزد", "تبریز", "اصفهان"];
describe("buildCityIntro", () => {
it("دو شهر متن یکسان نمی‌گیرند", () => {
const a = buildCityIntro("بهبهان", 12, "جراحی عمومی");
const b = buildCityIntro("یاسوج", 80, "جراحی عمومی");
expect(a).not.toBe(b);
});
it("یک شهر در دو فراخوانی همان متن را می‌دهد", () => {
// ناپایداری یعنی محتوای صفحهٔ اصلی با هر rebuild عوض شود.
expect(buildCityIntro("بهبهان", 12, "جراحی عمومی")).toBe(
buildCityIntro("بهبهان", 12, "جراحی عمومی")
);
});
it("نام شهر و عدد و تخصص در متن می‌آیند", () => {
const text = buildCityIntro("بهبهان", 12, "جراحی عمومی");
expect(text).toContain("بهبهان");
expect(text).toContain("12");
expect(text).toContain("جراحی عمومی");
});
it("شمار صفر جمله را نمی‌شکند", () => {
const text = buildCityIntro("شهر تازه", 0, "");
expect(text).toContain("شهر تازه");
expect(text).not.toContain("undefined");
expect(text).not.toContain("NaN");
});
it("ورودی نامعتبر به صفر و تخصص پیش‌فرض می‌افتد", () => {
const text = buildCityIntro("شهر", "abc", null);
expect(text).not.toContain("NaN");
expect(text).toContain("پزشکی عمومی");
});
it("متن‌ها روی شهرهای واقعی به یک قالب واحد نمی‌چسبند", () => {
// اگر همهٔ شهرها یک variant بگیرند، دوباره همان duplicate ساخته می‌شود.
const texts = CITIES.map((c) => buildCityIntro(c, 10, "قلب و عروق"));
expect(new Set(texts).size).toBeGreaterThan(1);
});
it("متن به قدر کافی بلند هست که سیگنال محتوایی باشد", () => {
for (const city of CITIES) {
expect(buildCityIntro(city, 10, "قلب").length).toBeGreaterThan(200);
}
});
it("روی همهٔ شهرهای واقعی city.json بدون خطا اجرا می‌شود", () => {
for (const city of cityData) {
const text = buildCityIntro(city.name, 5, "داخلی");
expect(typeof text).toBe("string");
expect(text).not.toContain("undefined");
}
});
});
describe("جملهٔ آماری متن صفحات لیست", () => {
it("دو شهرِ هم‌variant با آمار متفاوت، متن یکسان نمی‌گیرند", () => {
// بهبهان و یاسوج روی CLINIC_INTROS هم‌variant‌اند؛ پیش از افزودن عدد، متنشان
// تقریباً یکی می‌شد و شباهت /clinics از ۶۸.۹٪ به ۸۴.۷٪ رفت.
const a = buildClinicsIntro("بهبهان", 12);
const b = buildClinicsIntro("یاسوج", 31);
expect(a).not.toBe(b);
expect(a).toContain("12");
expect(b).toContain("31");
});
it("آمار صفر جمله را اضافه نمی‌کند", () => {
const withZero = buildDoctorsIntro("بهبهان", 0);
const without = buildDoctorsIntro("بهبهان");
expect(withZero).toBe(without);
expect(withZero).not.toContain("0 پزشک");
});
it("ورودی نامعتبر جمله را اضافه نمی‌کند", () => {
const text = buildDoctorsIntro("بهبهان", "abc");
expect(text).not.toContain("NaN");
expect(text).toBe(buildDoctorsIntro("بهبهان"));
});
it("نام شهر و واحد درست در جمله می‌آید", () => {
expect(buildDoctorsIntro("یزد", 40)).toContain("40 پزشک در یزد");
expect(buildClinicsIntro("یزد", 9)).toContain("9 مرکز درمانی در یزد");
});
});
+62 -4
View File
@@ -16,18 +16,76 @@ const DOCTOR_INTROS = (city) => [
`فهرست پزشکان ${city} در این صفحه گردآوری شده است؛ از پزشک عمومی تا فوق‌تخصص‌های حوزه‌های مختلف. هر پروفایل شامل تخصص، کد نظام پزشکی، محل مطب و بازخورد بیماران پیشین است. با فیلترهای بالای صفحه می‌توانید نتایج را بر اساس تخصص، جنسیت پزشک یا درجهٔ تخصصی محدود کنید تا سریع‌تر به گزینهٔ مناسب برسید. پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ نیازی به تماس تلفنی یا مراجعهٔ حضوری برای گرفتن نوبت نیست. اطلاعات هر پزشک به‌صورت دوره‌ای بازبینی می‌شود تا ساعات کاری و محل پذیرش با وضعیت واقعی مطب هم‌خوان بماند. اگر ترجیح می‌دهید جست‌وجو را از یک حوزهٔ درمانی مشخص شروع کنید، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در ${city} نشان می‌دهد و از هر رشته مستقیم به پزشکان همان حوزه می‌رسید. هزینهٔ هر خدمت نیز پیش از تأیید نهایی نمایش داده می‌شود.`, `فهرست پزشکان ${city} در این صفحه گردآوری شده است؛ از پزشک عمومی تا فوق‌تخصص‌های حوزه‌های مختلف. هر پروفایل شامل تخصص، کد نظام پزشکی، محل مطب و بازخورد بیماران پیشین است. با فیلترهای بالای صفحه می‌توانید نتایج را بر اساس تخصص، جنسیت پزشک یا درجهٔ تخصصی محدود کنید تا سریع‌تر به گزینهٔ مناسب برسید. پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ نیازی به تماس تلفنی یا مراجعهٔ حضوری برای گرفتن نوبت نیست. اطلاعات هر پزشک به‌صورت دوره‌ای بازبینی می‌شود تا ساعات کاری و محل پذیرش با وضعیت واقعی مطب هم‌خوان بماند. اگر ترجیح می‌دهید جست‌وجو را از یک حوزهٔ درمانی مشخص شروع کنید، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در ${city} نشان می‌دهد و از هر رشته مستقیم به پزشکان همان حوزه می‌رسید. هزینهٔ هر خدمت نیز پیش از تأیید نهایی نمایش داده می‌شود.`,
`اینجا می‌توانید متخصصان فعال ${city} را با هم مقایسه کنید. اطلاعات هر پزشک — رشتهٔ تخصصی، سابقهٔ فعالیت، نشانی محل پذیرش و امتیاز مراجعان — به‌شکل یکسان نمایش داده می‌شود تا مقایسه منصفانه باشد. امتیازها از بیماران واقعی همان پزشک جمع‌آوری شده‌اند و نه از منابع عمومی، بنابراین تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند. برخی پزشکان بیش از یک محل پذیرش دارند و ساعات کاری هر محل جداگانه اعلام شده است؛ هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبت با برنامهٔ روزانه‌تان هماهنگ باشد. رزرو به‌صورت شبانه‌روزی انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من امکان‌پذیر است و آن بازهٔ زمانی برای بیمار دیگری آزاد می‌شود.`, `اینجا می‌توانید متخصصان فعال ${city} را با هم مقایسه کنید. اطلاعات هر پزشک — رشتهٔ تخصصی، سابقهٔ فعالیت، نشانی محل پذیرش و امتیاز مراجعان — به‌شکل یکسان نمایش داده می‌شود تا مقایسه منصفانه باشد. امتیازها از بیماران واقعی همان پزشک جمع‌آوری شده‌اند و نه از منابع عمومی، بنابراین تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند. برخی پزشکان بیش از یک محل پذیرش دارند و ساعات کاری هر محل جداگانه اعلام شده است؛ هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبت با برنامهٔ روزانه‌تان هماهنگ باشد. رزرو به‌صورت شبانه‌روزی انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من امکان‌پذیر است و آن بازهٔ زمانی برای بیمار دیگری آزاد می‌شود.`,
`دسترسی به پزشک مناسب در ${city} با این فهرست ساده‌تر می‌شود. به‌جای جست‌وجو میان منابع پراکنده، همهٔ متخصصان شهر را با اطلاعات ساختاریافته یک‌جا می‌بینید و می‌توانید بر اساس تخصص یا نزدیکی محل مطب انتخاب کنید. رزرو نوبت به‌صورت آنلاین و شبانه‌روزی انجام می‌شود: تاریخ و ساعت خالی را انتخاب می‌کنید، تأیید می‌گیرید و پیامک تأییدیه دریافت می‌کنید. در صورت تغییر برنامه نیز لغو نوبت از حساب کاربری‌تان ممکن است. سابقهٔ نوبت‌های شما در همان حساب باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. مراکز درمانی و کلینیک‌های ${city} هم صفحهٔ اختصاصی خود را دارند؛ اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از آن مسیر هم می‌توانید به همین پزشکان برسید.`, `دسترسی به پزشک مناسب در ${city} با این فهرست ساده‌تر می‌شود. به‌جای جست‌وجو میان منابع پراکنده، همهٔ متخصصان شهر را با اطلاعات ساختاریافته یک‌جا می‌بینید و می‌توانید بر اساس تخصص یا نزدیکی محل مطب انتخاب کنید. رزرو نوبت به‌صورت آنلاین و شبانه‌روزی انجام می‌شود: تاریخ و ساعت خالی را انتخاب می‌کنید، تأیید می‌گیرید و پیامک تأییدیه دریافت می‌کنید. در صورت تغییر برنامه نیز لغو نوبت از حساب کاربری‌تان ممکن است. سابقهٔ نوبت‌های شما در همان حساب باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. مراکز درمانی و کلینیک‌های ${city} هم صفحهٔ اختصاصی خود را دارند؛ اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از آن مسیر هم می‌توانید به همین پزشکان برسید.`,
`پزشکان ${city} در این صفحه بر اساس تخصص، جنسیت و درجهٔ علمی قابل فیلتر شدن‌اند. هر کارت نام پزشک، حوزهٔ فعالیت، امتیاز بیماران و اولین نوبت آزاد را نشان می‌دهد تا بدون باز کردن چند صفحه بتوانید مقایسه کنید. اطلاعات هر پروفایل به‌صورت دوره‌ای بازبینی می‌شود تا ساعات کاری و محل پذیرش با وضعیت واقعی مطب هم‌خوان بماند. امتیازها از بیماران واقعی همان پزشک جمع‌آوری شده‌اند، نه از منابع عمومی، بنابراین تصویر دقیق‌تری از کیفیت خدمات می‌دهند. هزینهٔ هر خدمت هم پیش از تأیید نهایی نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. با انتخاب هر پزشک، محل‌های پذیرش و ساعات کاری هر محل را می‌بینید و نوبت را برای همان محل رزرو می‌کنید. اگر نمی‌دانید به کدام تخصص مراجعه کنید، فهرست تخصص‌ها نقطهٔ شروع بهتری است.`,
`جست‌وجوی پزشک در ${city} از همین‌جا شروع می‌شود. می‌توانید نام پزشک یا نام تخصص را تایپ کنید؛ هر دو در نتایج لحاظ می‌شوند. کارت هر پزشک شامل تخصص‌ها، محل مطب و وضعیت نوبت‌دهی است و پزشکانی که هم‌اکنون نوبت آزاد دارند بالاتر نمایش داده می‌شوند. رزرو نهایی در همان لحظه انجام می‌شود و تأییدیه پیامک می‌گردد؛ نیازی به تماس با منشی مطب نیست. سابقهٔ نوبت‌های شما در همان حساب کاربری باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. اگر پزشک بیش از یک محل پذیرش داشته باشد، ساعات کاری هر محل جداگانه اعلام شده است و هنگام رزرو خودتان محل مراجعه را انتخاب می‌کنید. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من ممکن است و آن بازه برای بیمار دیگری آزاد می‌شود.`,
`فهرست کامل پزشکان ${city} را اینجا می‌بینید، از پزشک عمومی تا فوق‌تخصص. برای هر پزشک، کد نظام پزشکی و نشانی محل پذیرش درج شده تا از هویت حرفه‌ای مطمئن شوید. نوبت‌ها به‌صورت آنلاین و شبانه‌روزی رزرو می‌شوند و در صورت نیاز، لغو نوبت هم از همان حساب کاربری ممکن است. برای دیدن مراکز درمانی به‌جای پزشکان، صفحهٔ کلینیک‌های ${city} فهرست جداگانه‌ای دارد. هر مرکز درمانی صفحهٔ اختصاصی خود را دارد که پزشکان همکار، تخصص‌های موجود و بیمه‌های طرف قرارداد را نشان می‌دهد. اگر ترجیح می‌دهید ابتدا مرکز را انتخاب کنید و بعد پزشک را، آن مسیر هم به همین فهرست می‌رسد. رزرو در هر دو مسیر یکسان است: انتخاب بازهٔ خالی، تأیید، و دریافت پیامک تأییدیه در همان لحظه.`,
]; ];
const CLINIC_INTROS = (city) => [ const CLINIC_INTROS = (city) => [
`کلینیک‌ها و مراکز درمانی ${city} در این صفحه فهرست شده‌اند. برای هر مرکز، تخصص‌های ارائه‌شده، پزشکان همکار، نشانی و اطلاعات تماس نمایش داده می‌شود. اگر ترجیح می‌دهید به‌جای انتخاب پزشک، ابتدا مرکز درمانی را انتخاب کنید، این فهرست نقطهٔ شروع مناسبی است. از صفحهٔ هر کلینیک می‌توانید پزشکان همان مرکز را ببینید و مستقیم برایشان نوبت بگیرید؛ همهٔ مراحل آنلاین و بدون تماس تلفنی انجام می‌شود. صفحهٔ هر مرکز موقعیت روی نقشه و ساعات کاری را هم نشان می‌دهد تا پیش از مراجعه، مسیر و زمان را بدانید. مراکزی که به‌صورت شبانه‌روزی پذیرش دارند نیز مشخص شده‌اند. برای مقایسهٔ مستقیم پزشکان به‌جای مراکز، فهرست پزشکان ${city} گزینهٔ دیگری در اختیارتان می‌گذارد.`, `کلینیک‌ها و مراکز درمانی ${city} در این صفحه فهرست شده‌اند. برای هر مرکز، تخصص‌های ارائه‌شده، پزشکان همکار، نشانی و اطلاعات تماس نمایش داده می‌شود. اگر ترجیح می‌دهید به‌جای انتخاب پزشک، ابتدا مرکز درمانی را انتخاب کنید، این فهرست نقطهٔ شروع مناسبی است. از صفحهٔ هر کلینیک می‌توانید پزشکان همان مرکز را ببینید و مستقیم برایشان نوبت بگیرید؛ همهٔ مراحل آنلاین و بدون تماس تلفنی انجام می‌شود. صفحهٔ هر مرکز موقعیت روی نقشه و ساعات کاری را هم نشان می‌دهد تا پیش از مراجعه، مسیر و زمان را بدانید. مراکزی که به‌صورت شبانه‌روزی پذیرش دارند نیز مشخص شده‌اند. برای مقایسهٔ مستقیم پزشکان به‌جای مراکز، فهرست پزشکان ${city} گزینهٔ دیگری در اختیارتان می‌گذارد.`,
`این صفحه مراکز درمانی فعال ${city} را معرفی می‌کند — از کلینیک‌های تخصصی تا درمانگاه‌های چندتخصصی. هر مرکز صفحهٔ اختصاصی خود را دارد که در آن خدمات، بیمه‌های طرف قرارداد، تخصص‌های موجود و فهرست پزشکان همکار آمده است. جست‌وجو را می‌توانید با فیلتر تخصص محدود کنید تا فقط مراکزی را ببینید که خدمت موردنظرتان را ارائه می‌دهند. رزرو نوبت از همان صفحهٔ مرکز و به‌صورت آنلاین انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. اطلاعات تماس و نشانی هر مرکز به‌صورت دوره‌ای بازبینی می‌شود. اگر مرکزی بیش از یک شعبه دارد، هر شعبه رکورد جداگانهٔ خود را با ساعات کاری مستقل دارد تا انتخاب دقیق‌تری داشته باشید.`, `این صفحه مراکز درمانی فعال ${city} را معرفی می‌کند — از کلینیک‌های تخصصی تا درمانگاه‌های چندتخصصی. هر مرکز صفحهٔ اختصاصی خود را دارد که در آن خدمات، بیمه‌های طرف قرارداد، تخصص‌های موجود و فهرست پزشکان همکار آمده است. جست‌وجو را می‌توانید با فیلتر تخصص محدود کنید تا فقط مراکزی را ببینید که خدمت موردنظرتان را ارائه می‌دهند. رزرو نوبت از همان صفحهٔ مرکز و به‌صورت آنلاین انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. اطلاعات تماس و نشانی هر مرکز به‌صورت دوره‌ای بازبینی می‌شود. اگر مرکزی بیش از یک شعبه دارد، هر شعبه رکورد جداگانهٔ خود را با ساعات کاری مستقل دارد تا انتخاب دقیق‌تری داشته باشید.`,
`برای یافتن مرکز درمانی مناسب در ${city}، فهرست زیر گزینه‌های موجود را با اطلاعات یکسان کنار هم می‌گذارد. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک صفحهٔ کامل وصل است: موقعیت روی نقشه، ساعات کاری، تخصص‌ها و پزشکانی که در آن مرکز پذیرش دارند. پس از انتخاب مرکز و پزشک، نوبت‌های خالی را می‌بینید و رزرو در همان لحظه نهایی می‌شود. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و بازهٔ آزادشده بلافاصله در اختیار بیمار دیگری قرار می‌گیرد.`, `برای یافتن مرکز درمانی مناسب در ${city}، فهرست زیر گزینه‌های موجود را با اطلاعات یکسان کنار هم می‌گذارد. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک صفحهٔ کامل وصل است: موقعیت روی نقشه، ساعات کاری، تخصص‌ها و پزشکانی که در آن مرکز پذیرش دارند. پس از انتخاب مرکز و پزشک، نوبت‌های خالی را می‌بینید و رزرو در همان لحظه نهایی می‌شود. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و بازهٔ آزادشده بلافاصله در اختیار بیمار دیگری قرار می‌گیرد.`,
`درمانگاه‌ها، کلینیک‌های تخصصی و مراکز تصویربرداری ${city} در این فهرست کنار هم آمده‌اند. برای هر مرکز می‌بینید چه تخصص‌هایی ارائه می‌شود، کدام پزشکان آنجا پذیرش دارند و ساعات کاری هر روز چگونه است. انتخاب مرکز پیش از انتخاب پزشک وقتی مفید است که به خدمتی نیاز دارید که در یک مرکز مشخص متمرکز است. نوبت‌گیری از صفحهٔ همان مرکز انجام می‌شود و نیازی به تماس تلفنی نیست. برای مراکزی که چند تخصص را زیر یک سقف جمع کرده‌اند، می‌توانید در همان مراجعه چند خدمت را پیگیری کنید. فهرست پزشکان هر مرکز با تخصص و اولین نوبت آزادشان نمایش داده می‌شود تا انتخاب سریع‌تر باشد. هزینهٔ هر خدمت پیش از تأیید نهایی اعلام می‌شود و در صورت تغییر برنامه، لغو نوبت از حساب کاربری‌تان امکان‌پذیر است.`,
`مراکز درمانی ${city} را از این صفحه پیدا کنید و مستقیم نوبت بگیرید. هر مرکز نشانی، شمارهٔ تماس، موقعیت روی نقشه و فهرست پزشکان همکارش را در صفحهٔ اختصاصی خود نشان می‌دهد. اگر بیمهٔ خاصی دارید، بیمه‌های طرف قرارداد هر مرکز هم همان‌جا اعلام شده است. پس از انتخاب پزشک، نوبت‌های خالی همان هفته نمایش داده می‌شود و تأیید رزرو بلافاصله پیامک می‌گردد. مراکزی که به‌صورت شبانه‌روزی پذیرش دارند جداگانه مشخص شده‌اند تا در شرایط اضطراری سریع‌تر تصمیم بگیرید. اطلاعات تماس و نشانی هر مرکز به‌صورت دوره‌ای بازبینی می‌شود. اگر به‌جای مرکز درمانی دنبال یک پزشک مشخص هستید، فهرست پزشکان همان شهر مسیر مستقیم‌تری در اختیارتان می‌گذارد.`,
`این فهرست مراکز درمانی فعال ${city} را با ساختار یکسان کنار هم می‌گذارد تا مقایسه ساده باشد. برخی مراکز چند شعبه دارند و هر شعبه رکورد جداگانه با ساعات کاری مستقل خود را دارد؛ پیش از مراجعه، شعبهٔ درست را از روی نشانی و نقشه انتخاب کنید. رزرو، تغییر و لغو نوبت همگی آنلاین و از حساب کاربری‌تان انجام می‌شود و بازهٔ لغوشده بلافاصله در اختیار بیمار دیگری قرار می‌گیرد. صفحهٔ هر مرکز موقعیت روی نقشه را هم نشان می‌دهد تا پیش از مراجعه مسیر و زمان رسیدن را بدانید. تخصص‌های ارائه‌شده و پزشکان همکار هر مرکز در همان صفحه فهرست شده‌اند و از آنجا می‌توانید مستقیم نوبت بگیرید، بدون اینکه لازم باشد جداگانه دنبال پزشک بگردید.`,
]; ];
export function buildDoctorsIntro(cityName) { // مقدمهٔ صفحهٔ اصلی هر دامنهٔ شهری.
return pick(DOCTOR_INTROS(cityName), hash(cityName)); //
// صفحهٔ اصلی تا پیش از این فقط `site_name` و `slogan` را از city.json شهری می‌کرد و
// بقیهٔ صفحه — بنر، کادر جستجو، جستجوهای پرتکرار، فوتر — روی همهٔ دامنه‌ها یکسان بود.
// اندازه‌گیری: شباهت متن رندرشدهٔ بهبهان و یاسوج ۹۹٫۰٪. گوگل canonicalِ درستِ ما را رد
// می‌کرد و صفحهٔ اصلی شهر را با دامنهٔ دیگری تجمیع می‌کرد
// (Duplicate, Google chose different canonical than user).
//
// عدد پزشک و تخصص داخل متن می‌آید تا تمایز فقط لفظی نباشد؛ هر شهر آمار خودش را دارد.
const CITY_INTROS = (city, doctorCount, topSpecialty) => [
`${city} در حال حاضر ${doctorCount} پزشک فعال روی این سامانه دارد و بیشترین مراجعه به حوزهٔ ${topSpecialty} است. برای هر پزشک، تخصص، محل مطب، ساعات کاری و نوبت‌های خالی همین‌جا نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد. اگر نمی‌دانید به کدام تخصص مراجعه کنید، از فهرست تخصص‌های همین صفحه شروع کنید؛ هر رشته مستقیم به پزشکان همان حوزه در ${city} می‌رسد.`,
`دسترسی به پزشک در ${city} از همین صفحه شروع می‌شود: ${doctorCount} پزشک فعال، با پرمراجعه‌ترین حوزهٔ ${topSpecialty}. نام پزشک یا نام تخصص را در کادر بالا جست‌وجو کنید، یا از میان تخصص‌های پرمراجعهٔ همین شهر انتخاب کنید. نوبت‌گیری شبانه‌روزی است و تأییدیه بلافاصله پیامک می‌شود؛ برای گرفتن نوبت نیازی به تماس تلفنی با مطب نیست.`,
`این صفحه نقطهٔ ورود به پزشکان و مراکز درمانی ${city} است. ${doctorCount} پزشک در رشته‌های مختلف پذیرش دارند و ${topSpecialty} بیشترین سهم مراجعه را در این شهر دارد. هر پروفایل شامل سابقهٔ فعالیت، نشانی محل پذیرش و بازخورد بیماران پیشین است تا انتخاب با اطلاع کامل انجام شود. رزرو، تغییر و لغو نوبت همگی آنلاین و از حساب کاربری خودتان انجام می‌شود.`,
`نوبت پزشکان ${city} را می‌توانید بدون تماس تلفنی و در هر ساعت شبانه‌روز رزرو کنید. هم‌اکنون ${doctorCount} پزشک روی این سامانه در دسترس‌اند و پرمراجعه‌ترین حوزه در این شهر ${topSpecialty} است. برای هر پزشک، محل پذیرش و ساعت‌های خالیِ همان هفته نمایش داده می‌شود؛ کافی است بازهٔ دلخواهتان را انتخاب کنید تا تأییدیه بلافاصله پیامک شود. مقایسهٔ پزشکان بر اساس تخصص و امتیاز بیماران هم از همین صفحه ممکن است.`,
`اگر دنبال پزشک در ${city} هستید، از همین‌جا شروع کنید. ${doctorCount} پزشک فعال در این شهر ثبت شده‌اند و ${topSpecialty} بیشترین مراجعه را به خود اختصاص داده است. جست‌وجو را می‌توانید با نام پزشک یا با نام تخصص انجام دهید؛ نتایج شامل نشانی مطب، ساعات کاری و اولین نوبت آزاد هر پزشک است. برای مراکز درمانی چندتخصصی نیز فهرست جداگانه‌ای در دسترس است که پزشکان هر مرکز را کنار هم نشان می‌دهد.`,
`فهرست پزشکان ${city} با ${doctorCount} پروفایل فعال در این سامانه نگهداری می‌شود و ${topSpecialty} پرمراجعه‌ترین حوزهٔ درمانی این شهر است. اطلاعات هر پزشک — تخصص، محل پذیرش و ساعات کاری — به‌صورت دوره‌ای بازبینی می‌شود تا با وضعیت واقعی مطب هم‌خوان بماند. نوبت‌گیری در همان لحظه قطعی می‌شود و در صورت تغییر برنامه، لغو آن از حساب کاربری‌تان امکان‌پذیر است.`,
];
/**
* مقدمهٔ یکتای صفحهٔ اصلی یک شهر.
*
* seed از نام شهر می‌آید، نه تصادفی: متن باید بین شهرها واگرا شود ولی برای یک شهر
* در هر rebuild ثابت بماند، وگرنه محتوای صفحهٔ اصلی مدام عوض می‌شد.
*/
export function buildCityIntro(cityName, doctorCount = 0, topSpecialty = "") {
const count = Number(doctorCount) > 0 ? Number(doctorCount) : 0;
const specialty = topSpecialty || "پزشکی عمومی";
return pick(CITY_INTROS(cityName, count, specialty), hash(cityName) >>> 5);
} }
export function buildClinicsIntro(cityName) { /**
return pick(CLINIC_INTROS(cityName), hash(cityName) >>> 2); * جملهٔ آماری که به انتهای متن می‌چسبد.
*
* انتخاب variant با هش انجام می‌شود و با ۶ حالت و ۳۵ شهر، حدود ۱۵٪ جفت‌ها هم‌variant
* می‌شوند. برای آن جفت‌ها متن تقریباً یکسان می‌شد و دو صفحه دوباره شبیه هم — سنجیده
* شد: بهبهان و یاسوج روی /clinics از ۶۸٫۹٪ به ۸۴٫۷٪ شباهت رفتند.
*
* عدد واقعیِ هر شهر این را می‌شکند، چون هیچ دو شهری آمار یکسان ندارند. همان الگویی
* که در متن صفحهٔ اصلی جواب داد.
*/
const countSentence = (count, cityName, noun) =>
Number(count) > 0
? ` هم‌اکنون ${Number(count)} ${noun} در ${cityName} روی این سامانه ثبت شده‌اند.`
: "";
export function buildDoctorsIntro(cityName, doctorCount = 0) {
return (
pick(DOCTOR_INTROS(cityName), hash(cityName)) +
countSentence(doctorCount, cityName, "پزشک")
);
}
export function buildClinicsIntro(cityName, clinicCount = 0) {
return (
pick(CLINIC_INTROS(cityName), hash(cityName) >>> 2) +
countSentence(clinicCount, cityName, "مرکز درمانی")
);
} }