From 24ee504e5e7c02425682927eae502ecc8dd16ec0 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:21:26 +0330 Subject: [PATCH] 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. --- .../scripts/generate-article-pages.mjs | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 app/festival-submission/scripts/generate-article-pages.mjs diff --git a/app/festival-submission/scripts/generate-article-pages.mjs b/app/festival-submission/scripts/generate-article-pages.mjs new file mode 100644 index 0000000..6f155d0 --- /dev/null +++ b/app/festival-submission/scripts/generate-article-pages.mjs @@ -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, '>'); +} + +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>/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]) => ``), + ...articleCitationUrls(article).map((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 = html.replace(/[^<]*<\/title>/, `<title>${title}`); + html = html.replace(/(/, `\n `); + html = html.replace(/(`); + 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 || `

${esc(cleanText(article.summary || rawDesc))}

`, + 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`);