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

7.0 KiB

ساده‌سازی 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:

// شهرهایی که دامنهٔ اختصاصی دارند — موجودیت‌هایشان 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 در پاسخ هست):

.filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))

وظایف

۱. حذف ۳۵ sweep و یکسان‌سازی با الگوی کلینیک

وقتی city در پاسخ لیست پزشکان آمد:

  • getDoctorsForScope و CITY_IDS_WITH_DOMAIN حذف شوند.
  • روی دامنهٔ شهری: همان fetchAllPages('/api/v1/doctors', cityFilterParams(scope)) بماند.
  • روی دامنهٔ ریشه: یک fetch کامل + همان فیلتر کلینیک‌ها:
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 را مقایسه کن:

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 از یک آستانه (مثلاً ۴۵٬۰۰۰) گذشت، در لاگ دیده شود:
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) — این عمدی و درست است:

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 .