This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const articlesPath = join(webRoot, 'data', 'articles.json');
|
||||
const slugs = process.argv.slice(2);
|
||||
|
||||
if (slugs.length === 0) {
|
||||
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug]');
|
||||
}
|
||||
|
||||
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
|
||||
const genericPhrases = [
|
||||
'У этой модели нет магической силы',
|
||||
'Материалы для проверки',
|
||||
'Если держать этот порядок, решение остаётся понятным',
|
||||
];
|
||||
let failed = false;
|
||||
|
||||
function count(content, expression) {
|
||||
return (content.match(expression) || []).length;
|
||||
}
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
for (const slug of slugs) {
|
||||
const article = archive.find((candidate) => candidate.slug === slug);
|
||||
const issues = [];
|
||||
|
||||
if (!article) {
|
||||
console.error('FAIL ' + slug + ': статья не найдена');
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = article.contentHtml;
|
||||
const text = plainText(content);
|
||||
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
|
||||
|
||||
if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения');
|
||||
if (text.length < 4800) issues.push('меньше 4800 символов осмысленного текста');
|
||||
if (count(content, /<h2>/g) < 5) issues.push('меньше пяти смысловых разделов');
|
||||
if (count(content, /<figure>/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения');
|
||||
if (count(content, /<figcaption>/g) < 1) issues.push('у иллюстрации нет подписи');
|
||||
if (count(content, /<table>/g) < 1 || count(content, /<thead>/g) < 1) issues.push('нет доступной таблицы');
|
||||
if (count(content, /<pre><code>/g) < 1) issues.push('нет воспроизводимого примера');
|
||||
if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников');
|
||||
if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет отдельного раздела с источниками');
|
||||
if (content.includes('undefined') || content.includes('[object Object]')) issues.push('в тексте есть след генерации');
|
||||
|
||||
for (const phrase of genericPhrases) {
|
||||
if (content.includes(phrase)) issues.push('обнаружен шаблонный оборот: «' + phrase + '»');
|
||||
}
|
||||
|
||||
for (const source of imageSources.filter((value) => value.startsWith('/'))) {
|
||||
try {
|
||||
await access(join(webRoot, 'public', source));
|
||||
} catch {
|
||||
issues.push('не найден локальный visual asset: ' + source);
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
failed = true;
|
||||
console.error('FAIL ' + slug + ': ' + issues.join('; '));
|
||||
} else {
|
||||
console.log(
|
||||
'PASS ' + slug
|
||||
+ ': ' + text.length + ' chars, '
|
||||
+ count(content, /<figure>/g) + ' figure, '
|
||||
+ count(content, /<table>/g) + ' table, '
|
||||
+ count(content, /<pre><code>/g) + ' code example',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exitCode = 1;
|
||||
Reference in New Issue
Block a user