feat(seo): ایندکس‌پذیری کامل، محتوای سمت سرور برای خزنده‌های AI و رفع باگ ایندکس

سئوی سمت سرور (backend/app/seo_pages.py)
- متا/canonical/hreflang/JSON-LD برای همهٔ صفحات عمومی (شش صفحهٔ استاتیک فارسی که قبلاً پوستهٔ خالی بودند)
- canonical سمت سرور (قبلاً فقط با JS ست می‌شد)، ۴۰۴ واقعی برای مسیرهای ناموجود
- اسکیما: Organization با لوگوی ImageObject، FAQPage، LocalBusiness، BreadcrumbList،
  CreativeWork پروژه با همهٔ تصاویر به‌صورت ImageObject (نام/توضیح + موقعیت پروژه)
- hreflang فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد؛ sitemap با URLهای درصد‌گذاری‌شده
- llms.txt و robots.txt تازه‌ساز از دیتابیس؛ سیاست صریح خزنده‌های AI + Allow: /api/site
- _site_content_html: محتوای واقعی سایت به‌صورت HTML خوانا در <noscript> —
  خزنده‌های بدون جاوااسکریپت (GPTBot/OAI-SearchBot/ClaudeBot/PerplexityBot) از ۲۴۰ نویسه
  به ۸٬۵۰۰+ نویسه محتوای معنایی می‌رسند
- Cache-Control: no-store روی HTML و llms.txt (جلوگیری از کش کهنهٔ CDN)، max-age روی sitemap

فرانت‌اند
- api.tsx: خواندن دادهٔ تزریق‌شدهٔ سرور (window.__SITE__) به‌عنوان حالت اولیه — رندر بدون XHR
- App.tsx: اسکلت بارگذاری به‌جای DOM خالی (رفع Soft 404 گوگل)
- index.html: فاوآیکون واقعی برند (قبلاً به vite.svg ناموجود اشاره می‌کرد) + preload فونت

زیرساخت
- Dockerfile: آینهٔ PyPI (Tsinghua/Aliyun) — PyPI برای IP ایران بلاک است
- Caddyfile: هر مسیر پسوند‌دار بدون فایل واقعی ۴۰۴ می‌گیرد (قبلاً HTML اسپا برمی‌گرداند)
- docker-compose.prod.yml: مسیرهای seo برای صفحات استاتیک + llms.txt + کلیدهای IndexNow

داده
- seed_data*.py: حذف دفاتر و معرفی‌های دمو (که در هر استقرار برمی‌گشتند)؛
  تنها دفتر واقعی: اصفهان، ملاصدرا شمالی

دارایی‌ها
- فاوآیکون/آیکون‌های برند از لوگوی رسمی + کلیدهای IndexNow
This commit is contained in:
aramesh-site deploy
2026-10-09 23:29:26 +00:00
parent 353d4639b4
commit 1d0c1ae53d
17 changed files with 397 additions and 66 deletions
+14 -4
View File
@@ -16,14 +16,24 @@
file_server
}
# مسیریابی SPA — مسیرهای ناشناختهٔ غیرasset به index.html برمی‌گردند
# هر مسیر دارای پسوند فایل (فونت، تصویر، نقشه، آیکون، …): فقط فایل واقعی سرو می‌شود؛
# فایل ناموجود ۴۰۴ می‌گیرد، نه HTML اسپا — خزنده‌ها (گوگل) پاسخ HTML برای فونت/تصویر
# را «خطای منابع» ثبت می‌کنند و CDN هم همان HTML را کش می‌کند
@filelike path_regexp fileext \.[a-zA-Z0-9]{2,5}$
handle @filelike {
file_server
}
# مسیریابی SPA — فقط مسیرهای بدون پسوند (مسیرهای اپ) به index.html برمی‌گردند
handle {
try_files {path} /index.html
file_server
}
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
@notassets not path /assets/*
header @notassets Cache-Control "no-cache"
# index.html و fallback اسپا: بدون کش مرورگر تا هر استقرار فوری دیده شود
header Cache-Control "no-cache"
# فایل‌های ثابت (فونت/تصویر): یک ساعت کش مرورگر
header @filelike Cache-Control "public, max-age=3600"
# دارایی‌های هش‌دار: کش یک‌ساله
header @assets Cache-Control "public, immutable, max-age=31536000"
}
+5 -1
View File
@@ -2,7 +2,11 @@
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<!-- آیکون‌های سایت از لوگوی رسمی برند (قبلاً به vite.svg پیش‌فرض اشاره می‌کرد که وجود نداشت) -->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- پیش‌بارگذاری فونت‌های اصلی — بدون این، swap فونت با تأخیر می‌آید (LCP) -->
<link rel="preload" href="/fonts/pinar/Pinar-Light.woff2" as="font" type="font/woff2" crossorigin />
@@ -0,0 +1 @@
63730e04d66942f8b422d2d017f67fe4
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1 @@
f48526ce084d4b76a6e522283d3ad68f
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+8 -1
View File
@@ -149,7 +149,14 @@ function AppShell() {
<ScrollToTop />
<AppChrome />
</>
) : null}
) : (
// اسکلت اولیه تا رسیدن داده‌های API — صفحه هرگز خالی رندر نمی‌شود.
// (خزنده‌های رندرکننده — مثل گوگل — صفحهٔ خالی را Soft 404 می‌بینند)
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<h1 className="text-2xl font-bold text-ivory">{t('brandFallback')}</h1>
<p className="mt-4 text-sm font-light text-ivory/70">{t('loading')}</p>
</div>
)}
</div>
);
}
+15 -3
View File
@@ -228,6 +228,14 @@ interface SiteContextValue {
updateSite: (mutate: (data: SiteData) => void) => void
}
declare global {
interface Window {
/** دادهٔ سایت که بک‌اند داخل HTML تزریق می‌کند (app/seo_pages.py) —
* رندر بدون انتظار برای درخواست /api/site؛ خزنده‌های رندرکننده صفحهٔ کامل می‌بینند */
__SITE__?: SiteData
}
}
const SiteContext = createContext<SiteContextValue>({
data: null,
error: false,
@@ -242,7 +250,10 @@ const SiteContext = createContext<SiteContextValue>({
const SITE_TIMEOUT_MS = 15_000
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null)
// دادهٔ تزریق‌شدهٔ سرور (window.__SITE__) به‌عنوان حالت اولیه — اولین رندر کامل است
// و به درخواست /api/site وابسته نیست؛ درخواست همچنان در پس‌زمینه تازه‌سازی می‌کند
const embedded = typeof window !== 'undefined' ? window.__SITE__ : undefined
const [data, setData] = useState<SiteData | null>(embedded ?? null)
const [error, setError] = useState(false)
const load = useCallback(() => {
@@ -284,11 +295,12 @@ export function SiteProvider({ children }: { children: ReactNode }) {
}
if (!cancelled) {
if (json.brand && json.hero && json.footer && json.nav) setData(json)
else setError(true)
// با دادهٔ تزریق‌شده، پاسخ نامعتبر فقط یعنی «تازه‌سازی نشد» — خطا نشان نده
else if (!embedded) setError(true)
}
})
.catch(() => {
if (!cancelled) setError(true)
if (!cancelled && !embedded) setError(true)
})
return () => {
cancelled = true