Files
progcode/web/scripts/audit-quality-batch.mjs
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

151 lines
6.1 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';
import { cleanReaderHtml } from '../lib/editorial-content.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-articles')
? archive.map((article) => article.slug)
: 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 | --all-articles');
}
const genericPhrases = [
'У этой модели нет магической силы',
'Материалы для проверки',
'Если держать этот порядок, решение остаётся понятным',
'В современном мире',
'очень важно',
'следует отметить',
'просто нужно',
'нужно понимать, что',
];
const editorialMetaPatterns = [
/synthetic-plan-hand-off/i,
/productionEffect/i,
/future-only/i,
/plan\/scenario/i,
/source cutoff/i,
/editorial date/i,
/planDate/i,
/not-collected/i,
/not-attempted/i,
/future owner/i,
/author trajectory/i,
/развитие автора/i,
];
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 = cleanReaderHtml(article.contentHtml);
const body = bodyText(content);
const imageSources = [...content.matchAll(/<img[^>]+\bsrc=(['"])(.*?)\1/gi)].map((match) => match[2]);
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\b[^>]*\bhref=(['"])https?:\/\/[^'"]+\1/gi) < 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('в тексте есть след генерации');
}
const editorialMetaHit = editorialMetaPatterns.find((pattern) => pattern.test(body));
if (editorialMetaHit) issues.push('в теле статьи есть внутренняя редакционная мета-лексика: ' + editorialMetaHit);
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=(['"])(.*?)\1/i);
if (!image || !alt || alt[2].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;