#!/usr/bin/env node // 글의 문체를 수치로 찍는다. 우아한형제들 5편의 값이 기준선이다. // node scripts/style_profile.mjs // node scripts/style_profile.mjs --baseline 기준선 범위를 다시 계산 import { readFileSync } from 'node:fs'; // 우아한형제들 5편에서 잰 값 (scripts/style_profile.mjs --baseline 으로 재계산) // 우아한형제들 5편 실측(산문만): // avgLen 56.5~66.5 · longRatio 0~.05 · shortRatio .012~.136 // enderKinds 4~6 · connPer100 7.1~25.9 · steerPer100 3.7~11.9 // 아래는 거기에 약간의 여유를 준 값이다. 규칙을 고치면 --baseline으로 다시 잰다. const BASE = { avgLen: { lo: 48, hi: 75, label: '문장 평균 길이(자)' }, longRatio: { lo: 0, hi: 0.08, label: '120자 넘는 문장 비율' }, shortRatio: { lo: 0.01, hi: 0.20, label: '25자 미만 문장 비율' }, enderKinds: { lo: 3, hi: 8, label: '종결어미 종류 수' }, connPer100: { lo: 6, hi: 30, label: '이유 연결어미 / 문장 100개' }, steerPer100: { lo: 0, hi: 16, label: '독자 안내 표현 / 문장 100개' }, // 하한 없음: 강제하면 지도형 문장이 생긴다 engPerSent: { lo: 0, hi: 3.5, label: '문장당 맨몸 영문 낱말' }, // 기준선 0.71~3.14 hangulRatio: { lo: 0.60, hi: 1, label: '한글 비율(식별자 제외)' }, // 기준선 0.64~0.92 }; // 산문만 남긴다. 코드블록·표·제목·목록·링크주소·인라인코드는 문장이 아니다. function strip(src) { let t = src; // 짝이 맞는 코드펜스 제거 t = t.replace(/```[\s\S]*?```/g, '\n'); // 짝이 안 맞는 펜스(구획을 중간에서 잘랐을 때): 남은 펜스부터 끝까지 버린다 const stray = t.indexOf('```'); if (stray >= 0) t = t.slice(0, stray); return t .replace(/^\s*\|.*$/gm, '') // 표 .replace(/^\s*#{1,6}\s.*$/gm, '') // 제목 .replace(/^\s*[-*+]\s.*$/gm, '') // 목록 .replace(/^\s*\d+[.)]\s.*$/gm, '') // 번호 목록 .replace(/^\s*/gm, '') // 주석 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // 링크는 글자만 남기고 주소 제거 .replace(/`[^`\n]*`/g, 'X') // 인라인 코드는 한 글자로 .replace(/[*_>]/g, ''); } export function profile(raw) { const _raw = raw; const text = strip(raw); const sents = text.split(/(?<=[.?!])\s+|\n{2,}/) .map(s => s.replace(/\s+/g, ' ').trim()) .filter(s => s.length > 4 && /[가-힣]/.test(s)); const n = sents.length || 1; const lens = sents.map(s => s.length); const avgLen = lens.reduce((a, b) => a + b, 0) / n; const kinds = new Set(); for (const s of sents) { if (/(습니다|았습니다|었습니다)[.!]?$/.test(s)) kinds.add('습니다'); if (/입니다[.!]?$/.test(s)) kinds.add('입니다'); // 한다체 안에도 어미는 여럿이다. 한 덩어리로 세면 잘 쓴 한다체 글이 1점을 받고, // 점수를 올릴 길이 물음·청유밖에 남지 않아 없던 화자가 생긴다. 실제로 그렇게 됐다. if (/(한다|된다|만든다|넘긴다|받는다)[.!]?$/.test(s)) kinds.add('한다'); if (/(했다|였다|됐다|되었다|았다|었다)[.!]?$/.test(s)) kinds.add('했다'); if (/(아니다|없다|같다|다르다|이다)[.!]?$/.test(s)) kinds.add('이다'); if (/(못한다|않는다|않았다|못했다)[.!]?$/.test(s)) kinds.add('부정'); if (/(뿐이다|때문이다|까지다|것이다)[.!]?$/.test(s)) kinds.add('설명'); if (/(겠습니다|보겠습니다)[.!]?$/.test(s)) kinds.add('겠습니다'); if (/\?$/.test(s)) kinds.add('물음'); if (/(봅시다|보자|맙시다|주세요)[.!]?$/.test(s)) kinds.add('청유'); if (/(네요|는데요|거든요|어요|아요)[.!]?$/.test(s)) kinds.add('해요체'); if (/(합니다만|지만)[.!]?$/.test(s)) kinds.add('지만'); } const conn = (text.match(/(기 때문에|다 보니|으므로|이므로|해서|어서|아서|는데|으니|니까)/g) || []).length; const steer = (text.match(/(살펴보|알아보|파보|확인해\s*봅|정리해\s*보|소개해\s*보|짚어\s*보|이번에는|먼저|나중에|다루겠|보겠습니다|공유)/g) || []).length; // 백틱 안(식별자)은 빼고, 맨몸으로 쓰인 영문만 센다 const bare = raw .replace(/```[\s\S]*?```/g, ' ') .replace(//g, ' ') // HTML 주석(techviz 등)은 산문이 아니다 .replace(/<\/?[a-zA-Z][^>]*>/g, ' ') //
, 같은 태그 .replace(/^\s*\|.*$/gm, ' ') // 표 .replace(/`[^`\n]*`/g, ' ') .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1'); const PROPER = /^(Redis|Nginx|Hibernate|Spring|Actuator|Keycloak|PostgreSQL|Java|Gradle|Lettuce|Kubernetes|Docker|OAuth|Sentinel|Lua|SQL|API|TTL|ACL|TLS|HTTP|JSON|YAML|CI|AI|DB|ID|URL)$/i; const engWords = (bare.match(/[A-Za-z][A-Za-z0-9_.-]{1,}/g) || []).filter(w => !PROPER.test(w)); // 한글 비율은 글쓴이가 고를 수 있는 산문만 본다. 백틱 안 식별자는 보호 구간이라 제외한다. const hangul = (bare.match(/[가-힣]/g) || []).length; const letters = (bare.match(/[가-힣A-Za-z]/g) || []).length || 1; return { sentences: n, engPerSent: +(engWords.length / n).toFixed(2), hangulRatio: +(hangul / letters).toFixed(2), avgLen: +avgLen.toFixed(1), longRatio: +(lens.filter(l => l > 120).length / n).toFixed(3), shortRatio: +(lens.filter(l => l < 25).length / n).toFixed(3), enderKinds: kinds.size, connPer100: +((conn / n) * 100).toFixed(1), steerPer100: +((steer / n) * 100).toFixed(1), }; } const args = process.argv.slice(2); if (args[0] === '--baseline') { const rows = args.slice(1).map(f => ({ f: f.replace(/^.*\//, ''), p: profile(readFileSync(f, 'utf8')) })); for (const k of Object.keys(BASE)) { const vals = rows.map(r => r.p[k]); console.log(`${k.padEnd(12)} min=${Math.min(...vals)} max=${Math.max(...vals)}`); } console.table(rows.map(r => ({ file: r.f, ...r.p }))); process.exit(0); } let bad = 0; const rows = []; for (const f of args) { const p = profile(readFileSync(f, 'utf8')); const flags = []; for (const [k, b] of Object.entries(BASE)) { if (p[k] < b.lo || p[k] > b.hi) { flags.push(`${b.label}=${p[k]} (기준 ${b.lo}~${b.hi})`); bad++; } } rows.push({ file: f.replace(/^.*\//, ''), ...p, 벗어남: flags.length }); if (flags.length) console.log(`· ${f.replace(/^.*\//, '')}\n ` + flags.join('\n ')); } console.table(rows); process.exit(bad === 0 ? 0 : 1);