Files
nobat724_front/.claude/prompt/sitemap-simplify-with-city.md
T
hamedandClaude Opus 4.8 21f5f07bc6 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>
2026-07-19 08:09:21 +03:30

125 lines
7.0 KiB
Markdown

<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>