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'; import { cleanReaderHtml } from '../lib/editorial-content.mjs'; 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 [--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>[\s\S]*?(?=

|$)/, ''), ); } 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 = cleanReaderHtml(revision.contentHtml || ''); const body = bodyText(content); const openingParagraphs = [...content.matchAll(/

([\s\S]*?)<\/p>/g)] .slice(0, 2) .map((match) => plainText(match[1])) .join(' '); const figures = [...content.matchAll(/

([\s\S]*?)<\/figure>/g)].map((match) => match[1]); const imageSources = [...content.matchAll(/]+src="([^"]+)"/g)].map((match) => match[1]); const proseWithoutCode = content .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
    .replace(/[\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, /

/g) < 5) issues.push('меньше пяти смысловых разделов'); if (count(content, /
/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения'); if (count(content, /
/g) < 1) issues.push('у рисунка нет подписи'); if (count(content, //g) < 1 || count(content, //g) < 1) issues.push('нет доступной таблицы'); if (count(content, /
/g) < 1) issues.push('нет воспроизводимого примера');
  if (count(content, /
    /g) < 1) issues.push('нет последовательности действий'); if (count(content, / 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;