Import source: app/festival-submission/scripts/generate-article-pages.mjs
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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`);
|
||||
Reference in New Issue
Block a user