323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
import Database from 'better-sqlite3';
|
||
import { existsSync, mkdirSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
const settings = {
|
||
siteInfo: {
|
||
name: "روس امروز",
|
||
tagline: "خبرگزاری",
|
||
description: "خبرگزاری تخصصی پوشش اخبار روسیه، اوراسیا و روابط منطقهای",
|
||
copyright: "۱۴۰۵ روس امروز — تمامی حقوق محفوظ است"
|
||
},
|
||
breakingNews: {
|
||
label: "فوری",
|
||
href: "/posts/آستانا-میزبان-جام-قهرمانان-چهار-قاره-در-اسکیت-نمایشی-در-سال-۲۰۲۷-1782120219014/",
|
||
items: [
|
||
"آستانا میزبان جام قهرمانان چهار قاره در اسکیت نمایشی در سال ۲۰۲۷"
|
||
],
|
||
postSlugs: [
|
||
"آستانا-میزبان-جام-قهرمانان-چهار-قاره-در-اسکیت-نمایشی-در-سال-۲۰۲۷-1782120219014"
|
||
]
|
||
},
|
||
hotTopics: [
|
||
"انرژی",
|
||
"تحریم",
|
||
"نفت",
|
||
"گاز",
|
||
"اوراسیا",
|
||
"SCO",
|
||
"روبل",
|
||
"تجارت",
|
||
"قفقاز",
|
||
"ناتو",
|
||
"بریکس"
|
||
],
|
||
homeLayout: {
|
||
featuredSlug: "در-کوبان-به-فرد-حمله-کننده-در-مرکز-خرید-اتهام-قتل-و-تلاش-برای-قتل-وارد-شد-1782119386187",
|
||
sideFeaturedSlugs: [
|
||
"south-caucasus-geopolitics",
|
||
"oil-price-new-record"
|
||
],
|
||
latestCount: 4,
|
||
showAd: true
|
||
},
|
||
ad: {
|
||
enabled: true,
|
||
label: "آگهی",
|
||
href: "/advertise/",
|
||
text: "جایگاه تبلیغات"
|
||
},
|
||
agents: {
|
||
tass: {
|
||
processedLinks: [
|
||
"https://tass.ru/proisshestviya/27834845",
|
||
"https://tass.ru/sport/27834851",
|
||
"https://tass.ru/obschestvo/27834629",
|
||
"https://tass.ru/mezhdunarodnaya-panorama/27834975",
|
||
"https://tass.ru/obschestvo/27834957",
|
||
"https://tass.ru/politika/27834811",
|
||
"https://tass.ru/obschestvo/27818171",
|
||
"https://tass.ru/obschestvo/27835073",
|
||
"https://tass.ru/sport/27835167"
|
||
]
|
||
}
|
||
}
|
||
};
|
||
const categories = [
|
||
"اقتصاد",
|
||
"سیاست",
|
||
"انرژی",
|
||
"فناوری",
|
||
"بینالملل"
|
||
];
|
||
const posts = [
|
||
];
|
||
const fallbackCms = {
|
||
settings: settings,
|
||
categories: categories,
|
||
posts: posts
|
||
};
|
||
|
||
const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||
const toPersianDigits = (value) => String(value).replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] ?? digit);
|
||
const getPublishedAt = (post) => {
|
||
if (post.publishedAt) {
|
||
const date = new Date(post.publishedAt);
|
||
return Number.isNaN(date.getTime()) ? null : date;
|
||
}
|
||
const match = post.slug.match(/(\d{13})$/);
|
||
if (match) {
|
||
const date = new Date(Number(match[1]));
|
||
return Number.isNaN(date.getTime()) ? null : date;
|
||
}
|
||
return null;
|
||
};
|
||
const derivePublishedAt = (post) => {
|
||
const date = getPublishedAt(post);
|
||
return date ? date.toISOString() : void 0;
|
||
};
|
||
const formatShamsiDate = (date) => {
|
||
const formatter = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit"
|
||
});
|
||
return toPersianDigits(formatter.format(date));
|
||
};
|
||
const formatShamsiDay = (date) => {
|
||
const formatter = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric"
|
||
});
|
||
return toPersianDigits(formatter.format(date));
|
||
};
|
||
const formatRelativeTime = (date) => {
|
||
const now = Date.now();
|
||
const diffSeconds = Math.max(0, Math.round((now - date.getTime()) / 1e3));
|
||
if (diffSeconds < 60) {
|
||
return "هماکنون";
|
||
}
|
||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||
if (diffMinutes < 60) {
|
||
return `${toPersianDigits(diffMinutes)} دقیقه پیش`;
|
||
}
|
||
const diffHours = Math.floor(diffMinutes / 60);
|
||
if (diffHours < 24) {
|
||
return `${toPersianDigits(diffHours)} ساعت پیش`;
|
||
}
|
||
const diffDays = Math.floor(diffHours / 24);
|
||
if (diffDays < 30) {
|
||
return `${toPersianDigits(diffDays)} روز پیش`;
|
||
}
|
||
return formatShamsiDay(date);
|
||
};
|
||
const formatPostRelativeTime = (post) => {
|
||
const date = getPublishedAt(post);
|
||
return date ? formatRelativeTime(date) : post.time;
|
||
};
|
||
const formatPostShamsiDate = (post) => {
|
||
const date = getPublishedAt(post);
|
||
return date ? formatShamsiDate(date) : post.time;
|
||
};
|
||
|
||
const dbPath = path.join(process.cwd(), "data", "cms.db");
|
||
const CMS_KEY = "cms";
|
||
let dbInstance = null;
|
||
const getDb = () => {
|
||
if (dbInstance) {
|
||
return dbInstance;
|
||
}
|
||
const directory = path.dirname(dbPath);
|
||
if (!existsSync(directory)) {
|
||
mkdirSync(directory, { recursive: true });
|
||
}
|
||
const db = new Database(dbPath);
|
||
db.pragma("journal_mode = WAL");
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS cms (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
updated_at INTEGER NOT NULL
|
||
)
|
||
`);
|
||
dbInstance = db;
|
||
return db;
|
||
};
|
||
const seedIfEmpty = (db) => {
|
||
const existing = db.prepare("SELECT value FROM cms WHERE key = ?").get(CMS_KEY);
|
||
if (existing) {
|
||
return;
|
||
}
|
||
db.prepare("INSERT INTO cms (key, value, updated_at) VALUES (?, ?, ?)").run(
|
||
CMS_KEY,
|
||
JSON.stringify(fallbackCms),
|
||
Date.now()
|
||
);
|
||
};
|
||
const staticPages = [
|
||
{
|
||
slug: "about",
|
||
title: "درباره ما",
|
||
description: "آشنایی با مأموریت، رویکرد خبری و تیم تحریریه روس امروز",
|
||
body: [
|
||
"روس امروز رسانهای تخصصی برای پوشش خبرها، گزارشها و تحلیلهای مرتبط با روسیه، اوراسیا و روابط منطقهای است.",
|
||
"هدف ما ارائه روایت دقیق، سریع و قابل اتکا از روندهای سیاسی، اقتصادی، انرژی و فناوری در این حوزه جغرافیایی است.",
|
||
"تحریریه روس امروز تلاش میکند با زبان روشن و تحلیل دادهمحور، تصویر کاملتری از تحولات منطقه ارائه دهد."
|
||
]
|
||
},
|
||
{
|
||
slug: "contact",
|
||
title: "ارتباط با ما",
|
||
description: "راههای ارتباط با تحریریه و واحد بازرگانی روس امروز",
|
||
body: [
|
||
"برای ارسال خبر، پیشنهاد همکاری یا پیگیری موضوعات رسانهای میتوانید از طریق ایمیل info@ruseemrooz.ir با ما در تماس باشید.",
|
||
"پیامهای مرتبط با آگهی و همکاری تجاری توسط واحد بازرگانی بررسی و در کوتاهترین زمان پاسخ داده میشود."
|
||
]
|
||
},
|
||
{
|
||
slug: "editorial-team",
|
||
title: "تیم تحریریه",
|
||
description: "معرفی ساختار تحریریه و حوزههای تخصصی روس امروز",
|
||
footerLabel: "تیم تحریریه",
|
||
body: [
|
||
"تیم تحریریه روس امروز از نویسندگان و تحلیلگرانی تشکیل شده که حوزههای روسیه، قفقاز، آسیای مرکزی، انرژی و اقتصاد منطقهای را دنبال میکنند.",
|
||
"مطالب منتشرشده پس از بررسی محتوایی و ویرایشی در سایت قرار میگیرند تا دقت و خوانایی حفظ شود."
|
||
]
|
||
},
|
||
{
|
||
slug: "advertise",
|
||
title: "آگهی در سایت",
|
||
description: "فرصتهای تبلیغاتی و همکاری رسانهای با روس امروز",
|
||
body: [
|
||
"روس امروز امکان انتشار آگهی، رپورتاژ و کمپینهای هدفمند برای مخاطبان علاقهمند به روسیه و اوراسیا را فراهم میکند.",
|
||
"برای دریافت تعرفه و پیشنهاد رسانهای اختصاصی، با واحد بازرگانی سایت تماس بگیرید."
|
||
]
|
||
},
|
||
{
|
||
slug: "privacy",
|
||
title: "حریم خصوصی",
|
||
description: "سیاست حفظ حریم خصوصی کاربران روس امروز",
|
||
body: [
|
||
"روس امروز اطلاعات کاربران را تنها برای بهبود تجربه کاربری، ارسال خبرنامه و تحلیل عملکرد سایت استفاده میکند.",
|
||
"اطلاعات تماس کاربران بدون رضایت آنها در اختیار اشخاص ثالث قرار نمیگیرد."
|
||
]
|
||
}
|
||
];
|
||
const readCms = async () => {
|
||
try {
|
||
const db = getDb();
|
||
seedIfEmpty(db);
|
||
const row = db.prepare("SELECT value FROM cms WHERE key = ?").get(CMS_KEY);
|
||
const data = row ? JSON.parse(row.value) : fallbackCms;
|
||
return normalizeCms(data);
|
||
} catch {
|
||
return normalizeCms(fallbackCms);
|
||
}
|
||
};
|
||
const writeCms = async (data) => {
|
||
const db = getDb();
|
||
const json = JSON.stringify(normalizeCms(data));
|
||
db.prepare(
|
||
`INSERT INTO cms (key, value, updated_at) VALUES (?, ?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||
).run(CMS_KEY, json, Date.now());
|
||
};
|
||
const normalizeCms = (data) => ({
|
||
...data,
|
||
settings: {
|
||
...data.settings,
|
||
breakingNews: {
|
||
...data.settings.breakingNews,
|
||
postSlugs: data.settings.breakingNews.postSlugs ?? []
|
||
},
|
||
agents: {
|
||
...data.settings.agents,
|
||
tass: {
|
||
processedLinks: data.settings.agents?.tass?.processedLinks ?? []
|
||
}
|
||
}
|
||
},
|
||
posts: data.posts.map((post) => ({
|
||
...post,
|
||
status: post.status ?? "pending",
|
||
body: Array.isArray(post.body) ? post.body : [],
|
||
urgent: post.urgent ?? false,
|
||
sidebar: post.sidebar ?? false,
|
||
publishedAt: post.publishedAt ?? derivePublishedAt(post)
|
||
}))
|
||
});
|
||
const getApprovedPosts = (data) => data.posts.filter((post) => post.status === "approved");
|
||
const getCategoryPath = (category) => `/categories/${encodeURIComponent(category)}/`;
|
||
const getStaticPageBySlug = (slug) => staticPages.find((page) => page.slug === slug);
|
||
const getPrimaryNav = () => [
|
||
{ label: "صفحه اصلی", href: "/" },
|
||
...staticPages.filter((page) => ["about", "contact"].includes(page.slug)).map((page) => ({ label: page.navLabel ?? page.title, href: `/${page.slug}/` }))
|
||
];
|
||
const getFooterPageLinks = () => staticPages.map((page) => ({
|
||
label: page.footerLabel ?? page.navLabel ?? page.title,
|
||
href: `/${page.slug}/`
|
||
}));
|
||
const getPostBySlug = (posts, slug) => posts.find((post) => post.slug === slug);
|
||
const getPostsByCategory = (posts, category) => posts.filter((post) => post.category === category);
|
||
const getPaginatedPosts = (page, perPage, source) => {
|
||
const totalPages = Math.max(1, Math.ceil(source.length / perPage));
|
||
const currentPage = Math.min(Math.max(Math.floor(page), 1), totalPages);
|
||
const start = (currentPage - 1) * perPage;
|
||
return {
|
||
currentPage,
|
||
totalPages,
|
||
posts: source.slice(start, start + perPage)
|
||
};
|
||
};
|
||
const searchPosts = (posts, query) => {
|
||
const normalizedQuery = query.trim().toLocaleLowerCase("fa-IR");
|
||
if (!normalizedQuery) {
|
||
return [];
|
||
}
|
||
return posts.filter((post) => {
|
||
const searchableText = [
|
||
post.title,
|
||
post.category,
|
||
post.excerpt,
|
||
...post.body
|
||
].join(" ").toLocaleLowerCase("fa-IR");
|
||
return searchableText.includes(normalizedQuery);
|
||
});
|
||
};
|
||
const slugify = (value) => value.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
||
const parseImageBlock = (value) => {
|
||
const match = value.match(/^\[image:(.+?)(?:\|(.*))?\]$/);
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
return {
|
||
src: match[1].trim(),
|
||
caption: match[2]?.trim() ?? ""
|
||
};
|
||
};
|
||
|
||
export { getPostsByCategory as a, getApprovedPosts as b, getPostBySlug as c, getPaginatedPosts as d, getCategoryPath as e, formatPostRelativeTime as f, getStaticPageBySlug as g, formatPostShamsiDate as h, searchPosts as i, getPrimaryNav as j, getFooterPageLinks as k, parseImageBlock as p, readCms as r, slugify as s, writeCms as w };
|