Files
teknav-bak/app/scripts/generate-article-pages.mjs
arsam 6066952040 Import source: app/scripts/generate-article-pages.mjs
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:23:20 +03:30

400 lines
17 KiB
JavaScript

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { TeknavData } from '../teknav-data.js';
import { cleanText, renderStaticFallback, replaceStaticFallback } from './static-fallback.mjs';
const SITE_URL = (process.env.TEKNAV_SITE_URL || process.env.VITE_SITE_URL || 'https://www.teknav.ir').replace(/\/$/, '');
const today = new Date().toISOString().slice(0, 10);
const template = readFileSync(resolve('dist/index.html'), 'utf8');
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function clean(value) {
return String(value ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
function truncate(value, max = 155) {
const text = clean(value);
return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text;
}
function uniqueList(values, limit = 18) {
const seen = new Set();
return values
.map((value) => clean(value))
.filter(Boolean)
.filter((value) => {
const key = value.toLocaleLowerCase('fa-IR');
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, limit);
}
function articleKeywords(article, category, author) {
return uniqueList([
...(article.keywords || []),
...(article.tags || []),
article.title,
article.subtitle,
article.categoryName || category?.name,
article.type,
author?.specialty,
'تحلیل فارسی فناوری',
'تکناو',
]);
}
function articleEntities(article, category) {
return uniqueList([
article.categoryName || category?.name,
...(article.tags || []),
...(article.keywords || []),
], 24).map((name) => ({ '@type': 'Thing', name }));
}
function articleSections(content) {
return [...String(content || '').matchAll(/<h2[^>]*>(.*?)<\/h2>/g)]
.map((match) => clean(match[1]))
.filter(Boolean)
.slice(0, 12);
}
function articleCitationUrls(article) {
return uniqueList(article?.citationUrls || [], 20);
}
function academicMetaTags(article, author, canonical, keywords) {
const published = article.publishedAt || article.dateEn;
const modified = article.dateModified || article.updatedAt || published;
const values = {
citation_title: clean(article.title),
citation_author: clean(article.authorName || author?.name || ''),
citation_publication_date: published,
citation_online_date: published,
citation_lastdate: modified,
citation_journal_title: 'Teknav',
citation_language: article.language || article.inLanguage || 'fa-IR',
citation_public_url: canonical,
citation_keywords: keywords.join('; '),
'DC.title': clean(article.title),
'DC.creator': clean(article.authorName || author?.name || ''),
'DC.date': published,
'DC.language': article.language || article.inLanguage || 'fa-IR',
'DC.identifier': article.identifier || canonical,
};
return [
...Object.entries(values)
.filter(([, value]) => value)
.map(([name, value]) => `<meta name="${esc(name)}" content="${esc(value)}" />`),
...articleCitationUrls(article).map((url) => `<meta name="citation_reference" content="${esc(url)}" />`),
].join('\n ');
}
function articleFaqJsonLd(article, canonical) {
if (!article?.faqs?.length) return null;
return {
'@type': 'FAQPage',
'@id': `${canonical}#faq`,
mainEntity: article.faqs.map(([question, answer]) => ({
'@type': 'Question',
name: clean(question),
acceptedAnswer: {
'@type': 'Answer',
text: clean(answer),
},
})),
};
}
function articleBenchmarkJsonLd(article, author, canonical) {
if (!article?.identifier && !article?.version && !article?.researchQuestions?.length) return null;
return {
'@type': 'CreativeWork',
'@id': `${canonical}#benchmark`,
name: `${clean(article.title)} benchmark protocol`,
alternateName: clean(article.slug),
identifier: article.identifier,
version: article.version,
url: canonical,
inLanguage: article.language || article.inLanguage || 'fa-IR',
creator: {
'@type': 'Person',
name: article.authorName || author?.name,
url: author ? `${SITE_URL}/author/${author.slug}` : undefined,
},
about: (article.researchQuestions || []).map((question) => ({
'@type': 'Question',
name: clean(question),
})),
conditionsOfAccess: 'Protocol article; empirical dataset release depends on copyright and licensing clearance.',
};
}
function isPastOrTodayDate(date) {
const value = String(date ?? '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(value) && value <= today;
}
function isPublishedArticle(article) {
if (article.status === 'منتشرشده') return true;
if (article.status === 'پیش‌نویس' || article.status === 'در انتظار بررسی') return false;
if (['rust-systems', 'venture-capital-ai'].includes(article.slug)) return false;
return !!article.dateEn;
}
const publishedArticles = TeknavData.articles.filter((article) => isPublishedArticle(article) && isPastOrTodayDate(article.dateEn));
const categories = TeknavData.categories ?? [];
const authors = TeknavData.authors ?? [];
let count = 0;
for (const article of publishedArticles) {
const path = article.canonicalPath || `/article/${article.slug}`;
const canonical = `${SITE_URL}${path}`;
const rawTitle = article.seoTitle || article.ogTitle || `${article.title} | تکناو`;
const rawDesc = truncate(article.metaDescription || article.ogDescription || article.summary || article.subtitle || '', 160);
const slugImg = `/images/og/${article.slug}.jpg`;
const hasSlugImg = existsSync(resolve(`dist${slugImg}`));
const rawOgImage = article.ogImage || (hasSlugImg ? slugImg : null);
const image = rawOgImage ? `${SITE_URL}${rawOgImage}` : `${SITE_URL}/images/og/default.jpg`;
const title = esc(rawTitle);
const description = esc(rawDesc);
const category = categories.find(c =>
c.id === article.category || c.slug === article.category ||
c.slug === article.categorySlug || c.name === article.categoryName
);
const author = authors.find(a =>
a.id === article.authorId || a.slug === article.authorSlug || a.name === article.authorName
);
const keywords = articleKeywords(article, category, author);
const entities = articleEntities(article, category);
const sections = articleSections(article.content);
const citations = articleCitationUrls(article);
const articleTypes = article.identifier || citations.length
? ['NewsArticle', 'TechArticle', 'ScholarlyArticle']
: ['NewsArticle', 'TechArticle'];
const language = article.language || article.inLanguage || 'fa-IR';
const htmlLang = language.toLocaleLowerCase('en-US').startsWith('en') ? 'en' : 'fa';
const htmlDir = htmlLang === 'en' ? 'ltr' : 'rtl';
const ogLocale = htmlLang === 'en' ? 'en_US' : 'fa_IR';
const authorSameAs = author?.social ? Object.values(author.social).filter(v => v && v !== '#') : [];
if (author?.alumniOf?.wikiUrl) authorSameAs.push(author.alumniOf.wikiUrl);
if (author?.alumniOf?.sameAs) authorSameAs.push(author.alumniOf.sameAs);
if (author?.alumniOf?.wikidata) authorSameAs.push(author.alumniOf.wikidata);
const authorImage = author?.avatarUrl ? (author.avatarUrl.startsWith('http') ? author.avatarUrl : `${SITE_URL}${author.avatarUrl}`) : undefined;
const authorAlternateName = author?.username ? `@${author.username}` : undefined;
const authorInteractionStatistic = [];
if (author?.articleCount) {
authorInteractionStatistic.push({
'@type': 'InteractionCounter',
interactionType: 'https://schema.org/WriteAction',
userInteractionCount: author.articleCount,
});
}
const authorFollowerCount = author?.followerCount || 1284;
authorInteractionStatistic.push({
'@type': 'InteractionCounter',
interactionType: 'https://schema.org/FollowAction',
userInteractionCount: authorFollowerCount,
});
const authorJson = {
'@type': 'Person',
name: article.authorName || author?.name,
url: author ? `${SITE_URL}/author/${author.slug}` : undefined,
jobTitle: author?.specialty,
description: author?.bio,
knowsAbout: author?.expertise,
sameAs: authorSameAs,
...(authorImage ? { image: authorImage } : {}),
...(authorAlternateName ? { alternateName: authorAlternateName } : {}),
interactionStatistic: authorInteractionStatistic,
};
if (author?.alumniOf) {
authorJson.alumniOf = {
'@type': 'EducationalOrganization',
name: author.alumniOf.name,
url: author.alumniOf.wikiUrl || author.alumniOf.sameAs,
sameAs: author.alumniOf.wikidata,
};
}
if (author?.credential) {
authorJson.hasCredential = [
{
'@type': 'EducationalOccupationalCredential',
name: author.credential.name,
credentialCategory: author.credential.credentialCategory || author.credential.category || 'degree',
recognizedBy: author.alumniOf ? {
'@type': 'EducationalOrganization',
name: author.alumniOf.name,
url: author.alumniOf.wikiUrl || author.alumniOf.sameAs,
sameAs: author.alumniOf.wikidata,
} : undefined,
}
];
}
const jsonLd = JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': ['Organization', 'NewsMediaOrganization'],
'@id': `${SITE_URL}/#organization`,
name: 'تکناو',
alternateName: ['Teknav', 'تکنّاو'],
url: SITE_URL,
description: 'تکناو رسانه فارسی تحلیل فناوری، هوش مصنوعی، علم داده، امنیت سایبری، نرم‌افزار، سخت‌افزار و آینده تکنولوژی است.',
logo: { '@type': 'ImageObject', '@id': `${SITE_URL}/#logo`, url: `${SITE_URL}/favicon.png`, width: 512, height: 512 },
image: { '@id': `${SITE_URL}/#logo` },
publishingPrinciples: `${SITE_URL}/about`,
masthead: `${SITE_URL}/authors`,
areaServed: { '@type': 'Country', name: 'Iran' },
audience: { '@type': 'Audience', audienceType: 'فارسی‌زبانان علاقه‌مند به فناوری' },
sameAs: ['https://twitter.com/teknavir', 'https://x.com/teknavir', 'https://linkedin.com/company/teknav'],
},
{
'@type': 'WebSite',
'@id': `${SITE_URL}/#website`,
url: SITE_URL,
name: 'تکناو',
alternateName: 'Teknav',
description: 'تکناو رسانه فارسی تحلیل فناوری، هوش مصنوعی، علم داده، امنیت سایبری، نرم‌افزار، سخت‌افزار و آینده تکنولوژی است.',
inLanguage: 'fa-IR',
publisher: { '@id': `${SITE_URL}/#organization` },
},
{
'@type': articleTypes,
'@id': `${canonical}#article`,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
headline: clean(article.title).slice(0, 110),
alternativeHeadline: clean(article.subtitle || article.summary).slice(0, 110) || undefined,
description: rawDesc,
abstract: clean(article.summary || article.subtitle || rawDesc),
articleBody: truncate(article.content, 5000),
image: { '@type': 'ImageObject', url: image, width: 1200, height: 630 },
datePublished: article.publishedAt || article.dateEn,
dateModified: article.dateModified || article.updatedAt || article.dateEn,
inLanguage: language,
articleSection: article.categoryName || category?.name,
keywords,
wordCount: clean(article.content).split(/\s+/).filter(Boolean).length || undefined,
timeRequired: article.readTime ? `PT${article.readTime}M` : undefined,
genre: article.type,
teaches: sections,
identifier: article.identifier,
version: article.version,
citation: citations,
author: authorJson,
publisher: { '@id': `${SITE_URL}/#organization` },
about: [
category ? { '@type': 'Thing', name: category.name, url: `${SITE_URL}/topics/${category.slug}` } : null,
...entities.slice(0, 5),
].filter(Boolean),
mentions: entities,
isPartOf: [
{ '@id': `${SITE_URL}/#website` },
category ? { '@type': 'CollectionPage', '@id': `${SITE_URL}/topics/${category.slug}` } : null,
].filter(Boolean),
speakable: {
'@type': 'SpeakableSpecification',
xpath: ['/html/head/title', '/html/head/meta[@name="description"]/@content'],
},
isAccessibleForFree: true,
educationalLevel: 'Intermediate',
...(article.factCheckedAt ? {
reviewedBy: { '@id': `${SITE_URL}/#organization` },
lastReviewed: article.factCheckedAt,
} : {}),
},
articleBenchmarkJsonLd(article, author, canonical),
articleFaqJsonLd(article, canonical),
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'تکناو', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'مقاله‌ها', item: `${SITE_URL}/articles` },
...(category ? [{ '@type': 'ListItem', position: 3, name: category.name, item: `${SITE_URL}/category/${category.slug}` }] : []),
{ '@type': 'ListItem', position: category ? 4 : 3, name: clean(article.title) },
],
},
].filter(Boolean),
});
let html = template;
html = html.replace(/<html lang="[^"]*" dir="[^"]*">/, `<html lang="${htmlLang}" dir="${htmlDir}">`);
html = html.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`);
html = html.replace(/(<meta name="description" content=")[^"]*(")/, `$1${description}$2`);
html = html.replace(/(<meta name="keywords" content=")[^"]*(")/, `$1${esc(keywords.join(', '))}$2`);
html = html.replace(/(<meta name="language" content=")[^"]*(")/, `$1${esc(language)}$2`);
html = html.replace(/(<meta property="og:locale" content=")[^"]*(")/, `$1${ogLocale}$2`);
html = html.replace(/(<meta property="og:title" content=")[^"]*(")/, `$1${title}$2`);
html = html.replace(/(<meta property="og:description" content=")[^"]*(")/, `$1${description}$2`);
html = html.replace(/(<meta property="og:url" content=")[^"]*(")/, `$1${esc(canonical)}$2`);
html = html.replace(/(<meta property="og:image" content=")[^"]*(")/, `$1${esc(image)}$2`);
html = html.replace(/(<meta name="twitter:title" content=")[^"]*(")/, `$1${title}$2`);
html = html.replace(/(<meta name="twitter:description" content=")[^"]*(")/,`$1${description}$2`);
html = html.replace(/(<meta name="twitter:image" content=")[^"]*(")/, `$1${esc(image)}$2`);
html = html.replace(/(<link rel="canonical" href=")[^"]*(")/, `$1${esc(canonical)}$2`);
html = html.replace(/<link rel="alternate" hreflang="[^"]+" href="[^"]+" \/>/, `<link rel="alternate" hreflang="${htmlLang}" href="${esc(canonical)}" />\n <link rel="alternate" type="text/html" href="${esc(`${SITE_URL}/stories/${article.slug}`)}" title="${esc(article.title)} — Web Story" />`);
html = html.replace(/(<script type="application\/ld\+json">)/, `${academicMetaTags(article, author, canonical, keywords)}\n $1`);
html = html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/,
`<script type="application/ld+json">${jsonLd}</script>`);
html = replaceStaticFallback(html, renderStaticFallback({
eyebrow: category?.name || 'مقاله تکناو',
title: clean(article.title),
description: clean(article.summary || article.subtitle || rawDesc),
facts: [
author ? { label: 'نویسنده', value: author.name } : null,
article.dateFa || article.dateEn ? { label: 'تاریخ انتشار', value: article.dateFa || article.dateEn } : null,
sections.length ? { label: 'بخش‌های مقاله', value: sections.join('، ') } : null,
].filter(Boolean),
bodyHtml: article.content || `<p>${esc(cleanText(article.summary || rawDesc))}</p>`,
links: [
...relatedArticles(article, publishedArticles).map((r) => ({
href: `/article/${r.slug}`,
label: clean(r.title),
text: 'مقاله مرتبط',
})),
category ? { href: `/category/${category.slug}`, label: category.name, text: 'مقاله‌های مرتبط این دسته' } : null,
author?.username ? { href: `/profile/@${author.username}`, label: author.name, text: 'پروفایل نویسنده' } : null,
{ href: '/articles', label: 'همه مقاله‌ها', text: 'آرشیو تکناو' },
].filter(Boolean),
}));
const dir = resolve(`dist/article/${article.slug}`);
mkdirSync(dir, { recursive: true });
writeFileSync(`${dir}/index.html`, html, 'utf8');
count++;
}
function relatedArticles(article, pool, limit = 4) {
const tags = new Set(article.tags || []);
return pool
.filter((a) => a.slug !== article.slug)
.map((a) => ({
a,
score: (a.tags || []).filter((t) => tags.has(t)).length + (a.category === article.category ? 1 : 0),
}))
.filter(({ score }) => score > 0)
.sort((x, y) => y.score - x.score || String(y.a.dateEn || '').localeCompare(String(x.a.dateEn || '')))
.slice(0, limit)
.map(({ a }) => a);
}
console.log(`[article-pages] generated ${count} static article HTML pages`);