humanizer-lint 0.0.0-stage → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +529 -3
- package/SKILL.md +116 -0
- package/package.json +26 -6
- package/profiles/default-human.json +36 -0
- package/rules/en-slop.json +49 -0
- package/rules/id-slop.json +57 -0
- package/scripts/analyze.mjs +87 -0
- package/scripts/core/analyzer-core.mjs +352 -0
- package/scripts/fingerprint.mjs +127 -0
- package/scripts/utils/metrics-core.mjs +84 -0
- package/scripts/utils/metrics.mjs +28 -0
- package/scripts/utils/sanitizer.mjs +48 -0
- package/scripts/utils/tokenizer.mjs +82 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs, realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { sanitizeMarkdown } from './utils/sanitizer.mjs';
|
|
7
|
+
import { calculateMetrics, mean, punctuationRates } from './utils/metrics.mjs';
|
|
8
|
+
import { tokenizeText } from './utils/tokenizer.mjs';
|
|
9
|
+
|
|
10
|
+
const { mkdir, readFile, readdir, rename, stat, writeFile } = fs;
|
|
11
|
+
|
|
12
|
+
function round(value, places = 4) {
|
|
13
|
+
const factor = 10 ** places;
|
|
14
|
+
return Math.round(value * factor) / factor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function averageObjects(objects) {
|
|
18
|
+
const keys = [...new Set(objects.flatMap((object) => Object.keys(object)))];
|
|
19
|
+
return Object.fromEntries(keys.map((key) => [key, round(mean(objects.map((object) => object[key] ?? 0)))]));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function fingerprintSamples(files, options = {}) {
|
|
23
|
+
if (!Array.isArray(files) || files.length < 1 || files.length > 5) throw new Error('Fingerprint requires 1 to 5 sample files');
|
|
24
|
+
const samples = [];
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const raw = await readFile(file, 'utf8');
|
|
27
|
+
const sanitized = sanitizeMarkdown(raw);
|
|
28
|
+
const tokenized = tokenizeText(sanitized, 'auto');
|
|
29
|
+
const metrics = calculateMetrics(tokenized, sanitized);
|
|
30
|
+
if (metrics.wordCount < 20 || metrics.sentenceCount < 3) throw new Error(`Sample is too short: ${file}`);
|
|
31
|
+
samples.push({
|
|
32
|
+
...metrics,
|
|
33
|
+
averageParagraphSentenceCount: mean(tokenized.paragraphs.map((paragraph) => paragraph.sentences.length)),
|
|
34
|
+
punctuationRates: punctuationRates(sanitized, metrics.wordCount),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const punctuation = averageObjects(samples.map((sample) => sample.punctuationRates));
|
|
39
|
+
const profile = {
|
|
40
|
+
name: options.name ?? 'custom-voice',
|
|
41
|
+
version: 1,
|
|
42
|
+
sampleCount: samples.length,
|
|
43
|
+
language: options.language ?? 'auto',
|
|
44
|
+
metrics: {
|
|
45
|
+
meanSentenceLength: round(mean(samples.map((sample) => sample.meanSentenceLength))),
|
|
46
|
+
stdDev: round(mean(samples.map((sample) => sample.stdDev))),
|
|
47
|
+
burstinessCV: round(mean(samples.map((sample) => sample.burstinessCV))),
|
|
48
|
+
adjacentDelta: round(mean(samples.map((sample) => sample.adjacentDelta))),
|
|
49
|
+
mattr: round(mean(samples.map((sample) => sample.mattr))),
|
|
50
|
+
averageParagraphSentenceCount: round(mean(samples.map((sample) => sample.averageParagraphSentenceCount))),
|
|
51
|
+
punctuationRates: punctuation,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (options.output) {
|
|
56
|
+
const output = path.resolve(options.output);
|
|
57
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
58
|
+
const temporary = `${output}.tmp-${process.pid}`;
|
|
59
|
+
await writeFile(temporary, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
|
|
60
|
+
await rename(temporary, output);
|
|
61
|
+
}
|
|
62
|
+
return profile;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function collectInput(input) {
|
|
66
|
+
const info = await stat(input);
|
|
67
|
+
if (info.isFile()) return [path.resolve(input)];
|
|
68
|
+
if (!info.isDirectory()) throw new Error('--input must be a file or directory');
|
|
69
|
+
const entries = await readdir(input, { withFileTypes: true });
|
|
70
|
+
return entries
|
|
71
|
+
.filter((entry) => entry.isFile() && /\.(?:md|txt)$/i.test(entry.name))
|
|
72
|
+
.map((entry) => path.resolve(input, entry.name))
|
|
73
|
+
.sort()
|
|
74
|
+
.slice(0, 5);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runFingerprintCli(argv, io = {}) {
|
|
78
|
+
const stdout = io.stdout ?? ((value) => process.stdout.write(value));
|
|
79
|
+
const stderr = io.stderr ?? ((value) => process.stderr.write(value));
|
|
80
|
+
try {
|
|
81
|
+
const { values } = parseArgs({
|
|
82
|
+
args: argv,
|
|
83
|
+
options: {
|
|
84
|
+
input: { type: 'string' },
|
|
85
|
+
output: { type: 'string' },
|
|
86
|
+
name: { type: 'string' },
|
|
87
|
+
lang: { type: 'string', default: 'auto' },
|
|
88
|
+
help: { type: 'boolean' },
|
|
89
|
+
},
|
|
90
|
+
strict: true,
|
|
91
|
+
allowPositionals: false,
|
|
92
|
+
});
|
|
93
|
+
if (values.help) {
|
|
94
|
+
stdout('Usage: humanizer-fingerprint --input <file-or-dir> --output <profile.json> [--name <name>] [--lang id|en|auto]\n');
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
if (!values.input || !values.output) throw new Error('--input and --output are required');
|
|
98
|
+
if (!['id', 'en', 'auto'].includes(values.lang)) throw new Error('--lang must be id, en, or auto');
|
|
99
|
+
const files = await collectInput(values.input);
|
|
100
|
+
if (!files.length) throw new Error('No .txt or .md samples found');
|
|
101
|
+
const profile = await fingerprintSamples(files, { output: values.output, name: values.name, language: values.lang });
|
|
102
|
+
stdout(`${JSON.stringify(profile, null, 2)}\n`);
|
|
103
|
+
return 0;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
stderr(`ERROR: ${error.message}\n`);
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function currentModulePath() {
|
|
111
|
+
let value = decodeURIComponent(new URL(import.meta.url).pathname);
|
|
112
|
+
if (/^\/[A-Za-z]:/.test(value)) value = value.slice(1);
|
|
113
|
+
return path.resolve(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isMainModule() {
|
|
117
|
+
if (!process.argv[1]) return false;
|
|
118
|
+
try {
|
|
119
|
+
return realpathSync(process.argv[1]) === realpathSync(currentModulePath());
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isMainModule()) {
|
|
126
|
+
process.exitCode = await runFingerprintCli(process.argv.slice(2));
|
|
127
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function mean(values) {
|
|
2
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function standardDeviation(values) {
|
|
6
|
+
if (!values.length) return 0;
|
|
7
|
+
const average = mean(values);
|
|
8
|
+
return Math.sqrt(mean(values.map((value) => (value - average) ** 2)));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function coefficientOfVariation(values) {
|
|
12
|
+
const average = mean(values);
|
|
13
|
+
return average ? standardDeviation(values) / average : 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function adjacentDelta(values) {
|
|
17
|
+
if (values.length < 2) return 0;
|
|
18
|
+
let total = 0;
|
|
19
|
+
for (let index = 1; index < values.length; index += 1) total += Math.abs(values[index] - values[index - 1]);
|
|
20
|
+
return total / (values.length - 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mattr(words, requestedWindow) {
|
|
24
|
+
if (!words.length) return 0;
|
|
25
|
+
const window = Math.min(words.length, requestedWindow ?? Math.min(50, Math.max(10, Math.floor(words.length / 2))));
|
|
26
|
+
if (words.length <= window) return new Set(words).size / words.length;
|
|
27
|
+
let total = 0;
|
|
28
|
+
let windows = 0;
|
|
29
|
+
for (let index = 0; index <= words.length - window; index += 1) {
|
|
30
|
+
total += new Set(words.slice(index, index + window)).size / window;
|
|
31
|
+
windows += 1;
|
|
32
|
+
}
|
|
33
|
+
return total / windows;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function punctuationRates(text, wordCount) {
|
|
37
|
+
const denominator = wordCount || 1;
|
|
38
|
+
const count = (pattern) => (String(text ?? '').match(pattern) ?? []).length / denominator;
|
|
39
|
+
return {
|
|
40
|
+
comma: count(/,/g),
|
|
41
|
+
semicolon: count(/;/g),
|
|
42
|
+
colon: count(/:/g),
|
|
43
|
+
question: count(/\?/g),
|
|
44
|
+
exclamation: count(/!/g),
|
|
45
|
+
parentheses: count(/[()]/g) / 2,
|
|
46
|
+
dash: count(/—|\s--\s/g),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function calculateMetricsFromCompressionRatio(tokenized, compressionRatio) {
|
|
51
|
+
const lengths = tokenized.sentences.map((sentence) => sentence.wordCount);
|
|
52
|
+
const wordCount = tokenized.words.length;
|
|
53
|
+
const sentenceCount = lengths.length;
|
|
54
|
+
return {
|
|
55
|
+
wordCount,
|
|
56
|
+
sentenceCount,
|
|
57
|
+
meanSentenceLength: mean(lengths),
|
|
58
|
+
stdDev: standardDeviation(lengths),
|
|
59
|
+
burstinessCV: coefficientOfVariation(lengths),
|
|
60
|
+
adjacentDelta: adjacentDelta(lengths),
|
|
61
|
+
shortSentenceRatio: sentenceCount ? lengths.filter((length) => length <= 6).length / sentenceCount : 0,
|
|
62
|
+
longSentenceRatio: sentenceCount ? lengths.filter((length) => length >= 26).length / sentenceCount : 0,
|
|
63
|
+
mattr: mattr(tokenized.words),
|
|
64
|
+
compressionRatio,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createMetrics({ encode, deflateRaw }) {
|
|
69
|
+
if (typeof encode !== 'function' || typeof deflateRaw !== 'function') {
|
|
70
|
+
throw new TypeError('createMetrics requires encode and deflateRaw functions');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const compressionRatio = (text) => {
|
|
74
|
+
const bytes = encode(String(text ?? '').trim());
|
|
75
|
+
return bytes.length ? deflateRaw(bytes).length / bytes.length : 0;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const calculateMetrics = (tokenized, normalizedText) => calculateMetricsFromCompressionRatio(
|
|
79
|
+
tokenized,
|
|
80
|
+
compressionRatio(normalizedText),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return { compressionRatio, calculateMetrics };
|
|
84
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { deflateRawSync } from 'node:zlib';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
adjacentDelta,
|
|
5
|
+
coefficientOfVariation,
|
|
6
|
+
createMetrics,
|
|
7
|
+
mattr,
|
|
8
|
+
mean,
|
|
9
|
+
punctuationRates,
|
|
10
|
+
standardDeviation,
|
|
11
|
+
} from './metrics-core.mjs';
|
|
12
|
+
|
|
13
|
+
const metrics = createMetrics({
|
|
14
|
+
encode: (text) => Buffer.from(text, 'utf8'),
|
|
15
|
+
deflateRaw: (bytes) => deflateRawSync(bytes),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
adjacentDelta,
|
|
20
|
+
coefficientOfVariation,
|
|
21
|
+
mattr,
|
|
22
|
+
mean,
|
|
23
|
+
punctuationRates,
|
|
24
|
+
standardDeviation,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const compressionRatio = metrics.compressionRatio;
|
|
28
|
+
export const calculateMetrics = metrics.calculateMetrics;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const ENTITY = 'TOKEN_ENTITY';
|
|
2
|
+
|
|
3
|
+
function isCompleteListItem(text) {
|
|
4
|
+
const words = text.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
5
|
+
return words.length >= 5 && /[.!?]["')\]]?$/.test(text.trim());
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sanitizeMarkdown(input) {
|
|
9
|
+
let text = String(input ?? '').replace(/\r\n?/g, '\n');
|
|
10
|
+
text = text.replace(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, '');
|
|
11
|
+
text = text.replace(/^(?:```|~~~)[^\n]*\n[\s\S]*?^(?:```|~~~)\s*$/gm, '\n');
|
|
12
|
+
text = text.replace(/\[([^\]]+)\]\(https?:\/\/[^\s)]+\)/gi, '$1');
|
|
13
|
+
text = text.replace(/`[^`\n]+`/g, ENTITY);
|
|
14
|
+
text = text.replace(/https?:\/\/[^\s)]+/gi, (match) => {
|
|
15
|
+
const trailing = match.match(/[.!?,;:]+$/u)?.[0] ?? '';
|
|
16
|
+
return `${ENTITY}${trailing}`;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const sourceLines = text.split('\n');
|
|
20
|
+
const lines = [];
|
|
21
|
+
for (let index = 0; index < sourceLines.length; index += 1) {
|
|
22
|
+
const rawLine = sourceLines[index];
|
|
23
|
+
if (/^\s*#{1,6}\s+/.test(rawLine)) {
|
|
24
|
+
lines.push('');
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (/^\s*\|.*\|\s*$/.test(rawLine)) {
|
|
28
|
+
lines.push('');
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let line = rawLine.replace(/^\s*>\s?/, '');
|
|
33
|
+
const list = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.*)$/);
|
|
34
|
+
if (list) {
|
|
35
|
+
let item = list[1].trim();
|
|
36
|
+
let cursor = index + 1;
|
|
37
|
+
while (cursor < sourceLines.length && /^\s{2,}\S/.test(sourceLines[cursor])) {
|
|
38
|
+
item += ` ${sourceLines[cursor].replace(/^\s*>\s?/, '').trim()}`;
|
|
39
|
+
cursor += 1;
|
|
40
|
+
}
|
|
41
|
+
index = cursor - 1;
|
|
42
|
+
line = isCompleteListItem(item) ? item : '';
|
|
43
|
+
}
|
|
44
|
+
lines.push(line.trimEnd());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
48
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const ABBREVIATIONS = {
|
|
2
|
+
id: ['dll.', 'dsb.', 'dst.', 'dkk.', 'hlm.', 'no.', 'dr.', 'prof.', 'ir.', 'hj.', 'pt.', 'cv.', 'tbk.', 'rp.', 'tgl.', 's.d.', 'a.n.', 'u.p.'],
|
|
3
|
+
en: ['mr.', 'mrs.', 'ms.', 'dr.', 'prof.', 'sr.', 'jr.', 'vs.', 'etc.', 'e.g.', 'i.e.', 'fig.', 'inc.', 'ltd.', 'co.', 'st.'],
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const DOT = '\uE000';
|
|
7
|
+
const DECIMAL = '\uE001';
|
|
8
|
+
const ELLIPSIS = '\uE002';
|
|
9
|
+
|
|
10
|
+
function escapeRegExp(value) {
|
|
11
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function protectAbbreviations(text, language) {
|
|
15
|
+
const items = language === 'auto'
|
|
16
|
+
? [...new Set([...ABBREVIATIONS.id, ...ABBREVIATIONS.en])]
|
|
17
|
+
: ABBREVIATIONS[language] ?? ABBREVIATIONS.en;
|
|
18
|
+
let protectedText = text
|
|
19
|
+
.replace(/\.{2,}/g, ELLIPSIS)
|
|
20
|
+
.replace(/(\d)\.(\d)/g, `$1${DECIMAL}$2`);
|
|
21
|
+
for (const abbreviation of items.sort((a, b) => b.length - a.length)) {
|
|
22
|
+
protectedText = protectedText.replace(
|
|
23
|
+
new RegExp(`\\b${escapeRegExp(abbreviation)}`, 'gi'),
|
|
24
|
+
(match) => match.replaceAll('.', DOT),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return protectedText;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function tokenizeWords(text) {
|
|
31
|
+
const normalized = String(text ?? '').replace(/—|--/g, ' ');
|
|
32
|
+
return (normalized.toLowerCase().match(/token_entity|[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)?(?:-[\p{L}\p{N}]+)*/gu) ?? []);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function tokenizeText(input, language = 'auto') {
|
|
36
|
+
const paragraphs = [];
|
|
37
|
+
const sentences = [];
|
|
38
|
+
const rawParagraphs = String(input ?? '').replace(/\r\n?/g, '\n').split(/\n\s*\n+/).map((value) => value.trim()).filter(Boolean);
|
|
39
|
+
|
|
40
|
+
for (const [paragraphOffset, rawParagraph] of rawParagraphs.entries()) {
|
|
41
|
+
const protectedText = protectAbbreviations(rawParagraph.replace(/\s+/g, ' '), language);
|
|
42
|
+
const markedText = protectedText.replace(/([.!?](?:["')\]”’]+)?)\s+/gu, '$1\n');
|
|
43
|
+
const parts = markedText.split('\n').map((value) => value
|
|
44
|
+
.replaceAll(DOT, '.')
|
|
45
|
+
.replaceAll(DECIMAL, '.')
|
|
46
|
+
.replaceAll(ELLIPSIS, '...')
|
|
47
|
+
.trim()).filter(Boolean);
|
|
48
|
+
const paragraphSentences = [];
|
|
49
|
+
|
|
50
|
+
for (const text of parts) {
|
|
51
|
+
const words = tokenizeWords(text);
|
|
52
|
+
if (!words.length) continue;
|
|
53
|
+
const sentence = {
|
|
54
|
+
text,
|
|
55
|
+
words,
|
|
56
|
+
wordCount: words.length,
|
|
57
|
+
paragraphIndex: paragraphOffset + 1,
|
|
58
|
+
sentenceIndex: sentences.length + 1,
|
|
59
|
+
sentenceInParagraph: paragraphSentences.length + 1,
|
|
60
|
+
};
|
|
61
|
+
paragraphSentences.push(sentence);
|
|
62
|
+
sentences.push(sentence);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (paragraphSentences.length) {
|
|
66
|
+
paragraphs.push({
|
|
67
|
+
text: rawParagraph,
|
|
68
|
+
paragraphIndex: paragraphOffset + 1,
|
|
69
|
+
sentences: paragraphSentences,
|
|
70
|
+
words: paragraphSentences.flatMap((sentence) => sentence.words),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
paragraphs,
|
|
77
|
+
sentences,
|
|
78
|
+
words: sentences.flatMap((sentence) => sentence.words),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { ABBREVIATIONS };
|