170 lines
11 KiB
TypeScript
170 lines
11 KiB
TypeScript
import { useMemo } from 'react'
|
||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||
import type { ArticleBodyDoc } from '../../data/site'
|
||
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||
|
||
export type SeoInput = {
|
||
title: string
|
||
slug: string
|
||
excerpt: string
|
||
body: ArticleBodyDoc | string[]
|
||
keyphrase: string
|
||
/** نام نویسنده — سیگنال E-E-A-T و اسکیمای Article */
|
||
author?: string
|
||
}
|
||
|
||
type Status = 'good' | 'ok' | 'bad'
|
||
type Check = { label: string; status: Status; hint: string }
|
||
|
||
// ---------- محاسبه بررسیها ----------
|
||
|
||
export function useSeoChecks(input: SeoInput): Check[] {
|
||
return useMemo(() => {
|
||
const text = docToText(input.body)
|
||
const words = text.split(/\s+/).filter(Boolean)
|
||
const kp = input.keyphrase.trim()
|
||
// تطبیق کلمه کلیدی بیتوجه به بزرگ/کوچکی حروف — و نرمالسازی عربی/فارسی
|
||
const norm = (s: string) => s.toLowerCase().replace(/ي/g, 'ی').replace(/ك/g, 'ک')
|
||
const has = (s: string, needle: string) => norm(s).includes(norm(needle))
|
||
const checks: Check[] = []
|
||
|
||
// عنوان
|
||
const tLen = input.title.trim().length
|
||
if (tLen === 0) checks.push({ label: 'عنوان مقاله', status: 'bad', hint: 'برای مقاله عنوان بنویسید.' })
|
||
else if (tLen >= 20 && tLen <= 60) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'good', hint: 'طول عنوان مناسب است.' })
|
||
else if (tLen >= 10 && tLen <= 70) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی عنوان بین ۲۰ تا ۶۰ نویسه است.' })
|
||
else checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'bad', hint: 'طول عنوان خارج از بازه ۱۰ تا ۷۰ نویسه است.' })
|
||
|
||
// کلمه کلیدی هدف
|
||
if (!kp) {
|
||
checks.push({ label: 'کلمه کلیدی هدف', status: 'ok', hint: 'کلمه کلیدی هدف را بنویسید تا حضور آن در متن بررسی شود.' })
|
||
} else {
|
||
checks.push(
|
||
has(input.title, kp)
|
||
? { label: 'کلمه کلیدی در عنوان', status: 'good', hint: 'کلمه کلیدی در عنوان هست.' }
|
||
: { label: 'کلمه کلیدی در عنوان', status: 'bad', hint: `کلمه «${kp}» در عنوان نیست.` },
|
||
)
|
||
const firstP = firstParagraph(input.body)
|
||
checks.push(
|
||
has(firstP, kp)
|
||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||
)
|
||
const h2s = docHeadings(input.body, 2)
|
||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||
else
|
||
checks.push(
|
||
h2s.some((h) => has(h, kp))
|
||
? { label: 'کلمه کلیدی در تیترها', status: 'good', hint: 'کلمه کلیدی در یکی از تیترها هست.' }
|
||
: { label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'کلمه کلیدی در هیچ تیتر H2 نیست.' },
|
||
)
|
||
}
|
||
|
||
// چکیده (توضیح متا)
|
||
const eLen = input.excerpt.trim().length
|
||
if (eLen === 0) checks.push({ label: 'چکیده (توضیح متا)', status: 'bad', hint: 'چکیده بنویسید — روی کارت مقاله و توضیح متای گوگل نمایش داده میشود.' })
|
||
else if (eLen >= 70 && eLen <= 160) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'good', hint: 'طول چکیده برای توضیح متا ایدهآل است.' })
|
||
else if (eLen >= 40 && eLen <= 200) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی چکیده بین ۷۰ تا ۱۶۰ نویسه است.' })
|
||
else checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'bad', hint: 'چکیده خیلی کوتاه یا بلندتر از ۲۰۰ نویسه است.' })
|
||
|
||
// طول متن
|
||
if (words.length >= 150) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'good', hint: 'طول متن برای محتوای ارزشمند کافی است.' })
|
||
else if (words.length >= 80) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'ok', hint: 'متن کمی کوتاه است — حداقل ۱۵۰ واژه پیشنهاد میشود.' })
|
||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||
|
||
// تیتر H2
|
||
const h2s = docHeadings(input.body, 2)
|
||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||
|
||
// تصاویر
|
||
const imgs = docImages(input.body)
|
||
if (imgs.length === 0)
|
||
checks.push({ label: 'تصاویر', status: 'ok', hint: 'تصویری داخل متن نیست — افزودن تصویر به جذابیت مقاله کمک میکند.' })
|
||
else if (imgs.every((i) => i.alt.trim()))
|
||
checks.push({ label: `تصاویر (${imgs.length} تصویر با متن جایگزین)`, status: 'good', hint: 'همه تصاویر متن جایگزین دارند.' })
|
||
else
|
||
checks.push({ label: 'تصاویر', status: 'bad', hint: 'برای همه تصاویر، متن جایگزین (alt) بنویسید.' })
|
||
|
||
// لینکها
|
||
const links = docLinks(input.body)
|
||
if (links.length > 0) checks.push({ label: `پیوندها (${links.length} لینک)`, status: 'good', hint: 'متن پیوند دارد.' })
|
||
else checks.push({ label: 'پیوندها', status: 'ok', hint: 'یک لینک مرتبط (داخلی به پروژه/خدمات یا خارجی) اضافه کنید.' })
|
||
|
||
// نشانی
|
||
if (/^article-\d+$/.test(input.slug))
|
||
checks.push({ label: 'نشانی (slug)', status: 'bad', hint: 'نشانی پیشفرض را به یک نشانی خوانا (انگلیسی) تغییر دهید.' })
|
||
else if (input.slug.length > 96)
|
||
checks.push({ label: 'نشانی (slug)', status: 'ok', hint: 'نشانی خیلی بلند است — کوتاهش کنید.' })
|
||
else
|
||
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
|
||
|
||
// ——— سئوی مولد (GEO) — آمادهسازی برای ارجاع در پاسخهای هوش مصنوعی ———
|
||
|
||
// پاسخ مستقیم در ابتدای مقاله — دستیارهای هوشمند بخشهای خودبسندهٔ آغازین را نقل میکنند
|
||
const firstPWords = firstParagraph(input.body).split(/\s+/).filter(Boolean).length
|
||
if (firstPWords >= 20)
|
||
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'good', hint: 'پاراگراف اول پاسخ نسبتاً کاملی میدهد — دقیقاً چیزی که هوش مصنوعی نقل میکند.' })
|
||
else
|
||
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'ok', hint: 'پاراگراف اول را پاسخوار بنویسید (حدود ۴۰ تا ۶۰ واژه، پاسخ مستقیم پرسش اصلی مقاله).' })
|
||
|
||
// آمار و عدد — قویترین سیگنال ارجاع هوش مصنوعی (بههمراه ذکر منبع)
|
||
const numberTokens = words.filter((w) => /\d|[۰-۹]/.test(w)).length
|
||
if (numberTokens >= 5)
|
||
checks.push({ label: `آمار و اعداد (${numberTokens} مورد)`, status: 'good', hint: 'متن دادهمحور است — سیگنال قوی برای استناد.' })
|
||
else
|
||
checks.push({ label: 'آمار و اعداد', status: 'ok', hint: 'حداقل ۵ عدد یا درصد (با ذکر منبع) به متن اضافه کنید — محتوای دادهمحور بیش از همه ارجاع میگیرد.' })
|
||
|
||
// تیتر پرسشی — همراستا با الگوی پرسش کاربران و «People Also Ask»
|
||
if (h2s.some((h) => h.includes('؟') || h.includes('?')))
|
||
checks.push({ label: 'تیتر پرسشی', status: 'good', hint: 'حداقل یک تیتر به شکل پرسش است.' })
|
||
else
|
||
checks.push({ label: 'تیتر پرسشی', status: 'ok', hint: 'یک تیتر H2 پرسشی (با «؟») اضافه کنید — همانطور که کاربران میپرسند.' })
|
||
|
||
// نویسنده — سیگنال اعتماد و هویت روشن محتوا
|
||
if ((input.author ?? '').trim())
|
||
checks.push({ label: 'نویسنده', status: 'good', hint: 'نام نویسنده ثبت شده — در اسکیمای مقاله هم میآید.' })
|
||
else
|
||
checks.push({ label: 'نویسنده', status: 'ok', hint: 'نام نویسنده را بنویسید — سیگنال اعتماد (E-E-A-T) و در اسکیمای مقاله استفاده میشود.' })
|
||
|
||
return checks
|
||
}, [input])
|
||
}
|
||
|
||
const STATUS_STYLE = {
|
||
good: { icon: CheckCircle2Icon, color: 'text-emerald-400' },
|
||
ok: { icon: AlertCircleIcon, color: 'text-amber-400' },
|
||
bad: { icon: XCircleIcon, color: 'text-red-400' },
|
||
} as const
|
||
|
||
/** پنل بررسی سئو — فهرست سبز/نارنجی/قرمز به سبک یوست، سازگار با فارسی */
|
||
export function SeoChecklist({ input }: { input: SeoInput }) {
|
||
const checks = useSeoChecks(input)
|
||
const good = checks.filter((c) => c.status === 'good').length
|
||
|
||
return (
|
||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="بررسی سئو">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-sm font-bold text-copper">بررسی سئو</h2>
|
||
<span className="text-xs font-light text-ivory/50">
|
||
{good} از {checks.length} مورد سبز
|
||
</span>
|
||
</div>
|
||
<ul className="mt-4 grid gap-2.5">
|
||
{checks.map((c) => {
|
||
const S = STATUS_STYLE[c.status]
|
||
return (
|
||
<li key={c.label} className="flex items-start gap-3">
|
||
<S.icon className={`mt-1 h-4 w-4 shrink-0 ${S.color}`} aria-hidden="true" />
|
||
<div>
|
||
<p className="text-sm font-light text-ivory/85">{c.label}</p>
|
||
<p className="text-xs font-light leading-6 text-ivory/45">{c.hint}</p>
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</section>
|
||
)
|
||
}
|