import { headers } from 'next/headers'; import { getBaseUrl } from '../utils/sitemap'; import citiesData from '@/data/city.json'; import statesData from '@/data/state.json'; import { isRootCity } from '@/lib/rootCity'; import { extractEntityCityId, findDomainByCityId } from '@/lib/domainHelpers'; import { isThinClinic, isThinDoctor } from '@/lib/entityQuality'; import specialtiesData from '@/data/specialties.json'; // sitemap باید در زمان اجرا (per-request) روی سرور production تولید شود، نه در build. // در build کانتینر به API دسترسی شبکه ندارد → fetch failed. force-dynamic این را قطعی می‌کند. export const dynamic = 'force-dynamic'; export const revalidate = 3600; const MAIN_DOMAIN = 'nobat724.com'; const API_URL = process.env.NEXT_PUBLIC_API_URL; // backend صفحه را به ۵۰ رکورد سقف می‌زند و limit بزرگ‌تر را نادیده می‌گیرد. // شرط توقف باید meta.totalPages باشد، نه «کمتر از limit برگشت» — وگرنه حلقه در // همان صفحهٔ اول می‌شکند و sitemap روی ۵۰ رکورد بریده می‌شود. const PAGE_LIMIT = 50; const MAX_PAGES = 200; // سقف استاندارد هر فایل sitemap ۵۰٬۰۰۰ URL است؛ کمی زودتر هشدار می‌دهیم. const SITEMAP_INDEX_THRESHOLD = 45000; function toSafeDate(value) { if (!value) return null; const d = new Date(typeof value === 'number' ? value * 1000 : value); return isNaN(d.getTime()) ? null : d; } function withLastModified(entry, dateValue) { const lastModified = toSafeDate(dateValue); return lastModified ? { ...entry, lastModified } : entry; } async function getCurrentDomain() { try { const headersList = await headers(); return headersList.get('host') || MAIN_DOMAIN; } catch { return MAIN_DOMAIN; } } // همان منطق lib/getStateInfo.js — اینجا host را از آرگومان می‌گیریم نه دوباره از headers function getCityScope(host) { const subdomain = (host || '').toLowerCase().replace(/^www\./, '').split('.')[0]; const matchedCity = citiesData.find( (city) => city.domain.split('.')[0] === subdomain ); const matchedState = matchedCity && statesData.find((state) => state.id === matchedCity.province_id); const isRoot = !matchedCity || isRootCity(matchedCity); return { matchedCity, matchedState, isRoot }; } function getStaticPages(baseUrl) { return [ { url: baseUrl, changeFrequency: 'daily', priority: 1 }, { url: `${baseUrl}/about-us`, changeFrequency: 'monthly', priority: 0.8 }, { url: `${baseUrl}/contact-us`, changeFrequency: 'monthly', priority: 0.8 }, { url: `${baseUrl}/blogs`, changeFrequency: 'weekly', priority: 0.9 }, { url: `${baseUrl}/doctors`, changeFrequency: 'daily', priority: 0.9 }, { url: `${baseUrl}/clinics`, changeFrequency: 'daily', priority: 0.9 }, { url: `${baseUrl}/specialties`, changeFrequency: 'weekly', priority: 0.9 }, ]; } async function fetchAllPages(path, extraParams = {}) { if (!API_URL) return []; const results = []; try { for (let page = 1; page <= MAX_PAGES; page++) { const search = new URLSearchParams({ ...extraParams, page: String(page), limit: String(PAGE_LIMIT), }); const res = await fetch(`${API_URL}${path}?${search.toString()}`, { next: { revalidate: 3600 }, signal: AbortSignal.timeout(8000), }); if (!res.ok) break; const json = await res.json(); const raw = json?.data ?? json; const items = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : []; if (items.length === 0) break; results.push(...items); const meta = json?.meta ?? raw?.meta; const totalPages = Number(meta?.totalPages) || 0; const totalRecords = Number(meta?.totalRecords) || 0; if (totalPages && page >= totalPages) break; if (totalRecords && results.length >= totalRecords) break; // بدون meta تنها نشانهٔ پایان، صفحهٔ خالی است (در تکرار بعدی). } } catch (error) { console.error(`[sitemap] failed to fetch ${path} (page skipped):`, error?.message || error); } return results; } function cityFilterParams(scope) { if (scope.isRoot) return {}; const params = {}; if (scope.matchedState?.id) params.state_id = String(scope.matchedState.id); if (scope.matchedCity?.id) params.city_id = String(scope.matchedCity.id); return params; } // پاسخ لیست پزشکان از GET /api/v1/doctors حالا خودش city دارد، پس تفکیک دامنه با همان // فیلتری انجام می‌شود که کلینیک‌ها استفاده می‌کنند. پیش از این، نبودِ city مجبورمان // می‌کرد روی دامنهٔ اصلی کل لیست را ۳۵ بار (یک‌بار به‌ازای هر شهرِ دامنه‌دار) بگیریم و // تفاضل بگیریم — همان کاری که ~۱۳ ثانیه طول می‌کشید. async function getDoctorUrls(baseUrl, scope) { const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope)); return doctors .filter((d) => !isThinDoctor(d)) // روی دامنهٔ اصلی فقط پزشکانی که شهرشان دامنهٔ اختصاصی ندارد؛ روی دامنهٔ شهری // فیلتر city_id سمت API قبلاً کار را کرده است. .filter((d) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(d))) .map((d) => withLastModified( { url: `${baseUrl}/doctor/${d.uuid}`, changeFrequency: 'weekly', priority: 0.8, }, d.updated || d.created ) ); } async function getClinicUrls(baseUrl, scope) { const clinics = await fetchAllPages('/api/v1/clinics', cityFilterParams(scope)); return clinics .filter((c) => !isThinClinic(c)) // پاسخ لیست کلینیک‌ها شهر دارد — کلینیکِ شهرِ دامنه‌دار فقط در sitemap همان دامنه. .filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c))) .map((c) => withLastModified( { url: `${baseUrl}/clinic/${c.uuid}`, changeFrequency: 'weekly', priority: 0.7, }, c.updated || c.created ) ); } // صفحات فرود تخصص per-domain — روی هر دامنه «تخصص + آن شهر» است، پس همه‌جا self. function getSpecialtyUrls(baseUrl) { return specialtiesData .filter((s) => s?.slug && s.status === 1) .map((s) => ({ url: `${baseUrl}/specialties/${s.slug}`, changeFrequency: 'weekly', priority: 0.8, })); } // هر پست فقط در sitemap دامنهٔ canonical خودش: پست شهریافته → دامنهٔ آن شهر، // پست سراسری (یا شهرِ بدون دامنهٔ اختصاصی) → دامنهٔ اصلی. // // تطبیق با شناسهٔ شهرِ scope انجام می‌شود نه با رشتهٔ دامنه: scope خودش از subdomain // استخراج شده، پس با پورت و TLD متفاوت (محیط محلی) هم درست کار می‌کند. async function getBlogUrls(baseUrl, scope) { const blogs = await fetchAllPages('/api/v1/blogs'); return blogs .filter((b) => b?.slug || b?.uuid) .filter((b) => { const cityId = extractEntityCityId(b); if (!findDomainByCityId(cityId)) return scope.isRoot; return !scope.isRoot && Number(scope.matchedCity?.id) === Number(cityId); }) .map((b) => withLastModified( { url: `${baseUrl}/blog/${b.slug || b.uuid}`, changeFrequency: 'monthly', priority: 0.6, }, b.updated || b.created ) ); } export default async function sitemap() { try { const domain = await getCurrentDomain(); const baseUrl = getBaseUrl(domain); const scope = getCityScope(domain); const [doctorUrls, clinicUrls, blogUrls] = await Promise.all([ getDoctorUrls(baseUrl, scope), getClinicUrls(baseUrl, scope), getBlogUrls(baseUrl, scope), ]); const allUrls = [ ...getStaticPages(baseUrl), ...getSpecialtyUrls(baseUrl), ...doctorUrls, ...clinicUrls, ...blogUrls, ]; const seen = new Set(); const uniqueUrls = allUrls.filter((item) => { if (seen.has(item.url)) return false; seen.add(item.url); return true; }); // sitemap-index عمداً پیاده نشده: سقف استاندارد هر فایل ۵۰٬۰۰۰ URL است و // بزرگ‌ترین دامنه اکنون حدود ۲٬۵۰۰ URL دارد — یعنی هنوز یک مرتبهٔ بزرگی فاصله. // پیاده‌سازی‌اش با generateSitemaps ساختار URL را به /sitemap/[id].xml می‌برد و // ارجاع robots.txt هم باید همراهش عوض شود؛ پس تا نزدیک‌شدن به آستانه نمی‌ارزد. // این هشدار هست تا وقتی واقعاً لازم شد، در لاگ دیده شود نه در Search Console. if (uniqueUrls.length > SITEMAP_INDEX_THRESHOLD) { console.warn( `[sitemap] ${domain}: ${uniqueUrls.length} URLs — نزدیک سقف ۵۰k، وقت پیاده‌سازی sitemap-index است` ); } return uniqueUrls; } catch (error) { console.error('Error generating sitemap:', error); return []; } }