170 lines
7.1 KiB
JavaScript
170 lines
7.1 KiB
JavaScript
import { execFile } from 'node:child_process';
|
|
import { access, readFile } from 'node:fs/promises';
|
|
import { promisify } from 'node:util';
|
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
|
const argumentsAfterCommand = process.argv.slice(2);
|
|
const scriptArgument = argumentsAfterCommand.find((argument) => !argument.startsWith('--'));
|
|
const expectedCountArgument = argumentsAfterCommand.find((argument) => argument.startsWith('--expected-count='));
|
|
const expectedCount = expectedCountArgument
|
|
? Number(expectedCountArgument.slice('--expected-count='.length))
|
|
: 3;
|
|
|
|
if (!scriptArgument || !Number.isInteger(expectedCount) || expectedCount < 1) {
|
|
throw new Error('Usage: node scripts/audit-editorial-draft.mjs <revision-script.mjs> [--expected-count=N]');
|
|
}
|
|
|
|
const scriptPath = isAbsolute(scriptArgument)
|
|
? scriptArgument
|
|
: resolve(process.cwd(), scriptArgument);
|
|
const archive = JSON.parse(await readFile(join(webRoot, 'data', 'articles.json'), 'utf8'));
|
|
const archiveBySlug = new Map(archive.map((article) => [article.slug, article]));
|
|
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,
|
|
];
|
|
|
|
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>|$)/, ''),
|
|
);
|
|
}
|
|
|
|
const cli = await execFileAsync(process.execPath, [scriptPath, '--print-revisions'], {
|
|
cwd: dirname(scriptPath),
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
if (cli.stderr.trim()) {
|
|
throw new Error('CLI wrote to stderr: ' + cli.stderr.trim());
|
|
}
|
|
|
|
let cliRevisions;
|
|
try {
|
|
cliRevisions = JSON.parse(cli.stdout);
|
|
} catch (error) {
|
|
throw new Error('CLI did not print JSON: ' + error.message);
|
|
}
|
|
|
|
const imported = await import(pathToFileURL(scriptPath).href);
|
|
const revisions = imported.revisions;
|
|
|
|
if (!Array.isArray(revisions) || revisions.length !== expectedCount) {
|
|
throw new Error('Module must export exactly ' + expectedCount + ' revision(s)');
|
|
}
|
|
|
|
if (JSON.stringify(cliRevisions) !== JSON.stringify(revisions)) {
|
|
throw new Error('CLI revisions do not match the import-safe module export');
|
|
}
|
|
|
|
const seenSlugs = new Set();
|
|
let failed = false;
|
|
|
|
for (const revision of revisions) {
|
|
const issues = [];
|
|
const content = revision.contentHtml || '';
|
|
const body = bodyText(content);
|
|
const openingParagraphs = [...content.matchAll(/<p>([\s\S]*?)<\/p>/g)]
|
|
.slice(0, 2)
|
|
.map((match) => plainText(match[1]))
|
|
.join(' ');
|
|
const figures = [...content.matchAll(/<figure>([\s\S]*?)<\/figure>/g)].map((match) => match[1]);
|
|
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
|
|
const proseWithoutCode = content
|
|
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
|
|
.replace(/<code>[\s\S]*?<\/code>/g, '');
|
|
|
|
if (!revision.slug || !archiveBySlug.has(revision.slug)) issues.push('slug отсутствует в базовом архиве');
|
|
if (seenSlugs.has(revision.slug)) issues.push('slug повторяется внутри пакета');
|
|
seenSlugs.add(revision.slug);
|
|
if (Object.hasOwn(revision, 'date') || Object.hasOwn(revision, 'author')) {
|
|
issues.push('ревизия не должна менять дату или автора');
|
|
}
|
|
if (body.length < 5000 || body.length > 15000) issues.push('основной текст вне 5 000–15 000 знаков');
|
|
if (revision.readingMinutes < 8) issues.push('меньше 8 минут чтения');
|
|
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('нет точного раздела источников');
|
|
if (!/(проблем|ошиб|симптом|сбой|задач)/i.test(body.slice(0, 800))) {
|
|
issues.push('проблема не названа в начале текста');
|
|
}
|
|
if (!/(цен[аы]|стоимост|издержк|затрат|потер|дороже|\bcost\b)/i.test(openingParagraphs)) {
|
|
issues.push('цена ошибки не названа в первых двух абзацах');
|
|
}
|
|
if (proseWithoutCode.includes('undefined') || proseWithoutCode.includes('[object Object]')) {
|
|
issues.push('в тексте есть след генерации');
|
|
}
|
|
const editorialMetaHit = editorialMetaPatterns.find((pattern) => pattern.test(body));
|
|
if (editorialMetaHit) issues.push('в теле статьи есть внутренняя редакционная мета-лексика: ' + editorialMetaHit);
|
|
|
|
for (const phrase of genericPhrases) {
|
|
if (content.includes(phrase)) issues.push('шаблонный оборот: «' + phrase + '»');
|
|
}
|
|
|
|
for (const figure of figures) {
|
|
const image = figure.match(/<img\b[^>]*>/);
|
|
const alt = image?.[0].match(/\balt="([^"]*)"/);
|
|
if (!image || !alt || alt[1].trim().length < 12) {
|
|
issues.push('у рисунка нет содержательного alt-текста');
|
|
break;
|
|
}
|
|
}
|
|
|
|
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 ' + revision.slug + ': ' + issues.join('; '));
|
|
} else {
|
|
console.log('PASS ' + revision.slug + ': ' + body.length + ' body chars');
|
|
}
|
|
}
|
|
|
|
if (failed) process.exitCode = 1;
|