132 lines
5.3 KiB
JavaScript
132 lines
5.3 KiB
JavaScript
import { access, readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { editorialRevisions } from '../data/editorial-revisions.mjs';
|
|
|
|
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
|
const articlesPath = join(webRoot, 'data', 'articles.json');
|
|
const archivedArticles = JSON.parse(await readFile(articlesPath, 'utf8'));
|
|
const revisionBySlug = new Map(
|
|
editorialRevisions.map((revision) => [revision.slug, revision]),
|
|
);
|
|
const archive = archivedArticles.map((article) => ({
|
|
...article,
|
|
...revisionBySlug.get(article.slug),
|
|
}));
|
|
const requestedSlugs = process.argv.slice(2);
|
|
const slugs = requestedSlugs.includes('--all-editorial')
|
|
? archive.filter((article) => article.slug.startsWith('editorial-')).map((article) => article.slug)
|
|
: requestedSlugs;
|
|
|
|
if (slugs.length === 0) {
|
|
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug] | --all-editorial');
|
|
}
|
|
const genericPhrases = [
|
|
'У этой модели нет магической силы',
|
|
'Материалы для проверки',
|
|
'Если держать этот порядок, решение остаётся понятным',
|
|
'В современном мире',
|
|
'очень важно',
|
|
'следует отметить',
|
|
'просто нужно',
|
|
'нужно понимать, что',
|
|
];
|
|
const MIN_BODY_CHARS = 5000;
|
|
const MAX_BODY_CHARS = 15000;
|
|
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();
|
|
}
|
|
|
|
function bodyText(content) {
|
|
return plainText(
|
|
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
|
|
);
|
|
}
|
|
|
|
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 body = bodyText(content);
|
|
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
|
|
const figures = [...content.matchAll(/<figure>([\s\S]*?)<\/figure>/g)].map((match) => match[1]);
|
|
|
|
if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения');
|
|
if (body.length < MIN_BODY_CHARS) {
|
|
issues.push('меньше ' + MIN_BODY_CHARS + ' знаков основного текста');
|
|
}
|
|
if (body.length > MAX_BODY_CHARS) {
|
|
issues.push('больше ' + MAX_BODY_CHARS + ' знаков основного текста');
|
|
}
|
|
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, /<ol>/g) < 1) issues.push('нет последовательности проверки или действий');
|
|
if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников');
|
|
if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет отдельного раздела с источниками');
|
|
const proseWithoutCode = content
|
|
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
|
|
.replace(/<code>[\s\S]*?<\/code>/g, '');
|
|
if (proseWithoutCode.includes('undefined') || proseWithoutCode.includes('[object Object]')) {
|
|
issues.push('в тексте есть след генерации');
|
|
}
|
|
if (!/(проблем|ошиб|симптом|сбой|задач)/i.test(body.slice(0, 800))) {
|
|
issues.push('проблема не названа в начале текста');
|
|
}
|
|
|
|
for (const figure of figures) {
|
|
const image = figure.match(/<img\b[^>]*>/);
|
|
const alt = image?.[0].match(/\balt="([^"]*)"/);
|
|
if (!image || !alt || alt[1].trim() === '') {
|
|
issues.push('у рисунка нет содержательного alt-текста');
|
|
break;
|
|
}
|
|
}
|
|
|
|
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
|
|
+ ': ' + body.length + ' body chars, '
|
|
+ count(content, /<figure>/g) + ' figure, '
|
|
+ count(content, /<table>/g) + ' table, '
|
|
+ count(content, /<pre><code>/g) + ' code example',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (failed) process.exitCode = 1;
|