chore(prompt): record SEO audit and split remaining work
P1-P11 of the live SEO audit are implemented and verified against a local production build, so the audit file becomes a reference rather than a task list: it now records what shipped, the root causes that differed from the original hypotheses, and the deliberate trade-offs. Remaining work is split into smaller prompts, ordered by dependency: - seo-post-deploy-verification: the acceptance criteria were "curl on production" but were only run against a local build - blog-city-scoping-activate: blocked on the backend blog city column - sitemap-simplify-with-city: drops the 35-sweep workaround once the doctors list exposes city Each names its blocking dependency and carries reference numbers so a regression is visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
<div dir="rtl" markdown="1">
|
||||
|
||||
# فعالسازی بلاگ شهر-محور (بعد از افزودن شهر در backend)
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front`
|
||||
|
||||
**پیشنیاز قطعی:** `clinicpro/.claude/prompt/blog-city-field.md` اجرا و deploy شده باشد.
|
||||
تا وقتی `GET /api/v1/blogs` فیلد شهر برنگرداند، این پرامپت کاری برای انجام ندارد.
|
||||
|
||||
## زمینه
|
||||
|
||||
منطق شهر-محورِ بلاگ سمت فرانت **از قبل نوشته شده** ولی چون دادهاش وجود ندارد غیرفعال است. این پرامپت آن را فعال، تکمیل و تست میکند.
|
||||
|
||||
وضعیت داده هنگام نگارش: `GET /api/v1/blogs` صفر رکورد دارد (`totalRecords: 0`) و هیچ فیلد شهری ندارد.
|
||||
|
||||
## وضعیت فعلی (کدی که آمادهٔ فعالشدن است)
|
||||
|
||||
`app/blog/[slug]/page.js` — canonical و شهر از همان هلپر مشترک:
|
||||
|
||||
```js
|
||||
const blogCityId = extractEntityCityId(blog);
|
||||
const blogCity = findCityById(blogCityId);
|
||||
const origin = await getEntityOrigin(blogCityId);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `${origin}/blog/${slug}` },
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
JSON-LD مقاله فقط با وجود شهر `spatialCoverage` میگیرد:
|
||||
|
||||
```js
|
||||
...(blogCity && {
|
||||
spatialCoverage: { "@type": "Place", name: blogCity.name },
|
||||
}),
|
||||
```
|
||||
|
||||
`components/blog/head/index.js` — برچسب بصری، فقط با `cityName`:
|
||||
|
||||
```jsx
|
||||
{cityName && (
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
|
||||
مخصوص شهر:
|
||||
</p>
|
||||
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
|
||||
{cityName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
`lib/domainHelpers.js` — استخراج شهر، هر سه شکل پاسخ را میپذیرد:
|
||||
|
||||
```js
|
||||
export function extractEntityCityId(entity) {
|
||||
if (!entity) return null;
|
||||
const candidates = [
|
||||
entity.city_id,
|
||||
...(Array.isArray(entity.city) ? entity.city.map((c) => c?.id) : [entity.city?.id]),
|
||||
...(Array.isArray(entity.address) ? entity.address.map((a) => a?.city?.id) : []),
|
||||
].filter((id) => id != null);
|
||||
const withDomain = candidates.find((id) => findDomainByCityId(id));
|
||||
return withDomain ?? candidates[0] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
`app/sitemap.js` — مسیریابی پست به sitemap دامنهٔ canonical خودش:
|
||||
|
||||
```js
|
||||
async function getBlogUrls(baseUrl, scope, currentDomain) {
|
||||
const blogs = await fetchAllPages('/api/v1/blogs');
|
||||
return blogs
|
||||
.filter((b) => b?.slug || b?.uuid)
|
||||
.filter((b) => {
|
||||
const cityDomain = findDomainByCityId(extractEntityCityId(b));
|
||||
return cityDomain ? cityDomain === currentDomain : scope.isRoot;
|
||||
})
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. تأیید سازگاری شکل پاسخ
|
||||
|
||||
اول پاسخ واقعی را ببین:
|
||||
|
||||
```bash
|
||||
curl -s "https://clinic-pro.ir/api/v1/blogs?page=1&limit=5" | jq '.data.data[0]'
|
||||
```
|
||||
|
||||
بررسی کن `extractEntityCityId` روی شکل واقعی جواب میدهد. اگر backend شکل دیگری داد (مثلاً `city` رشته بهجای آبجکت)، `extractEntityCityId` را گسترش بده — **نه** یک استخراج جدا برای بلاگ بنویس.
|
||||
|
||||
`normalizeBlog` در `helper/index.js` نباید فیلد شهر را حذف کند؛ اگر فیلدها را صریح map میکند، `city` را اضافه کن.
|
||||
|
||||
### ۲. لیست بلاگ per-domain
|
||||
|
||||
`app/blogs/page.js` روی هر دامنهٔ شهری باید پستهای آن شهر + سراسری را بگیرد. با پارامتر `city_id` که backend اضافه میکند:
|
||||
|
||||
- روی دامنهٔ شهری: `city_id` شهر همان دامنه (از `getStateInfo`)
|
||||
- روی دامنهٔ ریشه (`isRoot`): بدون پارامتر — همهٔ پستها
|
||||
|
||||
canonical لیست بلاگ **self روی همان دامنه** بماند (C1-a). این از قبل درست است — کانونیکال hardcode شده به دامنهٔ اصلی قبلاً حذف شده:
|
||||
|
||||
```js
|
||||
// C1-a — لیست بلاگ per-domain است (پستهای همان شهر + سراسری)؛
|
||||
// canonical لایهٔ layout (self روی همان دامنه) درست است.
|
||||
```
|
||||
|
||||
### ۳. عنوان و متادیتای شهرمحور
|
||||
|
||||
`generateMetadata` صفحهٔ پست: اگر پست شهر دارد، نام شهر در `title`/`description` بیاید. اگر سراسری است، هیچ نام شهری اضافه نشود (نه نام شهرِ دامنهٔ سروکننده).
|
||||
|
||||
### ۴. تست
|
||||
|
||||
با دادهٔ واقعی (حداقل یک پست شهری + یک پست سراسری) تأیید کن:
|
||||
|
||||
```bash
|
||||
S=<slug-پست-یاسوجی>
|
||||
G=<slug-پست-سراسری>
|
||||
|
||||
# پست شهری: canonical به دامنهٔ شهر، روی هر دامنهای
|
||||
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||
curl -s "https://nobat724.com/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o "یاسوج" # نام شهر در HTML قابلمشاهده
|
||||
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'spatialCoverage'
|
||||
|
||||
# پست سراسری: self-canonical، بدون نام شهر
|
||||
curl -s "https://nobat724.com/blog/$G" | grep -o 'rel="canonical" href="[^"]*"' # → nobat724.com
|
||||
curl -s "https://yasuj-nobat.ir/blog/$G" | grep -c 'spatialCoverage' # → 0
|
||||
|
||||
# sitemap: هر پست فقط در دامنهٔ canonical خودش
|
||||
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "$S" # → 1
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "$S" # → 0
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "$G" # → 1
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "/blog/" # → بیشتر از ۰
|
||||
```
|
||||
|
||||
تست واحد برای `extractEntityCityId` روی شکل واقعی پاسخ بلاگ به `lib/domainHelpers.test.js` اضافه کن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **پست سراسری حالت دائمی است، نه استثنا.** پستی بدون شهر باید روی دامنهٔ اصلی self-canonical شود و در همهٔ لیستها دیده شود. هرگز شهرِ دامنهٔ سروکننده را به پست بدون شهر نسبت نده.
|
||||
- `findCityById` رکورد ریشه (`id: 600` = `nobat724.com`) را شهر حساب نمیکند و `null` برمیگرداند — یعنی پستی که بهاشتباه به رکورد ریشه وصل شده، سراسری تلقی میشود. رفتار درست است.
|
||||
- برچسب بصری شهر با تایپوگرافی «نویسنده/تاریخ» همخانواده است؛ عنصر بصری ناهماهنگ اضافه نکن.
|
||||
- الگوی Soft-404 (`app/blog/[slug]/page.js`) بعد از تغییرات همچنان باید کار کند:
|
||||
`curl -o /dev/null -w "%{http_code}" https://nobat724.com/blog/invalid-xxx` → `404`.
|
||||
توجه: `app/blog/[slug]/loading.js` عمداً حذف شده — دوباره اضافهاش نکن، وگرنه Soft-404 برمیگردد.
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,464 @@
|
||||
<div dir="rtl" markdown="1">
|
||||
|
||||
# تقسیم ممیزی SEO به پرامپتهای کوچک اجرایی — nobat724
|
||||
|
||||
> **وضعیت: P1 تا P11 پیاده و روی build محلی production تأیید شدهاند (هنوز deploy نشدهاند).**
|
||||
> این فایل حالا **مرجع ممیزی** است، نه فهرست کار. کارهای باقیمانده به پرامپتهای کوچکتر تقسیم شدهاند — جدول «کارهای باقیمانده» پایینتر.
|
||||
> بخشهای P1–P11 برای ثبت تصمیمها و یافتههای اصلی نگه داشته شدهاند.
|
||||
|
||||
## چکلیست کلی — انجامشده
|
||||
|
||||
| # | پرامپت | وضعیت | نتیجه |
|
||||
| --- | --------------------------------------- | ----- | ----- |
|
||||
| P1 | بررسی robots.txt | ✅ | مشکلی نبود؛ همهٔ مسیرها مجاز، sitemap معرفی شده. بدون تغییر کد |
|
||||
| P2 | هلپرهای مشترک | ✅ | `lib/domainHelpers.js` (+ `findCityById`، `extractEntityCityId`) |
|
||||
| P3 | رفع noindex سیستمیک لیستها | ✅ | علت واقعی: **mutate شدن شیء `searchParams` مشترک** بین بدنهٔ صفحه و `generateMetadata` — نه منطق شمارنده. `lib/listingRobots.js` |
|
||||
| P4 | استراتژی canonical سهدسته + pagination | ✅ | `lib/getCanonicalUrl.js` بازنویسی شد؛ `x-search` به `proxy.js` اضافه شد؛ تناقض canonical/JSON-LD رفع شد |
|
||||
| P5 | بازسازی sitemap | ✅ | معیار «claimed» → `lib/entityQuality.js`؛ **باگ کشفشده:** سقف خاموش ۵۰ رکوردی API، یاسوج ۴۸ → ۴۵۴ پزشک |
|
||||
| P6 | صفحات موجودیت: 404 و noindex | ✅ | علت واقعی Soft-404: **`loading.js`** (Suspense → استریم ۲۰۰). هر سه حذف شدند |
|
||||
| P7 | اصلاح H1ها | ✅ | همهٔ صفحات دقیقاً یک h1؛ کلینیک ۴ → ۱؛ Title ریشه اصلاح شد |
|
||||
| P8 | یکپارچهسازی Breadcrumb | ✅ | `Pageguide` سمانتیک شد + `BreadcrumbList` از همان منبع داده |
|
||||
| P9 | صفحات فرود تخصص | ✅ | `app/specialties/[slug]/` + مهاجرت ~۹۰ لینک کارت |
|
||||
| P10 | بلاگ شهر-محور | ⚠️ | **کد آماده ولی غیرفعال** — API بلاگ صفر رکورد دارد و فیلد شهر ندارد. نیازمند تغییر backend |
|
||||
| P11 | FAQ schema + متن مقدمه | ✅ | `FAQPage` جدا در صفحات پزشک و تخصص؛ متن مقدمهٔ یکتا با تست شمارش کلمه |
|
||||
|
||||
## کارهای باقیمانده — پرامپتهای کوچک
|
||||
|
||||
به ترتیب وابستگی. **backend اول.**
|
||||
|
||||
| # | پرامپت | چرا |
|
||||
|---|--------|-----|
|
||||
| ۱ | `clinicpro/.claude/prompt/doctors-list-city-and-limit.md` | افزودن `city` به پاسخ لیست پزشکان + شفافکردن سقف `limit` |
|
||||
| ۲ | `clinicpro/.claude/prompt/blog-city-field.md` | افزودن شهر به Entity بلاگ — پیشنیاز P10 |
|
||||
| ۳ | `clinicpro/.claude/prompt/cleanup-polluted-doctor-clinic-records.md` | پاکسازی رکوردهای `name = شمارهتلفن` و `"test"` + اعتبارسنجی |
|
||||
| ۴ | `nobat724_front/.claude/prompt/seo-post-deploy-verification.md` | اجرای معیارهای پذیرش روی دامنههای واقعی بعد از deploy |
|
||||
| ۵ | `nobat724_front/.claude/prompt/blog-city-scoping-activate.md` | فعالسازی P10 — وابسته به ۲ |
|
||||
| ۶ | `nobat724_front/.claude/prompt/sitemap-simplify-with-city.md` | حذف ۳۵ sweep پرهزینه — وابسته به ۱ |
|
||||
|
||||
پرامپت ۴ مستقل است و میتواند بلافاصله بعد از deploy اجرا شود.
|
||||
|
||||
## تصمیمهای آگاهانه (side-effect نیستند)
|
||||
|
||||
- **M3-B:** صفحات `?page=2+` خودشان canonical دارند و ایندکسپذیر میمانند (collapse به صفحهٔ ۱ انتخاب نشد).
|
||||
- **sitemap-index پیاده نشد:** ~۲٬۵۰۰ URL در هر دامنه، بسیار زیر سقف ۵۰k. جزئیات در پرامپت ۶.
|
||||
- **`loading.js` سه مسیر موجودیت حذف شد:** بهای رفع Soft-404. اسکلت لودینگ هنگام ناوبری کلاینتی از دست رفت.
|
||||
- **«خانه» به breadcrumb صفحهٔ `/clinics` اضافه شد:** تغییر محتوایی عمدی، همراستا با schema که از قبل روی `/clinic/[id]` بود.
|
||||
- **h1 صفحهٔ اصلی روی شعار نشست** (نه یک جملهٔ کیوردی جدید): شعار هر شهر در `city.json` از قبل شهرمحور و کیورددار است.
|
||||
|
||||
---
|
||||
|
||||
## بلوک زمینهٔ مشترک (ابتدای هر پرامپت کپی شود)
|
||||
|
||||
```
|
||||
## نقش
|
||||
تو یک متخصص ارشد Technical SEO + مهندس Next.js 15 App Router هستی. هر تغییر با کمترین ریسک و مطابق آخرین Best Practiceهای Google Search و Next.js. فقط قابلیتهای رسمی و غیرمنسوخ Next.js. زبان خروجی و رشتهها فارسی، RTL، تقویم جلالی.
|
||||
|
||||
## زمینه
|
||||
پروژه `nobat724_front`: یک deployment واحد Next.js 15 که ۳۵ دامنهٔ شهری مستقل (data/city.json — مثل tehran-nobat.ir, yazd-nobat.ir) + دامنهٔ اصلی nobat724.com را سرو میکند. تشخیص شهر از host header (lib/getStateInfo.js سرور، context/ProvinceProvider.js کلاینت). رکورد id:600 در city.json دامنهٔ ریشه است نه شهر (lib/rootCity.js). slug پزشک/کلینیک = uuid. صفحات SSR (streaming با Suspense).
|
||||
|
||||
## فایلهای کلیدی
|
||||
lib/getCanonicalUrl.js (تولید canonical — ریشهٔ باگ canonical) · app/sitemap.js (sitemap per-domain) · app/doctors/page.js (metadata لیست + listingRobots — باگ noindex سیستمیک) · app/clinics/page.js + components/clinics/ · app/doctor/[slug]/page.js (Physician schema + noindex برای unclaimed) · app/clinic/[slug]/page.js (canonical متناقض با JSON-LD، بدون noindex برای کلینیک خالی، چند h1) · app/specialties/page.js + components/specialties/ · app/blog/[slug]/page.js + app/blog/page.js · app/page.js + components/home/ · data/city.json + data/specialties.json + data/state.json · next.config.js (security headers — دستنخورده)
|
||||
|
||||
## محدودیتهای عمومی
|
||||
- security headers و CSP در next.config.js را نشکن.
|
||||
- هر تغییر canonical/robots را با curl روی حداقل یک دامنهٔ شهری + دامنهٔ اصلی تأیید کن.
|
||||
- خارج از دامنهٔ اپ اقدام نکن (تنظیمات CDN/ArvanCloud = DevOps، فقط در گزارش یادآوری کن).
|
||||
- پس از تغییر کد، `graphify update .` را اجرا کن.
|
||||
- در پایان: گزارش کوتاه تغییرات + خروجی curl معیارهای پذیرش.
|
||||
|
||||
## محدودیتهای UI (در همهٔ پرامپتها الزامی)
|
||||
- **UI سایت نباید بههم بریزد.** تغییرات SEO (تعویض تگ h1↔span/p/h2، canonical، robots، schema) نباید هیچ تغییر بصری ایجاد کنند — هنگام تعویض تگ، className و استایلهای عنصر قبلی را عیناً روی تگ جدید نگهدار.
|
||||
- **صفحهٔ جدید = کامپوننتهای موجود.** برای هر صفحهٔ جدید (صفحات تخصص، breadcrumb، بلاگ) از کامپوننتهای موجود پروژه استفاده کن (کارت پزشک، لیست، دکمهها، layout و header/footer فعلی) — کامپوننت جدید فقط وقتی بساز که هیچ معادل موجودی نباشد، و در آن صورت با همان design tokenها و کلاسهای Tailwind پروژه.
|
||||
- **ساختار صفحات جدید دقیقاً شبیه UI فعلی باشد.** صفحهٔ /specialties/[slug] باید از نظر چیدمان، فاصلهگذاری، تایپوگرافی و رنگ مثل /doctors فعلی به نظر برسد؛ کاربر نباید حس کند وارد بخش متفاوتی از سایت شده.
|
||||
- بعد از هر تغییر، صفحات تغییریافته را از نظر بصری با نسخهٔ قبل مقایسه کن (اسکرینشات یا بازبینی دستی) و در گزارش تأیید کن که رگرسیون بصری نداریم.
|
||||
|
||||
## نقاط قوت فعلی (تخریب نشوند)
|
||||
اسکیمای Physician/Review/Geo · هدرهای امنیتی HSTS/CSP · ریدایرکت www→non-www با 301 · صفحهٔ ۴۰۴ عمومی درست.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## مرجع: فهرست کامل یافتههای زندهٔ ممیزی (تأییدشده با HTML واقعی)
|
||||
|
||||
این بخش مرجع است؛ هر پرامپت یافتههای مرتبط خودش را جداگانه دارد، اما تصویر کامل اینجاست:
|
||||
|
||||
- `yazd-nobat.ir/` و `yazd-nobat.ir/doctors` → canonical به `nobat724.com` (باگ سراسری canonical).
|
||||
- `yasuj-nobat.ir/doctors` **بدون هیچ query** → `robots: noindex, follow` + canonical به دامنهٔ اصلی. علت: `city`/`state` خودکار از host تزریق میشوند و شمارندهٔ `listingRobots` همیشه ۲+ میشود ⇒ صفحهٔ اصلی لیست روی **همهٔ ۳۵ دامنه همیشه noindex** است.
|
||||
- `yasuj-nobat.ir/clinics` → همان دو مشکل (noindex + canonical غلط) ⇒ باگ فقط `/doctors` نیست.
|
||||
- `yasuj-nobat.ir/specialties` → canonical غلط اما **بدون** noindex ⇒ `listingRobots` بین doctors/clinics مشترک است، specialties مسیر جداست. دو `<h1>` بدون نام شهر. ~۹۰ لینک کارت تخصص به `/doctors?specialty=X&city=…&state=…`.
|
||||
- `yasuj-nobat.ir/clinic/9c163d69-…` → **تناقض زنده:** canonical به `nobat724.com` اما `MedicalClinic.url` و کل `BreadcrumbList` به `yasuj-nobat.ir`. کلینیک `is_active:false` با نام «09398631203» بدون noindex ایندکسپذیر (نامتقارن با پزشکان). چهار `<h1>` (نام + سه عنوان بخش). schema هست ولی breadcrumb بصری نیست (معکوس `/clinics`). Title = «09398631203 | یاسوج نوبت».
|
||||
- `yasuj-nobat.ir/contact-us` → canonical به دامنهٔ اصلی، **بدون** noindex — برای صفحات استاتیک مشترک این canonical احتمالاً درست است (استثنای C1-c).
|
||||
- `yasuj-nobat.ir/about-us` → canonical مشابه اما **با** noindex ⇒ robots صفحات استاتیک ناسازگار است.
|
||||
- دادهٔ embedded یاسوج: `totalRecords: 456, totalPages: 38` ⇒ با query-stripping فعلی، صفحات ۲ تا ۳۸ به صفحهٔ ۱ collapse میشوند.
|
||||
- HTML اولیه ۶ کارت اسکلت با `href="/doctor/undefined"` دارد ⇒ رندر streaming با Suspense است، نه SSR همزمان کامل.
|
||||
- رکوردهای دادهٔ آلوده: پزشک با `name:"09390039833"` و `name:"test"` با `owner_status:"claimed"` در نتایج عمومی.
|
||||
- `nobat724.com/doctors` → بدون هیچ `<h1>`. H1 صفحهٔ اصلی = «با نوبت 724». Title روت /doctors = «پزشکان نوبت 724 …» (نام رکورد root بهجای شهر).
|
||||
- `/doctor/<uuid-نامعتبر>` → کد 200 (Soft-404).
|
||||
- `nobat724.com/sitemap.xml` → فقط ۱۳ URL (۷ استاتیک + ۴ پزشک claimed + ۲ کلینیک + ۰ بلاگ).
|
||||
- هیچ روت تمیز specialty/city و هیچ `FAQPage` schema در کل سایت نیست.
|
||||
- بلاگ شهر-محور نیست با اینکه هر شهر نمایندهٔ محتوایی دارد.
|
||||
|
||||
---
|
||||
|
||||
## P1 — بررسی robots.txt (پیشنیاز اعتبارسنجی)
|
||||
|
||||
> پیشنیاز: هیچ. اجرا: قبل از همه — اگر مسیرها مسدود باشند بقیهٔ کارها بیاثرند.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## تسک
|
||||
فقط بررسی و گزارش — بدون تغییر کد مگر مشکل پیدا شود.
|
||||
|
||||
1. robots.txt دامنهٔ اصلی و حداقل دو دامنهٔ شهری را بگیر:
|
||||
curl -s https://nobat724.com/robots.txt
|
||||
curl -s https://yasuj-nobat.ir/robots.txt
|
||||
curl -s https://tehran-nobat.ir/robots.txt
|
||||
2. تأیید کن:
|
||||
- مسیرهای /doctors, /clinics, /specialties, /blog, /doctor/, /clinic/ مسدود (Disallow) نیستند.
|
||||
- sitemap.xml با خط Sitemap: معرفی شده است.
|
||||
- مسیرهای پنل/لاگین (در صورت وجود) Disallow هستند.
|
||||
3. اگر مسیری بهاشتباه مسدود است، فایل/روت تولیدکنندهٔ robots.txt را پیدا و اصلاح کن (احتمالاً app/robots.js یا فایل استاتیک public/robots.txt).
|
||||
|
||||
## معیار پذیرش
|
||||
خروجی کامل robots.txt هر سه دامنه در گزارش نقل شود + جدول «مسیر × وضعیت (مجاز/مسدود)». اگر اصلاحی انجام شد، curl بعد از deploy هم ضمیمه شود.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P2 — هلپرهای مشترک (زیرساخت)
|
||||
|
||||
> پیشنیاز: هیچ. دو تابع که چند پرامپت بعدی مصرف میکنند — دو پیادهسازی جدا ریسک واگرایی دارد، پس اول و یکبار ساخته میشوند.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## تسک
|
||||
دو هلپر مشترک بساز (محل پیشنهادی: lib/domainHelpers.js یا کنار getStateInfo):
|
||||
|
||||
### ۱) findDomainByCityId(cityId)
|
||||
- ورودی: city_id یک موجودیت (پزشک/کلینیک/پست بلاگ).
|
||||
- خروجی: دامنهٔ شهری متناظر از data/city.json (مثلاً "yasuj-nobat.ir")، یا null اگر شهر دامنهٔ اختصاصی ندارد.
|
||||
- رکورد id:600 (دامنهٔ ریشه) هرگز بهعنوان «شهر» برنگردد.
|
||||
- مصرفکنندگان آینده: canonical موجودیتها (P4)، sitemap (P5)، بلاگ (P10).
|
||||
|
||||
### ۲) resolveCityDisplayName(cityInfo)
|
||||
- ورودی: آبجکت شهر از getStateInfo.
|
||||
- خروجی: نام قابلنمایش شهر برای H1/Title. اگر isRoot (رکورد id:600) → «ایران»، نه نام رکورد root («نوبت 724»).
|
||||
- مصرفکنندگان آینده: H1 صفحات لیست (P7)، صفحات تخصص (P9).
|
||||
|
||||
## نکته
|
||||
یافتهٔ زنده: Title روت /doctors الان «پزشکان نوبت 724 …» رندر میشود چون نام رکورد root بهجای شهر مینشیند — resolveCityDisplayName دقیقاً برای رفع این است.
|
||||
|
||||
## معیار پذیرش
|
||||
- تست واحد یا اسکریپت کوچک: findDomainByCityId برای city_id یاسوج → "yasuj-nobat.ir"؛ برای city_id بدون دامنه → null؛ برای id:600 → null.
|
||||
- resolveCityDisplayName برای رکورد root → «ایران»؛ برای یاسوج → «یاسوج».
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P3 — رفع noindex سیستمیک صفحات لیست (C5 — بدترین باگ)
|
||||
|
||||
> پیشنیاز: P2 (برای تشخیص «شهر host»). مهمترین فیکس تکخطینما با بیشترین اثر.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتهٔ زندهٔ تأییدشده
|
||||
- yasuj-nobat.ir/doctors و yasuj-nobat.ir/clinics — هر دو بدون هیچ query string — robots: noindex, follow دارند.
|
||||
- علت: city/state روی سرور خودکار از host استنتاج و به شیء پارامترها تزریق میشوند؛ منطق listingRobots («۲+ پارامتر → noindex») همیشه این دو را میشمارد و همیشه ۲+ میشود.
|
||||
- نتیجه: صفحات اصلی لیست پزشکان و کلینیکها روی هر ۳۵ دامنه همیشه noindex هستند — مهمترین صفحات هر دامنه برای «پزشک + شهر».
|
||||
- /specialties این باگ را ندارد (مسیر جدا) — یعنی listingRobots بین /doctors و /clinics مشترک است؛ همهٔ مصرفکنندگانش را پوشش بده، نه فقط app/doctors/page.js.
|
||||
- یافتهٔ مرتبط: about-us هم noindex دارد ولی contact-us ندارد — robots در کل اپ ناسازگار است؛ هنگام فیکس، همهٔ نقاط اعمال robots را یکجا فهرست و بازبینی کن.
|
||||
|
||||
## تسک
|
||||
1. در listingRobots: پارامترهای city/state وقتی مقدارشان برابر شهر/استان استنتاجشده از host فعلی است، از شمارش «۲+ فیلتر» کاملاً حذف شوند — چه از query آمده باشند چه تزریق سمت سرور.
|
||||
2. فقط فیلترهای صریحاً کاربر-انتخابشده بشمارند: تخصص، جنسیت، مرتبسازی غیرپیشفرض، city/state متفاوت از host.
|
||||
3. قاعدهٔ اصلی (noindex برای جستجوی نام یا فیلتر واقعی چندگانه) حفظ شود.
|
||||
4. robots صفحهٔ about-us را هم به index برگردان (صفحهٔ استاتیک نباید noindex باشد؛ canonical آن در P4 تعیین میشود).
|
||||
|
||||
## معیار پذیرش
|
||||
curl -s https://yasuj-nobat.ir/doctors | grep -i robots # → index یا غایب
|
||||
curl -s https://yasuj-nobat.ir/clinics | grep -i robots # → index یا غایب
|
||||
curl -s https://tehran-nobat.ir/doctors | grep -i robots # → index یا غایب
|
||||
curl -s https://yazd-nobat.ir/doctors | grep -i robots # → index یا غایب
|
||||
curl -s https://yasuj-nobat.ir/about-us | grep -i robots # → index یا غایب
|
||||
curl -s "https://yasuj-nobat.ir/doctors?city=تهران&specialty=X" | grep -i robots # → noindex بماند
|
||||
تست حداقل روی ۳ دامنهٔ متفاوت و هر دو مسیر /doctors و /clinics — باگ سیستمیک است، یک URL کافی نیست.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P4 — استراتژی canonical سهدسته + تصمیم pagination (C1-a/b/c + M3-B)
|
||||
|
||||
> پیشنیاز: P2 (findDomainByCityId)، P3 (تا canonical و robots همجهت شوند). بزرگترین پرامپت — قلب فیکس.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتههای زندهٔ تأییدشده
|
||||
- getCanonicalUrl() در lib/getCanonicalUrl.js همهٔ صفحات روی همهٔ دامنهها را به nobat724.com canonical میکند (buildMainCanonicalUrl). تأییدشده روی: /doctors، /clinics، /specialties، /clinic/[id]، /contact-us، /about-us.
|
||||
- تناقض زنده در clinic/9c163d69: canonical به nobat724.com اما MedicalClinic.url و BreadcrumbList به yasuj-nobat.ir — سیگنال متناقض به گوگل.
|
||||
- pagination واقعی: یاسوج totalPages: 38 — با query-stripping فعلی همهٔ صفحات ۲+ به صفحهٔ ۱ collapse میشوند.
|
||||
|
||||
## قاعدهٔ سهدسته
|
||||
| دسته | صفحات | canonical |
|
||||
|------|-------|-----------|
|
||||
| C1-a: per-domain | / ، /doctors ، /clinics ، /specialties ، /specialties/[slug] ، /blog (لیست) | self-canonical روی همان دامنه |
|
||||
| C1-b: موجودیت | /doctor/[uuid] ، /clinic/[uuid] ، /blog/[slug] شهریافته | دامنهٔ شهرِ همان موجودیت (با findDomainByCityId از P2) |
|
||||
| C1-c: استاتیک مشترک | /contact-us ، /about-us ، /terms ، /privacy ، … | دامنهٔ اصلی nobat724.com (استثنای عمدی — محتوا روی همهٔ دامنهها یکسان است) |
|
||||
|
||||
## تسک
|
||||
1. getCanonicalUrl را بازنویسی کن تا searchParams هم بپذیرد (الان فقط x-pathname میخواند و query را کامل نادیده میگیرد):
|
||||
- STATIC_SHARED_PAGES (فهرست C1-c، با تیم نهایی شود) → buildMainCanonicalUrl.
|
||||
- /doctors با searchParams فقط specialty (تکفیلتر) → canonical به /specialties/[slug] (مقصد در P9 ساخته میشود؛ فعلاً تابع buildSpecialtyCanonicalUrl را آماده کن و پشت feature-flag یا شرط وجود روت بگذار).
|
||||
- پارامتر page>1 → تصمیم M3-B: گزینهٔ پیشنهادی page را در canonical نگهدار (?page=2 خود canonical) تا صفحات ۲+ ایندکسپذیر بمانند. اگر گزینهٔ collapse به صفحهٔ ۱ انتخاب شد، در گزارش صریحاً بهعنوان تصمیم آگاهانه مستند کن، نه side-effect.
|
||||
- سایر queryها (فیلتر چندگانه، sort، نام) → حذف از canonical (رفتار فعلی درست است).
|
||||
- سایر مسیرها → self-canonical با host فعلی.
|
||||
2. برای موجودیتها تابع getEntityCanonical(entity, pathname, currentHost) بساز:
|
||||
- cityDomain = findDomainByCityId(entity?.city_id)؛ اگر null → self-canonical (fallback؛ هرگز canonical شکسته نده).
|
||||
- حالتهای لبهای: پزشک چند-شهری → یک شهر اصلی (اولین/پرترافیکترین مطب) روی همهٔ دامنهها؛ شهر بدون دامنه → nobat724.com و self.
|
||||
- در app/doctor/[slug]/page.js و app/clinic/[slug]/page.js اعمال کن.
|
||||
3. JSON-LD را هماهنگ کن: فیلدهای url/@id در Physician و MedicalClinic و آیتمهای BreadcrumbList باید همان مقصد canonical را منعکس کنند (تناقض زندهٔ بالا را رفع و تست کن).
|
||||
4. buildMainCanonicalUrl را فقط اگر جای دیگری (JSON-LD/sitemap) مصرف نمیشود حذف کن؛ وگرنه دستنخورده.
|
||||
5. گزینهٔ 301 بهجای canonical برای موجودیتها را فقط در گزارش پیشنهاد بده (عیبها: redirect chain بکلینکها، پرش دامنه در UX پورتال). بدون تأیید صریح پیاده نکن.
|
||||
|
||||
## معیار پذیرش
|
||||
# C1-a
|
||||
curl -s https://yazd-nobat.ir/doctors | grep canonical # → yazd-nobat.ir/doctors
|
||||
curl -s https://nobat724.com/doctors | grep canonical # → nobat724.com/doctors
|
||||
curl -s https://yasuj-nobat.ir/specialties | grep canonical # → yasuj-nobat.ir/specialties
|
||||
# C1-b
|
||||
curl -s https://yasuj-nobat.ir/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → self
|
||||
curl -s https://nobat724.com/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → yasuj-nobat.ir
|
||||
curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep canonical # → self (شهر=یاسوج)
|
||||
# C1-c
|
||||
curl -s https://yasuj-nobat.ir/contact-us | grep canonical # → nobat724.com/contact-us
|
||||
curl -s https://yasuj-nobat.ir/about-us | grep canonical # → nobat724.com/about-us
|
||||
# M3-B (اگر گزینهٔ پیشنهادی)
|
||||
curl -s "https://yasuj-nobat.ir/doctors?page=2" | grep canonical # → …/doctors?page=2
|
||||
# هماهنگی JSON-LD
|
||||
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -o '"url":"[^"]*"' # → همان دامنهٔ canonical
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P5 — بازسازی sitemap (C2)
|
||||
|
||||
> پیشنیاز: P4 (sitemap باید فقط URLهای canonical را سابمیت کند).
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتهٔ زنده
|
||||
nobat724.com/sitemap.xml فقط ۱۳ URL دارد (۷ استاتیک + ۴ پزشک + ۲ کلینیک + ۰ بلاگ). علت: فیلتر owner_status === 'claimed' در getDoctorUrls (app/sitemap.js). یک مارکتپلیس عملاً ۴ صفحهٔ پزشک ایندکسپذیر دارد.
|
||||
|
||||
## تسک
|
||||
1. معیار ورود به sitemap: از «claimed» به «دارای حداقل دادهٔ معنادار» (uuid + حداقل یک تخصص یا آدرس/شهر).
|
||||
2. در app/doctor/[slug]/page.js شرط isUnclaimed → noindex را نرم کن: فقط پزشکان بدون هیچ محتوای معنادار noindex بمانند.
|
||||
3. هماهنگی با canonical (P4): getDoctorUrls از همان findDomainByCityId استفاده کند. sitemap هر دامنه فقط موجودیتهایی را شامل شود که canonical آنها همان دامنه است. sitemap دامنهٔ اصلی فقط موجودیتهای بدون دامنهٔ شهری.
|
||||
4. برای مقیاس بالای ~۵۰k URL، sitemap-index با فایلهای جدا (doctors/clinics/blogs/static) بساز.
|
||||
|
||||
## معیار پذیرش
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>" # بسیار بیشتر از ۱۳
|
||||
curl -s https://nobat724.com/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → خالی (canonical این پزشک یاسوج است)
|
||||
curl -s https://yasuj-nobat.ir/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → موجود
|
||||
پزشک معتبر unclaimed دیگر noindex نگیرد (curl روی یک نمونه).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P6 — صفحات موجودیت: Soft-404 و noindex کلینیک خالی (H4 + H7)
|
||||
|
||||
> پیشنیاز: P4 (تا سیاست robots/canonical موجودیتها معلوم باشد). دو فیکس کوچک همحوزه.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتههای زنده
|
||||
- /doctor/<uuid-نامعتبر> کد 200 میدهد (Soft-404) — API برای uuid ناموجود 200 با data:null برمیگرداند و کد به notFound() نمیرسد.
|
||||
- clinic/9c163d69 با is_active:false، نام «09398631203» (شمارهتلفن)، بدون caption/logo/services — کاملاً ایندکسپذیر است. منطق «thin → noindex» که برای پزشک unclaimed هست، برای کلینیک اصلاً وجود ندارد (نامتقارن).
|
||||
|
||||
## تسک
|
||||
### H4 — Soft-404
|
||||
1. در getDoctor (app/doctor/[slug]/page.js): null-check کامل روی json?.data?.data → notFound().
|
||||
2. همان الگو برای app/clinic/[slug]/page.js و app/blog/[slug]/page.js.
|
||||
(روت /specialties/[slug] هنوز ساخته نشده — در P9 همین الگو اعمال میشود.)
|
||||
|
||||
### H7 — noindex کلینیک خالی
|
||||
1. در app/clinic/[slug]/page.js: کلینیک با is_active:false یا بدون محتوای معنادار (نام واقعی/خدمات/توضیح) → noindex.
|
||||
2. تصمیم بگیر و مستند کن: is_active:false یعنی «حذفشده» (→ 404/410) یا «موقتاً غیرفعال» (→ noindex)؟
|
||||
3. هماهنگی با P5: موجودیت noindex نباید در sitemap باشد.
|
||||
4. در گزارش برای تیم عملیات: رکوردهای آلوده (نام = شمارهتلفن، name:"test" با owner_status:"claimed") باید در لایهٔ داده پاک شوند — خارج از کد.
|
||||
|
||||
## معیار پذیرش
|
||||
curl -o /dev/null -w "%{http_code}" https://nobat724.com/doctor/invalid-xxx # → 404
|
||||
curl -o /dev/null -w "%{http_code}" https://nobat724.com/clinic/invalid-xxx # → 404
|
||||
curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep -i robots # → noindex (یا 404/410 طبق تصمیم)
|
||||
curl -s https://yasuj-nobat.ir/sitemap.xml | grep "9c163d69" # → خالی
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P7 — اصلاح H1ها در همهٔ صفحات (H2 + H3)
|
||||
|
||||
> پیشنیاز: P2 (resolveCityDisplayName). مستقل از canonical — میتواند موازی P4-P6 اجرا شود.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتههای زنده (الگوی مشترک: Title/meta شهرمحور درستاند، H1 از منبع جدا و استاتیک میآید)
|
||||
| صفحه | H1 فعلی | مشکل |
|
||||
|------|---------|-------|
|
||||
| / (اصلی) | «با نوبت 724» | برندمحور، بدون کیورد |
|
||||
| /doctors | «کهگیلویه و بویراحمد / یاسوج» | متن دکمهٔ انتخاب موقعیت داخل <button> — h1 نیست |
|
||||
| /specialties | دو h1: «تخصص مورد نظر شما چیست؟» + «لیست تخصص ها» | دو h1، بدون نام شهر |
|
||||
| /clinics | «لیست کلینیک ها و درمانگاه های تخصصی» | استاتیک، بدون نام شهر |
|
||||
| /clinic/[id] | چهار h1: نام + «بیمه های طرف قرارداد» + «تخصص ها» + «خدمات» | عنوان بخشها با h1 |
|
||||
| Title روت /doctors | «پزشکان نوبت 724 …» | نام رکورد root بهجای شهر |
|
||||
|
||||
## تسک
|
||||
1. صفحهٔ اصلی: H1 → «نوبتدهی آنلاین پزشکان و کلینیکهای سراسر ایران» (برند جای دیگر).
|
||||
2. /doctors: عنصر دکمه از h1 به span/p؛ h1 واقعی بالای لیست: «پزشکان و متخصصان {resolveCityDisplayName} | رزرو نوبت آنلاین» (components/doctors/).
|
||||
3. /specialties: «تخصص مورد نظر شما چیست؟» غیرعنوانی شود؛ h1 یکتا: «لیست تخصصهای پزشکی در {resolveCityDisplayName}».
|
||||
4. /clinics: h1 پویا: «کلینیکها و مراکز درمانی {resolveCityDisplayName}».
|
||||
5. /clinic/[id]: فقط نام کلینیک h1؛ سه عنوان بخش → h2. همین را در /doctor/[id] هم بررسی و در صورت وجود اصلاح کن.
|
||||
6. همهٔ الگوهای «پزشکان {cityName}» از resolveCityDisplayName استفاده کنند (رفع Title روت).
|
||||
7. هر صفحه دقیقاً یک h1 (نه صفر، نه چند).
|
||||
8. **بدون تغییر بصری:** هر تعویض تگ (h1→span، h1→h2) className و استایل عنصر قبلی را عیناً حفظ کند؛ h1 جدیدی که اضافه میشود با تایپوگرافی موجود صفحه هماهنگ باشد. بعد از تغییر، ظاهر صفحات با قبل مقایسه و در گزارش تأیید شود.
|
||||
|
||||
## معیار پذیرش
|
||||
for p in doctors specialties clinics; do
|
||||
curl -s "https://yasuj-nobat.ir/$p" | grep -c "<h1" # → 1
|
||||
curl -s "https://yasuj-nobat.ir/$p" | grep -o "<h1[^>]*>[^<]*" # → شامل «یاسوج»
|
||||
done
|
||||
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -c "<h1" # → 1 (فعلاً 4)
|
||||
curl -s https://nobat724.com/ | grep -o "<h1[^>]*>[^<]*" # → کیورددار، نه «با نوبت 724»
|
||||
curl -s https://nobat724.com/doctors | grep -o "<title>[^<]*" # → بدون تکرار «نوبت 724» بهجای شهر
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P8 — یکپارچهسازی Breadcrumb (H6)
|
||||
|
||||
> پیشنیاز: P4 (URLهای schema باید با canonical همجهت باشند)، P7 (h2های اشتباه همانجا پاک میشوند).
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتههای زنده (دو نیمهٔ ناقص یک قابلیت)
|
||||
- /clinics: breadcrumb بصری با تگ <h2> داخل <ul> ساخته شده («کلینیک ها > یاسوج»)، بدون لینک <a>، بدون BreadcrumbList schema.
|
||||
- /clinic/[id]: دقیقاً برعکس — BreadcrumbList JSON-LD دارد (به yasuj-nobat.ir اشاره میکند) اما هیچ breadcrumb بصری ندارد.
|
||||
|
||||
## تسک
|
||||
1. یک کامپوننت Breadcrumb واحد بساز: <nav aria-label="breadcrumb"> + <ol> + <a> (آیتم فعلی <span>). هیچ h2ای. **ظاهرش دقیقاً همان breadcrumb فعلی /clinics باشد** (همان کلاسهای Tailwind، فاصلهها، جداکنندهٔ «>») — فقط تگها و ساختار سمانتیک عوض میشود، نه شکل بصری.
|
||||
2. BreadcrumbList JSON-LD همراه همان کامپوننت (script مستقل، با safeJsonLd موجود) — یک منبع داده برای بصری و schema تا هرگز واگرا نشوند.
|
||||
3. روی /clinics و /clinic/[id] اعمال کن؛ /doctors و /specialties و /doctor/[id] را هم بررسی و در صورت نیاز اضافه کن.
|
||||
4. URLهای آیتمها با مقصد canonical صفحه (P4) همجهت باشند.
|
||||
|
||||
## معیار پذیرش
|
||||
curl -s https://yasuj-nobat.ir/clinics | grep -o "<h2[^>]*>[^<]*" # → بدون «کلینیک ها»/«یاسوج»
|
||||
curl -s https://yasuj-nobat.ir/clinics | grep "BreadcrumbList" # → موجود
|
||||
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -c "aria-label=\"breadcrumb\"" # → 1 (بصری اضافه شد)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P9 — صفحات فرود تخصص و «تخصص + شهر» (C3 — بزرگترین فرصت رشد)
|
||||
|
||||
> پیشنیاز: P4 (canonical و buildSpecialtyCanonicalUrl)، P5 (sitemap)، P7 (resolveCityDisplayName و الگوی h1).
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتهٔ زنده
|
||||
- کیوردهای پرارزش («متخصص پوست تهران») هیچ صفحهٔ فرودی ندارند؛ فقط /doctors?specialty=… (کوئری فارسی).
|
||||
- صفحهٔ /specialties با ~۹۰ کارت، همه به /doctors?specialty=X&city=یاسوج&state=… لینک میدهند — همان الگویی که پیش از P3 باعث noindex میشد؛ این ۹۰ لینک باید مقصد تمیز بگیرند.
|
||||
|
||||
## تسک
|
||||
1. روت app/specialties/[slug]/page.js بساز — slug انگلیسی/تمیز از data/specialties.json. روی هر دامنهٔ شهری خودکار «تخصص + آن شهر» میشود (شهر از host). **این صفحه از کامپوننتهای موجود ساخته شود** — همان کارت پزشک، همان layout و header/footer، و همان کامپوننت لیستی که /doctors استفاده میکند؛ چیدمان و ظاهر نهایی باید از نظر کاربر دقیقاً همخانوادهٔ /doctors فعلی باشد. هیچ کامپوننت/استایل جدیدی نساز مگر معادل موجود نداشته باشد.
|
||||
2. هر صفحه شامل:
|
||||
- h1: «متخصص {specialty} در {resolveCityDisplayName}».
|
||||
- پاراگراف مقدمهٔ یکتا (تعداد پزشک، میانگین امتیاز شهر، توضیح خدمت).
|
||||
- لیست پزشکان SSR (/api/v1/doctors با فیلتر تخصص/شهر).
|
||||
- بلوک FAQ + FAQPage schema (script مستقل).
|
||||
- کامپوننت Breadcrumb از P8 + ItemList/MedicalWebPage.
|
||||
- generateMetadata کیوردمحور، canonical self-domain (طبق C1-a در P4).
|
||||
3. slug نامعتبر → notFound() (الگوی P6).
|
||||
4. این URLها به sitemap اضافه شوند (P5): ضرب specialties.json × دامنهها.
|
||||
5. لینکهای داخلی: کارتهای /specialties و فوتر → روت جدید. Breadcrumb صفحهٔ پزشک از /doctors?specialty= به URL تمیز.
|
||||
6. فعالسازی canonical کوئری قدیم (آمادهشده در P4): /doctors?specialty=X تکفیلتر → canonical به /specialties/[slug].
|
||||
|
||||
## معیار پذیرش
|
||||
curl -s https://tehran-nobat.ir/specialties/dermatology | grep -c "<h1>" # → 1 (شامل نام شهر)
|
||||
curl -s https://nobat724.com/sitemap.xml | grep "specialties/" # → موجود
|
||||
curl -s "https://tehran-nobat.ir/doctors?specialty=dermatology" | grep canonical # → …/specialties/dermatology
|
||||
curl -o /dev/null -w "%{http_code}" https://tehran-nobat.ir/specialties/invalid-slug # → 404
|
||||
curl -s https://yasuj-nobat.ir/specialties | grep -c "doctors?specialty=" # → 0 (لینکها مهاجرت کردهاند)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P10 — بلاگ شهر-محور (C4)
|
||||
|
||||
> پیشنیاز: P4 (الگوی canonical موجودیت)، P5 (sitemap). ممکن است نیاز به تغییر بکاند/مدل داده داشته باشد — اول بررسی، بعد کد.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## یافتهٔ زنده
|
||||
sitemap صفر URL بلاگ دارد. هر شهر نمایندهٔ اختصاصی دارد اما ساختار بلاگ ارتباط پست با شهر را در URL/عنوان/متادیتا منعکس نمیکند.
|
||||
|
||||
## پیش از کد، بررسی کن (اسکیمای دادهٔ بلاگ مستند نیست)
|
||||
- آیا پست بلاگ فیلد شهر (city_id یا مشابه) دارد؟ اگر نه، افزودنش قدم اول است (با امکان «سراسری» برای پستهای عمومی).
|
||||
- آیا نویسنده/نماینده به شهر مشخصی مرتبط است که بتوان از آن استنتاج کرد؟
|
||||
|
||||
## تسک
|
||||
1. نمایش صریح شهر در HTML قابلمشاهده (نه فقط متادیتا): عنوان/زیرعنوان مثل «{title} | مخصوص {cityName}» یا بلوک «این مطلب برای شهر {cityName} نوشته شده». این عنصر با استایل موجود صفحهٔ بلاگ هماهنگ باشد (مثلاً همان کامپوننت badge/برچسبی که در پروژه هست) — عنصر بصری ناهماهنگ با بقیهٔ صفحه اضافه نکن.
|
||||
2. canonical طبق الگوی موجودیت (P4): پست شهریافته → دامنهٔ همان شهر (با همان findDomainByCityId — تابع مشترک، نه پیادهسازی جدا). پست سراسری → self روی دامنهٔ اصلی.
|
||||
3. لیست بلاگ per-domain: app/blog/page.js روی هر دامنهٔ شهری = پستهای آن شهر + سراسری؛ خود لیست self-canonical (C1-a).
|
||||
4. sitemap (P5): هر پست فقط در sitemap دامنهٔ canonical خودش.
|
||||
5. Soft-404 (الگوی P6): بعد از افزودن فیلد شهر، null-check همچنان درست باشد.
|
||||
6. JSON-LD: BlogPosting/Article با areaServed یا spatialCoverage (script مستقل).
|
||||
|
||||
## معیار پذیرش
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "/blog/" # → بیشتر از ۰
|
||||
curl -s https://yasuj-nobat.ir/blog/<slug-پست-یاسوجی> | grep -i "یاسوج" # → نام شهر در HTML
|
||||
curl -s https://yasuj-nobat.ir/blog/<slug-پست-یاسوجی> | grep canonical # → self (دامنهٔ شهر)
|
||||
curl -s https://nobat724.com/blog/<slug-پست-یاسوجی> | grep canonical # → yasuj-nobat.ir (نه self)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P11 — FAQPage schema و متن مقدمهٔ یکتا (M1 + M2)
|
||||
|
||||
> پیشنیاز: P9 (صفحات تخصص باید وجود داشته باشند). آخرین لایه — بهبود، نه رفع باگ.
|
||||
|
||||
```
|
||||
[بلوک زمینهٔ مشترک]
|
||||
|
||||
## تسک
|
||||
### M1 — FAQPage schema
|
||||
1. بلوک «سوالات متداول» + FAQPage JSON-LD به صفحات پزشک، تخصص و شهر.
|
||||
2. FAQPage حتماً یک <script type="application/ld+json"> مستقل باشد (نه ادغام در @graph بدون تست) — schemaهای موجود Physician/Breadcrumb/Review/Geo نقطهٔ قوتاند و نباید بشکنند. از safeJsonLd موجود استفاده کن.
|
||||
|
||||
### M2 — متن مقدمهٔ یکتا
|
||||
بالای /doctors و /clinics و صفحات تخصص: ۱۵۰–۳۰۰ کلمه متن یکتای شهری/تخصصی (رفع Thin Content — مکمل canonical؛ محتوای یکسان با canonical درست هنوز duplicate است).
|
||||
نکته: متنها الگوی خام «{کار} کنید، {کار} کنید» نداشته باشند؛ برای هر شهر/تخصص ساختار جمله متفاوت باشد.
|
||||
|
||||
## معیار پذیرش
|
||||
curl -s https://yasuj-nobat.ir/specialties/dermatology | grep "FAQPage" # → موجود
|
||||
Rich Results Test گوگل روی یک صفحهٔ پزشک: schemaهای قبلی سالم + FAQPage معتبر.
|
||||
شمارش کلمات بلوک مقدمه در ۳ شهر مختلف: بین ۱۵۰ تا ۳۰۰ و متنها یکسان نباشند.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## یادداشتهای سراسری (در گزارش نهایی هر فاز تکرار شود)
|
||||
|
||||
- **کیفیت داده (خارج از کد):** رکوردهای پزشک/کلینیک با name = شمارهتلفن یا "test" با owner_status:"claimed" باید توسط تیم عملیات پاکسازی شوند.
|
||||
- **DevOps:** بررسی bot-challenge ArvanCloud روی دامنهٔ اصلی — کد نیست، فقط یادآوری.
|
||||
- **پس از هر deploy مؤثر بر canonical/robots:** در Search Console هر دامنهٔ درگیر، URL Inspection روی صفحات کلیدی + انتظار چند هفته برای re-index.
|
||||
- **SSR:** رندر لیستها streaming با Suspense است (۶ کارت اسکلت با href="/doctor/undefined" در HTML اولیه). فعلاً مشکلساز نیست اما در گزارش ذکر شود؛ اگر فرصت شد href اسکلتها خالی/بدون لینک شود.
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,148 @@
|
||||
<div dir="rtl" markdown="1">
|
||||
|
||||
# تأیید زندهٔ فیکسهای SEO بعد از deploy
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front`
|
||||
|
||||
## زمینه
|
||||
|
||||
فیکسهای ممیزی SEO (P1–P11 از `seo-critical-fixes-live-audit.md`) پیاده و تست شدهاند، اما تأیید روی **build محلی production** انجام شد، نه روی دامنههای واقعی:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_API_URL=https://clinic-pro.ir DEV_MODE=FALSE npm run build
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 npx next start -p 3111
|
||||
# سپس curl با هدر Host برای شبیهسازی دامنههای شهری
|
||||
```
|
||||
|
||||
معیارهای پذیرش اصلی «curl روی production» بودند. این پرامپت همانها را روی دامنههای واقعی اجرا میکند.
|
||||
|
||||
**دو نکتهٔ حیاتی قبل از تفسیر هر خروجی:**
|
||||
|
||||
۱. **`DEV_MODE` باید `FALSE` باشد.** با `DEV_MODE=TRUE` کل سایت `noindex, nofollow, nocache` میگیرد و همهٔ تستهای robots بیمعنی میشوند. این مقدار در `next.config.js` از طریق `env` در **زمان build** درون کد جاسازی میشود — تغییر آن در runtime بیاثر است و نیاز به build مجدد دارد.
|
||||
|
||||
۲. **تغییرات هنوز commit نشدهاند** (وضعیت کاری). قبل از deploy باید commit و push شوند.
|
||||
|
||||
## هدف
|
||||
|
||||
اجرای کامل معیارهای پذیرش روی production و ثبت خروجی واقعی؛ و در صورت اختلاف با نتایج محلی، ریشهیابی.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. پیش از deploy
|
||||
|
||||
- `git status` را بررسی کن؛ تغییرات SEO را commit کن.
|
||||
- تأیید کن build با `DEV_MODE=FALSE` انجام میشود.
|
||||
- تأیید کن `NEXT_PUBLIC_API_URL` در محیط production درست است (نه `clinic-pro.ddev.site` که مقدار فعلی `.env` محلی است).
|
||||
|
||||
### ۲. اجرای معیارهای پذیرش روی production
|
||||
|
||||
بعد از deploy، اینها را اجرا کن و **خروجی واقعی هر کدام را در گزارش نقل کن**:
|
||||
|
||||
```bash
|
||||
# P3 — noindex سیستمیک لیستها (باید خالی باشد)
|
||||
for h in yasuj-nobat.ir tehran-nobat.ir yazd-nobat.ir nobat724.com; do
|
||||
for p in doctors clinics; do
|
||||
printf "%s/%s: " "$h" "$p"
|
||||
curl -s "https://$h/$p" | grep -o '<meta name="robots"[^>]*>' | head -1; echo
|
||||
done
|
||||
done
|
||||
|
||||
# P3 — فیلتر واقعی باید noindex بماند
|
||||
curl -s "https://yasuj-nobat.ir/doctors?name=x" | grep -o 'content="noindex[^"]*"'
|
||||
curl -s "https://yasuj-nobat.ir/doctors?specialty=پوست%20و%20مو&gender=woman" | grep -o 'content="noindex[^"]*"'
|
||||
|
||||
# P4 — C1-a self-canonical
|
||||
for h in yazd-nobat.ir nobat724.com yasuj-nobat.ir; do
|
||||
for p in "" doctors clinics specialties blogs; do
|
||||
printf "%s/%s -> " "$h" "$p"
|
||||
curl -s "https://$h/$p" | grep -o 'rel="canonical" href="[^"]*"'; echo
|
||||
done
|
||||
done
|
||||
|
||||
# P4 — C1-c استاتیک مشترک → دامنهٔ اصلی
|
||||
curl -s https://yasuj-nobat.ir/about-us | grep -o 'rel="canonical" href="[^"]*"'
|
||||
curl -s https://yasuj-nobat.ir/contact-us | grep -o 'rel="canonical" href="[^"]*"'
|
||||
|
||||
# P4 — C1-b موجودیت → دامنهٔ شهر خودش
|
||||
D=beaca548-816f-4613-937d-360db01bd7c8 # شهر: یاسوج
|
||||
C=9c163d69-0051-4745-a423-c830135b1c01 # شهر: یاسوج
|
||||
curl -s "https://nobat724.com/doctor/$D" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||
curl -s "https://yazd-nobat.ir/doctor/$D" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||
curl -s "https://nobat724.com/clinic/$C" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||
|
||||
# P4 — هماهنگی JSON-LD با canonical (تناقض زندهٔ قبلی)
|
||||
curl -s "https://nobat724.com/clinic/$C" | grep -o '"url":"[^"]*"'
|
||||
|
||||
# M3-B — pagination
|
||||
curl -s "https://yasuj-nobat.ir/doctors?page=2" | grep -o 'rel="canonical" href="[^"]*"' # → ?page=2
|
||||
curl -s "https://yasuj-nobat.ir/doctors?page=1" | grep -o 'rel="canonical" href="[^"]*"' # → بدون page
|
||||
|
||||
# P6 — Soft-404 (هر چهار باید 404 بدهند)
|
||||
for p in doctor/invalid-xxx clinic/invalid-xxx blog/invalid-xxx specialties/invalid-slug; do
|
||||
printf "%s -> " "$p"; curl -o /dev/null -s -w "%{http_code}\n" "https://nobat724.com/$p"
|
||||
done
|
||||
# و موجودیت معتبر باید 200 بماند
|
||||
curl -o /dev/null -s -w "valid doctor: %{http_code}\n" "https://nobat724.com/doctor/$D"
|
||||
|
||||
# P7 — دقیقاً یک h1 و شهرمحور
|
||||
for p in doctors clinics specialties; do
|
||||
printf "%s h1count=" "$p"; curl -s "https://yasuj-nobat.ir/$p" | grep -c "<h1"
|
||||
curl -s "https://yasuj-nobat.ir/$p" | grep -o "<h1[^>]*>[^<]*"
|
||||
done
|
||||
curl -s "https://yasuj-nobat.ir/clinic/$C" | grep -c "<h1" # → 1 (قبلاً 4)
|
||||
curl -s "https://nobat724.com/doctors" | grep -o "<title>[^<]*" # → بدون «نوبت 724» بهجای شهر
|
||||
|
||||
# P8 — breadcrumb
|
||||
curl -s https://yasuj-nobat.ir/clinics | grep -c 'aria-label="breadcrumb"'
|
||||
curl -s https://yasuj-nobat.ir/clinics | grep -c 'BreadcrumbList'
|
||||
curl -s "https://yasuj-nobat.ir/clinic/$C" | grep -c 'aria-label="breadcrumb"'
|
||||
|
||||
# P9 — صفحات فرود تخصص
|
||||
curl -o /dev/null -s -w "%{http_code}\n" https://tehran-nobat.ir/specialties/dermatology # → 200
|
||||
curl -o /dev/null -s -w "%{http_code}\n" https://tehran-nobat.ir/specialties/invalid-slug # → 404
|
||||
curl -s "https://tehran-nobat.ir/doctors?specialty=پوست%20و%20مو" | grep -o 'rel="canonical" href="[^"]*"'
|
||||
curl -s https://yasuj-nobat.ir/specialties | grep -c "doctors?specialty=" # → 0
|
||||
|
||||
# P5 — sitemap
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>"
|
||||
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "<loc>"
|
||||
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "$D" # → 1
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "$D" # → 0
|
||||
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "9c163d69" # → 0 (کلینیک بیمحتوا)
|
||||
|
||||
# P11 — FAQPage بدون شکستن اسکیماهای موجود
|
||||
curl -s https://tehran-nobat.ir/specialties/dermatology | grep -c FAQPage
|
||||
curl -s "https://yasuj-nobat.ir/doctor/$D" | grep -c FAQPage
|
||||
curl -s "https://yasuj-nobat.ir/doctor/$D" | grep -c Physician # باید سالم بماند
|
||||
```
|
||||
|
||||
### ۳. اعتبارسنجی ساختاریافته
|
||||
|
||||
- **Rich Results Test گوگل** روی یک صفحهٔ پزشک و یک صفحهٔ تخصص: تأیید کن `Physician` / `MedicalClinic` / `Review` / `BreadcrumbList` قبلی سالماند و `FAQPage` معتبر است.
|
||||
- تأیید کن همهٔ بلوکهای JSON-LD پارس میشوند (در تست محلی: ۰ خطای پارس، انواع `Organization, WebSite, Physician, FAQPage, BreadcrumbList`).
|
||||
|
||||
### ۴. بازبینی بصری
|
||||
|
||||
صفحات تغییریافته را با نسخهٔ قبل مقایسه کن. تغییر تگها باید بدون اثر بصری باشد:
|
||||
|
||||
| صفحه | تغییر | انتظار |
|
||||
|------|-------|--------|
|
||||
| `/` | خط برند `h1` → `p`؛ شعار `h2` → `h1` | ظاهر یکسان |
|
||||
| `/doctors` | متن دکمهٔ موقعیت `h1` → `span` + h1 جدید و پاراگراف مقدمه | دکمه بدون تغییر؛ عنوان و متن جدید بالای لیست |
|
||||
| `/clinics` | عنوان پویا + پاراگراف مقدمه + breadcrumb با «خانه» | «خانه >» به breadcrumb اضافه شده |
|
||||
| `/clinic/[id]` | ۳ عنوان بخش `h1` → `h2`؛ breadcrumb بصری جدید | تایپوگرافی یکسان |
|
||||
|
||||
### ۵. Search Console
|
||||
|
||||
برای هر دامنهٔ درگیر: URL Inspection روی صفحات کلیدی (`/`، `/doctors`، `/clinics`، یک صفحهٔ پزشک، یک صفحهٔ تخصص) و ثبت sitemap. re-index چند هفته طول میکشد.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **رگرسیون احتمالی که باید مشخصاً چک شود:** حذف `loading.js` از سه مسیر `doctor/[slug]`، `clinic/[slug]`، `blog/[slug]`. این کار برای رفع Soft-404 لازم بود (Suspense باعث میشد پاسخ با ۲۰۰ استریم شود و `notFound()` دیگر نتواند status را عوض کند). هزینهاش نبودِ اسکلت لودینگ هنگام ناوبری کلاینتی است — تجربهٔ کاربری این سه مسیر را عملاً بررسی کن.
|
||||
- اگر `robots` روی همهٔ صفحات `noindex, nofollow, nocache` بود، تقریباً قطعاً `DEV_MODE=TRUE` در build بوده — کد را دنبال نکن، اول build را بررسی کن.
|
||||
- ArvanCloud ممکن است روی دامنهٔ اصلی bot-challenge داشته باشد؛ اگر curl خروجی غیرمنتظره داد، قبل از دیباگ کد این را رد کن. (خارج از دامنهٔ اپ — DevOps)
|
||||
- sitemap دامنهٔ اصلی ~۱۳ ثانیه طول میکشد (۳۵ sweep برای تفکیک شهر). اگر timeout خورد، این انتظار است نه باگ — رفعش در `sitemap-simplify-with-city.md`.
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,124 @@
|
||||
<div dir="rtl" markdown="1">
|
||||
|
||||
# سادهسازی sitemap با شهرِ موجود در پاسخ + آستانهٔ sitemap-index
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front`
|
||||
|
||||
**پیشنیاز قطعی:** `clinicpro/.claude/prompt/doctors-list-city-and-limit.md` اجرا و deploy شده باشد.
|
||||
تا وقتی `GET /api/v1/doctors` فیلد `city` برنگرداند، وظیفهٔ ۱ قابل انجام نیست (وظیفهٔ ۲ مستقل است).
|
||||
|
||||
## زمینه
|
||||
|
||||
sitemap هر دامنه فقط باید موجودیتهایی را داشته باشد که canonical آنها همان دامنه است. برای کلینیکها ساده است چون پاسخ لیست `city` دارد. برای پزشکان **پاسخ لیست شهر ندارد**، پس sitemap دامنهٔ اصلی مجبور است این کار پرهزینه را بکند: کل لیست را بگیرد، بعد **۳۵ بار** لیست را با `city_id` هر شهرِ دامنهدار بگیرد، و تفاضل بگیرد.
|
||||
|
||||
نتیجه: تولید sitemap دامنهٔ اصلی ~۱۳ ثانیه (revalidate ساعتی، پس هزینه مستهلک میشود، ولی کد پیچیده و شکننده است).
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`app/sitemap.js`:
|
||||
|
||||
```js
|
||||
// شهرهایی که دامنهٔ اختصاصی دارند — موجودیتهایشان canonical روی همان دامنه دارند و
|
||||
// نباید در sitemap دامنهٔ اصلی تکرار شوند.
|
||||
const CITY_IDS_WITH_DOMAIN = citiesData
|
||||
.filter((c) => !isRootCity(c) && c.domain)
|
||||
.map((c) => c.id);
|
||||
|
||||
/**
|
||||
* پاسخ لیست پزشکان شهر ندارد؛ پس تفکیک با فیلتر سمت API انجام میشود:
|
||||
* روی دامنهٔ شهری با city_id، و روی دامنهٔ اصلی «همه منهای پزشکانِ شهرهای دامنهدار».
|
||||
*/
|
||||
async function getDoctorsForScope(scope) {
|
||||
if (!scope.isRoot) return fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
||||
|
||||
const [all, ...perCity] = await Promise.all([
|
||||
fetchAllPages('/api/v1/doctors'),
|
||||
...CITY_IDS_WITH_DOMAIN.map((cityId) =>
|
||||
fetchAllPages('/api/v1/doctors', { city_id: String(cityId) })
|
||||
),
|
||||
]);
|
||||
const ownedByCityDomain = new Set(perCity.flat().map((d) => d?.uuid).filter(Boolean));
|
||||
return all.filter((d) => !ownedByCityDomain.has(d?.uuid));
|
||||
}
|
||||
```
|
||||
|
||||
کلینیکها از قبل الگوی ساده و درست را دارند (چون `city` در پاسخ هست):
|
||||
|
||||
```js
|
||||
.filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. حذف ۳۵ sweep و یکسانسازی با الگوی کلینیک
|
||||
|
||||
وقتی `city` در پاسخ لیست پزشکان آمد:
|
||||
|
||||
- `getDoctorsForScope` و `CITY_IDS_WITH_DOMAIN` حذف شوند.
|
||||
- روی دامنهٔ شهری: همان `fetchAllPages('/api/v1/doctors', cityFilterParams(scope))` بماند.
|
||||
- روی دامنهٔ ریشه: یک fetch کامل + همان فیلتر کلینیکها:
|
||||
|
||||
```js
|
||||
async function getDoctorUrls(baseUrl, scope) {
|
||||
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
||||
return doctors
|
||||
.filter((d) => !isThinDoctor(d))
|
||||
.filter((d) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(d)))
|
||||
.map((d) => withLastModified({ /* ... */ }, d.updated || d.created));
|
||||
}
|
||||
```
|
||||
|
||||
`extractEntityCityId` بدون تغییر کار میکند (شکل `city: {id}` را میپذیرد).
|
||||
|
||||
**تأیید کن پوشش تغییر نکرده باشد** — قبل و بعد از تغییر تعداد URL هر sitemap را مقایسه کن:
|
||||
|
||||
```bash
|
||||
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "<loc>"
|
||||
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>"
|
||||
```
|
||||
|
||||
مقدار مرجع در زمان نگارش: یاسوج **۵۵۴** URL (۴۵۴ پزشک)، ریشه **۱۰۰** URL (۰ پزشک — همهٔ پزشکان شهر دامنهدار دارند).
|
||||
|
||||
### ۲. آستانهٔ sitemap-index (مستقل از backend)
|
||||
|
||||
الان همهٔ URLها در یک `sitemap.xml` هستند. سقف استاندارد **۵۰٬۰۰۰ URL** در هر فایل است.
|
||||
|
||||
مقیاس فعلی: ۲۳۴۱ پزشک، ۲ کلینیک، ۹۳ صفحهٔ تخصص، ۰ بلاگ ⇒ حداکثر ~۲٬۵۰۰ URL در هر دامنه. **بسیار زیر سقف، پس sitemap-index الان لازم نیست** و عمداً پیاده نشده.
|
||||
|
||||
کاری که این وظیفه میخواهد:
|
||||
|
||||
- یک هشدار صریح اضافه کن که وقتی تعداد URL از یک آستانه (مثلاً ۴۵٬۰۰۰) گذشت، در لاگ دیده شود:
|
||||
|
||||
```js
|
||||
if (allUrls.length > 45000) {
|
||||
console.warn(`[sitemap] ${domain}: ${allUrls.length} URLs — نزدیک سقف ۵۰k، sitemap-index لازم است`);
|
||||
}
|
||||
```
|
||||
|
||||
- تصمیم «فعلاً sitemap-index نداریم و چرا» را بهصورت کامنت کنار همین شرط مستند کن، تا دفعهٔ بعد کسی آن را بهعنوان قلمافتادگی برندارد.
|
||||
- sitemap-index را **پیاده نکن** مگر عدد واقعی به آستانه نزدیک شده باشد؛ با `generateSitemaps` ساختار URL عوض میشود (`/sitemap/[id].xml`) و ارجاع `robots.txt` باید همراهش تغییر کند.
|
||||
|
||||
### ۳. بازبینی سقف `limit` بعد از تغییر backend
|
||||
|
||||
حلقهٔ صفحهبندی الان روی `meta.totalPages` توقف میکند (نه روی `items.length < limit`) — این عمدی و درست است:
|
||||
|
||||
```js
|
||||
const PAGE_LIMIT = 50;
|
||||
const MAX_PAGES = 200;
|
||||
// ...
|
||||
if (totalPages && page >= totalPages) break;
|
||||
if (totalRecords && results.length >= totalRecords) break;
|
||||
```
|
||||
|
||||
اگر backend سقف `limit` را بالا برد، `PAGE_LIMIT` را متناسب بالا ببر و `MAX_PAGES` را بازبینی کن. **شرط توقف را به `items.length < PAGE_LIMIT` برنگردان** — دقیقاً همین باگ باعث شده بود sitemap روی ۵۰ رکورد بریده بماند.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **این تغییر نباید هیچ URLی را از sitemap حذف یا اضافه کند** — فقط راه رسیدن به همان نتیجه سادهتر میشود. اختلاف در تعداد URL یعنی رگرسیون؛ ریشهیابی کن.
|
||||
- `isThinDoctor` در `lib/entityQuality.js` روی شکل پاسخِ **لیست** کار میکند (`specialties` دارد، `address` ندارد). اگر backend شکل پاسخ لیست را عوض کرد، معیار `hasLocation` را بازبینی کن — با آمدن `city` در پاسخ، حالا `hasLocation` میتواند از `city` هم سیگنال بگیرد و پزشکان بیشتری وارد sitemap شوند.
|
||||
- منطق «چه چیزی noindex است» و «چه چیزی در sitemap است» باید یکی بماند؛ هر دو از `lib/entityQuality.js` میآیند. اگر یکی را عوض کردی، دیگری خودکار همراه میشود — عمداً همینطور طراحی شده.
|
||||
- بعد از تغییر: `graphify update .`
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user