اصلاحات بازبینی: تطبیق backfill با متن فارسی، رفع باگ متای /blog (ستون image ناموجود)، پاک‌سازی og:locale و برند در پوسته سئو، محدودکردن Traefik به پروژه خود، اصلاح مسیریابی Routes و عنوان برند در صفحات جزئیات

This commit is contained in:
MMD
2026-09-13 00:10:53 +03:30
parent 6b49d035c2
commit 4fb57dfd67
6 changed files with 87 additions and 65 deletions
+57 -45
View File
@@ -212,64 +212,74 @@ def _backfill_row(row, en_fields: dict) -> int:
def backfill_en_content() -> None:
"""پر کردن ترجمه‌های انگلیسی سطرهای موجود از seed_data_en — idempotent.
تطبیق سطرها: جدول‌های تک‌سطری مستقیم؛ جدول‌های دارای کلید طبیعی (slug/key/
page_key) با همان کلید؛ بقیه فقط وقتی سطرها با seed دست‌نخورده‌اند (تعداد و
idها مطابق) و به‌ترتیب. هیچ مقدار انگلیسیِ ثبت‌شده بازنویسی نمی‌شود.
تطبیق سطرها: جدول‌های تک‌سطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
ویرایش کرده باشد سطر تطبیق نمی‌خورد و ترجمه‌اش خالی می‌ماند تا از پنل پر شود.
هیچ مقدار انگلیسیِ ثبت‌شده بازنویسی نمی‌شود.
"""
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقه‌ای
# (مدل، فهرست فارسی، فهرست انگلیسی، کلید تطبیق یا None برای ترتیبی)
PLAN: list[tuple[object, list[dict], list[dict], str | None]] = [
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], None),
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], None),
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], None),
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, "key"),
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, "key"),
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, None),
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, None),
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, None),
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, None),
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, None),
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, "key"),
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], None),
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], None),
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], None),
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, None),
(models.Stat, seed_data.STATS, seed_data_en.STATS, None),
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, "slug"),
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, "slug"),
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, None),
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, None),
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, None),
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, None),
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, None),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], None),
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, "page_key"),
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تک‌سطری‌ها)
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")),
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)),
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
]
db = SessionLocal()
try:
total_rows = 0
total_fields = 0
skipped: list[str] = []
for model, fa_rows, en_rows, match_key in PLAN:
unmatched: list[str] = []
for model, fa_rows, en_rows, match_keys in PLAN:
rows = db.query(model).all()
if not rows or not en_rows:
continue
table_name = getattr(model, "__tablename__", str(model))
if match_key is not None:
en_by_key = {r[match_key]: r for r in en_rows if match_key in r}
matched = [(row, en_by_key.get(getattr(row, match_key, None))) for row in rows]
pairs = [(row, en) for row, en in matched if en is not None]
elif len(rows) == 1 and len(en_rows) == 1:
if not match_keys:
if len(rows) != 1 or len(en_rows) != 1:
unmatched.append(table_name)
continue
pairs = [(rows[0], en_rows[0])]
else:
# تطبیق ترتیبی فقط برای جدول‌های دست‌نخورده — idها از ۱ پیوسته
ids = [row.id for row in rows]
if ids != list(range(1, len(rows) + 1)) or len(rows) != len(en_rows):
skipped.append(getattr(model, "__tablename__", str(model)))
continue
pairs = list(zip(rows, en_rows))
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
en_lookups: list[dict[object, dict]] = []
for key in match_keys:
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
pairs = []
matched_en_ids: set[int] = set()
for row in rows:
en_row = None
for key, lookup in zip(match_keys, en_lookups):
en_row = lookup.get(getattr(row, key, None))
if en_row is not None:
break
if en_row is not None and id(en_row) not in matched_en_ids:
matched_en_ids.add(id(en_row))
pairs.append((row, en_row))
for row, en_row in pairs:
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل می‌شود
@@ -284,8 +294,10 @@ def backfill_en_content() -> None:
db.commit()
if total_fields:
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
if skipped:
print(f"جدول‌های تغییرکرده (بدون backfill خودکار — از پنل مدیریت ترجمه کنید): {', '.join(skipped)}")
if unmatched:
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
for name in unmatched:
print(f" - {name}")
finally:
db.close()
+16 -2
View File
@@ -136,8 +136,23 @@ def _shell(
)
if lang == "en":
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت و زبان سند عوض می‌شود
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
# description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
# og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود
og_locale = "en_US" if lang == "en" else "fa_IR"
if re.search(r'<meta property="og:locale"', raw):
raw = re.sub(
r'(<meta property="og:locale" content=")[^"]*(")',
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
raw,
count=1,
)
tags = [t for t in tags if 'og:locale' not in t]
block = "\n ".join(tags)
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
if noscript:
@@ -223,7 +238,6 @@ def blog_list(db: Session = Depends(get_db)):
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
description=meta.description if meta else "",
path="/blog",
og_image=meta.image if meta else "",
)
+2
View File
@@ -10,6 +10,8 @@ services:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
# فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80
ports:
- "${TRAEFIK_HTTP_PORT:-5173}:80"
+10 -16
View File
@@ -37,20 +37,13 @@ const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
{ path: '/contact', element: <ContactPage /> },
];
function LocaleRoutes({ prefix }: { prefix: '' | '/en' }) {
return (
<>
{PUBLIC_ROUTES.map(({ path, element }) => (
<Route
key={`${prefix}${path}`}
path={path === '/' ? (prefix || '/') : `${prefix}${path}`}
element={element}
/>
))}
{prefix === '' && <Route path="*" element={<Home />} />}
</>
);
}
// فرزند مستقیم <Routes> باید <Route> باشد — آرایه‌های Route به‌جای کامپوننت واسط
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
<Route key={path} path={path} element={element} />
));
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
));
function ScrollToTop() {
const { pathname } = useLocation();
@@ -75,8 +68,9 @@ function AnimatedRoutes() {
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
<LocaleRoutes prefix="" />
<LocaleRoutes prefix="/en" />
{FA_ROUTES}
<Route path="*" element={<Home />} />
{EN_ROUTES}
</Routes>
</motion.main>
</AnimatePresence>);
+1 -1
View File
@@ -40,7 +40,7 @@ export function ArticleDetail() {
useEffect(() => {
if (!article) return;
applySeo({
title: `${article.title} | ${brand}`,
title: `${article.title} | ${brand.name}`,
description: article.excerpt,
path: href(`/blog/${article.slug}`),
image: article.image,
+1 -1
View File
@@ -23,7 +23,7 @@ export function ProjectDetail() {
useEffect(() => {
if (!project) return;
applySeo({
title: `${project.title} | ${brand}`,
title: `${project.title} | ${brand.name}`,
description: project.summary,
path: href(`/projects/${project.slug}`),
image: project.image,