feat(metadata): enhance SEO fields for blog and blogs pages

This commit is contained in:
hamed
2026-07-23 22:16:32 +03:30
parent 42b7fa5a98
commit 7bbfe9a698
2 changed files with 69 additions and 7 deletions
+61 -7
View File
@@ -37,25 +37,43 @@ export async function generateMetadata({ params }) {
const blogCity = findCityById(extractEntityCityId(blog)); const blogCity = findCityById(extractEntityCityId(blog));
const brand = blogCity?.site_name || siteName; const brand = blogCity?.site_name || siteName;
const title = `${blog.title} | ${brand}`; // meta_title از فیلد اختصاصی SEO (اگر پایپ‌لاین/ادمین ست کرده باشد) وگرنه عنوان.
const title = `${blog.meta_title || blog.title} | ${brand}`;
const rawText = (blog.summary || blog.body || "") const rawText = (blog.summary || blog.body || "")
.replace(/<[^>]*>/g, "") .replace(/<[^>]*>/g, "")
.trim(); .trim();
const baseDescription = rawText.slice(0, 160) || blog.title; const baseDescription = rawText.slice(0, 160) || blog.title;
// نام شهر فقط برای پست شهریافته؛ پست سراسری هیچ شهری نمی‌گیرد. // فیلد meta_description اختصاصی مقدم است؛ وگرنه از خلاصه/متن استخراج می‌شود.
const description = blogCity const description =
? `${baseDescription}`.slice(0, 150) + ` | ${blogCity.name}` blog.meta_description ||
: baseDescription; (blogCity
const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG; ? `${baseDescription}`.slice(0, 150) + ` | ${blogCity.name}`
: baseDescription);
// کلیدواژه‌ها از فیلدهای SEO (اصلی + فرعی) + تگ‌ها.
const keywords = [
blog.primary_keyword,
...(Array.isArray(blog.secondary_keywords) ? blog.secondary_keywords : []),
...(Array.isArray(blog.tags)
? blog.tags.map((t) => (typeof t === "string" ? t : t?.name))
: []),
].filter(Boolean);
const image = blog.og_image
? imageUrl(blog.og_image)
: blog.image_url
? imageUrl(blog.image_url)
: FALLBACK_IMG;
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛ // C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛
// پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical می‌ماند. // پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical می‌ماند.
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپ‌لاین) مقدم است.
const origin = await getEntityOrigin(extractEntityCityId(blog)); const origin = await getEntityOrigin(extractEntityCityId(blog));
const canonical = blog.canonical_url || `${origin}/blog/${slug}`;
return { return {
title, title,
description, description,
alternates: { canonical: `${origin}/blog/${slug}` }, ...(keywords.length && { keywords }),
alternates: { canonical },
openGraph: { openGraph: {
title, title,
description, description,
@@ -63,6 +81,9 @@ export async function generateMetadata({ params }) {
...(blog.created_at && { ...(blog.created_at && {
publishedTime: new Date(blog.created_at * 1000).toISOString(), publishedTime: new Date(blog.created_at * 1000).toISOString(),
}), }),
...(blog.updated_at && {
modifiedTime: new Date(blog.updated_at * 1000).toISOString(),
}),
images: [image], images: [image],
}, },
twitter: { twitter: {
@@ -97,16 +118,27 @@ async function Blog({ params }) {
.slice(0, 4) .slice(0, 4)
.map(normalizeBlog); .map(normalizeBlog);
const brand = blogCity?.site_name || "نوبت 724";
const articleDescription =
blog?.meta_description ||
(blog?.body?.value || blog?.summary || "").replace(/<[^>]*>/g, "").trim().slice(0, 160);
const jsonLd = blog const jsonLd = blog
? { ? {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
headline: blog.title, headline: blog.title,
...(articleDescription && { description: articleDescription }),
...(blog.images?.[0]?.url && { image: imageUrl(blog.images[0].url) }), ...(blog.images?.[0]?.url && { image: imageUrl(blog.images[0].url) }),
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` },
...(blog.created && { ...(blog.created && {
datePublished: new Date(blog.created * 1000).toISOString(), datePublished: new Date(blog.created * 1000).toISOString(),
}), }),
...(blog.updated_at && {
dateModified: new Date(blog.updated_at * 1000).toISOString(),
}),
...(blog.author && { author: { "@type": "Person", name: blog.author } }), ...(blog.author && { author: { "@type": "Person", name: blog.author } }),
publisher: { "@type": "Organization", name: brand },
// پست شهریافته حوزهٔ جغرافیایی‌اش را اعلام می‌کند؛ پست سراسری این فیلد را ندارد. // پست شهریافته حوزهٔ جغرافیایی‌اش را اعلام می‌کند؛ پست سراسری این فیلد را ندارد.
...(blogCity && { ...(blogCity && {
spatialCoverage: { "@type": "Place", name: blogCity.name }, spatialCoverage: { "@type": "Place", name: blogCity.name },
@@ -114,6 +146,22 @@ async function Blog({ params }) {
} }
: null; : null;
// FAQPage از فیلد faq (سؤال/جواب) — فقط اگر مقاله FAQ داشته باشد.
const faqItems = Array.isArray(blog?.faq)
? blog.faq.filter((f) => f?.q && f?.a)
: [];
const faqJsonLd = faqItems.length
? {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqItems.map((f) => ({
"@type": "Question",
name: f.q,
acceptedAnswer: { "@type": "Answer", text: f.a },
})),
}
: null;
const breadcrumbJsonLd = blog const breadcrumbJsonLd = blog
? { ? {
"@context": "https://schema.org", "@context": "https://schema.org",
@@ -140,6 +188,12 @@ async function Blog({ params }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }} dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/> />
)} )}
{faqJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
/>
)}
<Layout> <Layout>
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} /> <BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
</Layout> </Layout>
+8
View File
@@ -8,9 +8,17 @@ export async function generateMetadata() {
const title = `مقالات و اخبار پزشکی | ${siteName}`; const title = `مقالات و اخبار پزشکی | ${siteName}`;
const description = `جدیدترین مقالات، اخبار و راهنماهای پزشکی. اطلاعات تخصصی در حوزه سلامت و پزشکی از متخصصان ${siteName}.`; const description = `جدیدترین مقالات، اخبار و راهنماهای پزشکی. اطلاعات تخصصی در حوزه سلامت و پزشکی از متخصصان ${siteName}.`;
const image = "/assets/images/og-image.png"; const image = "/assets/images/og-image.png";
// کلیدواژه‌های شهر از city.json (رشته یا آرایه).
const rawKeywords = matchedCity?.keywords;
const keywords = Array.isArray(rawKeywords)
? rawKeywords
: typeof rawKeywords === "string" && rawKeywords.trim()
? rawKeywords.split(",").map((k) => k.trim()).filter(Boolean)
: undefined;
return { return {
title, title,
description, description,
...(keywords?.length && { keywords }),
// C1-a — لیست بلاگ per-domain است (پست‌های همان شهر + سراسری)؛ // C1-a — لیست بلاگ per-domain است (پست‌های همان شهر + سراسری)؛
// canonical لایهٔ layout (self روی همان دامنه) درست است. // canonical لایهٔ layout (self روی همان دامنه) درست است.
openGraph: { title, description, images: [image] }, openGraph: { title, description, images: [image] },