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>
7.1 KiB
فعالسازی بلاگ شهر-محور (بعد از افزودن شهر در 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 و شهر از همان هلپر مشترک:
const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId);
const origin = await getEntityOrigin(blogCityId);
return {
title,
description,
alternates: { canonical: `${origin}/blog/${slug}` },
// ...
};
JSON-LD مقاله فقط با وجود شهر spatialCoverage میگیرد:
...(blogCity && {
spatialCoverage: { "@type": "Place", name: blogCity.name },
}),
components/blog/head/index.js — برچسب بصری، فقط با cityName:
{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 — استخراج شهر، هر سه شکل پاسخ را میپذیرد:
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 خودش:
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;
})
// ...
}
وظایف
۱. تأیید سازگاری شکل پاسخ
اول پاسخ واقعی را ببین:
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 شده به دامنهٔ اصلی قبلاً حذف شده:
// C1-a — لیست بلاگ per-domain است (پستهای همان شهر + سراسری)؛
// canonical لایهٔ layout (self روی همان دامنه) درست است.
۳. عنوان و متادیتای شهرمحور
generateMetadata صفحهٔ پست: اگر پست شهر دارد، نام شهر در title/description بیاید. اگر سراسری است، هیچ نام شهری اضافه نشود (نه نام شهرِ دامنهٔ سروکننده).
۴. تست
با دادهٔ واقعی (حداقل یک پست شهری + یک پست سراسری) تأیید کن:
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 برمیگردد.