@v1nvn/readability 0.44.0 → 0.45.1
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/README.md +3 -3
- package/package.json +7 -9
- package/src/cli.js +99 -0
- package/src/cli.ts +134 -0
- package/src/config.js +29 -0
- package/src/config.ts +42 -0
- package/src/dev.js +55 -0
- package/src/dev.ts +77 -0
- package/src/errors.js +16 -0
- package/src/errors.ts +19 -0
- package/src/handler.js +4 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.js +57 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.js +23 -0
- package/src/index.ts +27 -0
- package/src/logger.js +2 -0
- package/src/logger.ts +3 -0
- package/src/output/format.js +92 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.js +14 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.js +49 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.js +111 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.js +18 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.js +45 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.js +165 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.js +227 -0
- package/src/resources.ts +317 -0
- package/src/sampling.js +45 -0
- package/src/sampling.ts +74 -0
- package/src/server.js +73 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/schemas.ts +442 -0
- package/src/tools/suggest-preset.js +504 -0
- package/src/tools/suggest-preset.ts +708 -0
- package/src/types/jsdom.d.ts +15 -0
- package/src/types/turndown-plugin-gfm.d.ts +11 -0
- package/dist/assets/cli-Dfjg7dOQ.js +0 -76
- package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
- package/dist/assets/extract-CdDp-KwX.js +0 -2880
- package/dist/assets/extract-CdDp-KwX.js.map +0 -1
- package/dist/index.js +0 -2267
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const TOKEN_ESTIMATOR = 'chars/4';
|
|
2
|
+
|
|
3
|
+
export function estimateTokens(textContent: string): {
|
|
4
|
+
estimator: string;
|
|
5
|
+
tokenEstimate: number;
|
|
6
|
+
} {
|
|
7
|
+
return {
|
|
8
|
+
tokenEstimate: Math.round(textContent.length / 4),
|
|
9
|
+
estimator: TOKEN_ESTIMATOR,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function countWords(text: string): number {
|
|
14
|
+
return (text.match(/\S+/g) ?? []).length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function nonEmpty(value: string | undefined): string | undefined {
|
|
18
|
+
return value?.trim() ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TextMetrics {
|
|
22
|
+
readonly estimator: string;
|
|
23
|
+
readonly readingTimeMin: number;
|
|
24
|
+
readonly tokenEstimate: number;
|
|
25
|
+
readonly wordCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function computeTextMetrics(
|
|
29
|
+
text: string,
|
|
30
|
+
wordsPerMinute: number,
|
|
31
|
+
): TextMetrics {
|
|
32
|
+
const wordCount = countWords(text);
|
|
33
|
+
const readingTimeMin =
|
|
34
|
+
wordCount === 0 ? 0 : Math.max(1, Math.round(wordCount / wordsPerMinute));
|
|
35
|
+
return { wordCount, readingTimeMin, ...estimateTokens(text) };
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { hardSplitLines, parseBlocks } from "./markdown.js";
|
|
2
|
+
const TRUNCATION_MARKER = '\n\n…[truncated]';
|
|
3
|
+
// Cuts at a top-level block boundary. A fenced code block is a single mdast
|
|
4
|
+
// node, so a boundary cut never lands inside one — no open-fence rollback is
|
|
5
|
+
// needed. The kept span is sliced from the original string so the payload is a
|
|
6
|
+
// byte-for-byte prefix of the Turndown output.
|
|
7
|
+
export function truncateMarkdown(markdown, maxChars) {
|
|
8
|
+
if (markdown.length <= maxChars) {
|
|
9
|
+
return { text: markdown, truncated: false };
|
|
10
|
+
}
|
|
11
|
+
const blocks = parseBlocks(markdown);
|
|
12
|
+
let start = -1;
|
|
13
|
+
let end = -1;
|
|
14
|
+
let truncated = false;
|
|
15
|
+
for (const block of blocks) {
|
|
16
|
+
const from = start === -1 ? block.start : start;
|
|
17
|
+
if (block.end - from > maxChars) {
|
|
18
|
+
// First non-code block alone overflows: take a line-aware prefix instead
|
|
19
|
+
// of dropping everything. A code block is never split — a half-open fence
|
|
20
|
+
// is unrecoverable — so an oversized first code block stays dropped.
|
|
21
|
+
if (start === -1 && block.kind !== 'code') {
|
|
22
|
+
const firstPiece = hardSplitLines(markdown.slice(block.start, block.end), maxChars)[0] ??
|
|
23
|
+
'';
|
|
24
|
+
return {
|
|
25
|
+
text: firstPiece.replace(/\s+$/, '') + TRUNCATION_MARKER,
|
|
26
|
+
truncated: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
truncated = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (start === -1) {
|
|
33
|
+
start = block.start;
|
|
34
|
+
}
|
|
35
|
+
end = block.end;
|
|
36
|
+
}
|
|
37
|
+
if (!truncated) {
|
|
38
|
+
return { text: markdown, truncated: false };
|
|
39
|
+
}
|
|
40
|
+
const kept = start === -1 ? '' : markdown.slice(start, end);
|
|
41
|
+
return {
|
|
42
|
+
text: kept.replace(/\s+$/, '') + TRUNCATION_MARKER,
|
|
43
|
+
truncated: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { hardSplitLines, parseBlocks } from './markdown.ts';
|
|
2
|
+
|
|
3
|
+
export interface TruncateResult {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly truncated: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const TRUNCATION_MARKER = '\n\n…[truncated]';
|
|
9
|
+
|
|
10
|
+
// Cuts at a top-level block boundary. A fenced code block is a single mdast
|
|
11
|
+
// node, so a boundary cut never lands inside one — no open-fence rollback is
|
|
12
|
+
// needed. The kept span is sliced from the original string so the payload is a
|
|
13
|
+
// byte-for-byte prefix of the Turndown output.
|
|
14
|
+
export function truncateMarkdown(
|
|
15
|
+
markdown: string,
|
|
16
|
+
maxChars: number,
|
|
17
|
+
): TruncateResult {
|
|
18
|
+
if (markdown.length <= maxChars) {
|
|
19
|
+
return { text: markdown, truncated: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const blocks = parseBlocks(markdown);
|
|
23
|
+
let start = -1;
|
|
24
|
+
let end = -1;
|
|
25
|
+
let truncated = false;
|
|
26
|
+
for (const block of blocks) {
|
|
27
|
+
const from = start === -1 ? block.start : start;
|
|
28
|
+
if (block.end - from > maxChars) {
|
|
29
|
+
// First non-code block alone overflows: take a line-aware prefix instead
|
|
30
|
+
// of dropping everything. A code block is never split — a half-open fence
|
|
31
|
+
// is unrecoverable — so an oversized first code block stays dropped.
|
|
32
|
+
if (start === -1 && block.kind !== 'code') {
|
|
33
|
+
const firstPiece =
|
|
34
|
+
hardSplitLines(markdown.slice(block.start, block.end), maxChars)[0] ??
|
|
35
|
+
'';
|
|
36
|
+
return {
|
|
37
|
+
text: firstPiece.replace(/\s+$/, '') + TRUNCATION_MARKER,
|
|
38
|
+
truncated: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
truncated = true;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (start === -1) {
|
|
45
|
+
start = block.start;
|
|
46
|
+
}
|
|
47
|
+
end = block.end;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!truncated) {
|
|
51
|
+
return { text: markdown, truncated: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const kept = start === -1 ? '' : markdown.slice(start, end);
|
|
55
|
+
return {
|
|
56
|
+
text: kept.replace(/\s+$/, '') + TRUNCATION_MARKER,
|
|
57
|
+
truncated: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Disk side of the preset store: one <site>.json file per preset, read into
|
|
2
|
+
// the in-memory store at boot. Local to the user's machine, ordinary files;
|
|
3
|
+
// the server never fetches and nothing expires by clock — staleness is
|
|
4
|
+
// detector-checked per page (policy/presets.ts), so eviction here is disk
|
|
5
|
+
// hygiene only.
|
|
6
|
+
import { describeError } from '@v1nvn/agentic-core';
|
|
7
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { logger } from "./logger.js";
|
|
12
|
+
import { addPreset, normalizeSiteKey } from "./policy/presets.js";
|
|
13
|
+
export const PRESETS_DIR_ENV = 'READABILITY_PRESETS_DIR';
|
|
14
|
+
export const MAX_PRESET_FILES = 64;
|
|
15
|
+
// An empty scope applies nothing while reporting applied:true, and detectors
|
|
16
|
+
// only mean something as a non-empty set — reject both at the file boundary.
|
|
17
|
+
const presetFileSchema = z.object({
|
|
18
|
+
site: z.string().min(1),
|
|
19
|
+
detectors: z.array(z.string().min(1)).min(1),
|
|
20
|
+
scope: z
|
|
21
|
+
.object({
|
|
22
|
+
include: z.string().min(1).optional(),
|
|
23
|
+
exclude: z.array(z.string().min(1)).min(1).optional(),
|
|
24
|
+
})
|
|
25
|
+
.refine(scope => scope.include !== undefined || scope.exclude !== undefined, {
|
|
26
|
+
message: 'scope must carry include or exclude',
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
// Empty string disables preset loading; an absolute path wins over the
|
|
30
|
+
// platform default.
|
|
31
|
+
export function resolvePresetsDir(env = process.env) {
|
|
32
|
+
const override = env[PRESETS_DIR_ENV];
|
|
33
|
+
if (override !== undefined) {
|
|
34
|
+
return override.trim() === '' ? undefined : override;
|
|
35
|
+
}
|
|
36
|
+
const root = env.XDG_CACHE_HOME ||
|
|
37
|
+
join(homedir(), process.platform === 'darwin' ? 'Library/Caches' : '.cache');
|
|
38
|
+
return join(root, 'readability', 'presets');
|
|
39
|
+
}
|
|
40
|
+
// A preset that cannot load is a warning, never a boot failure — the pipeline
|
|
41
|
+
// is designed to run without one.
|
|
42
|
+
export function loadPresetDir(dir) {
|
|
43
|
+
let names;
|
|
44
|
+
try {
|
|
45
|
+
names = readdirSync(dir)
|
|
46
|
+
.filter(name => name.endsWith('.json'))
|
|
47
|
+
.sort();
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === 'ENOENT') {
|
|
51
|
+
logger.debug(`no preset directory at ${dir}`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
logger.warn(`preset directory unreadable: ${dir}`);
|
|
55
|
+
}
|
|
56
|
+
return { loaded: 0, pruned: 0, skipped: 0 };
|
|
57
|
+
}
|
|
58
|
+
const ranked = rankPresetFiles(dir, names);
|
|
59
|
+
const pruned = pruneRanked(dir, ranked.slice(MAX_PRESET_FILES));
|
|
60
|
+
let loaded = 0;
|
|
61
|
+
let skipped = 0;
|
|
62
|
+
for (const { name } of ranked.slice(0, MAX_PRESET_FILES)) {
|
|
63
|
+
const path = join(dir, name);
|
|
64
|
+
if (loadPresetFile(path)) {
|
|
65
|
+
loaded++;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
skipped++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (loaded > 0) {
|
|
72
|
+
logger.info(`loaded ${loaded} site preset(s) from ${dir}`);
|
|
73
|
+
}
|
|
74
|
+
if (pruned > 0) {
|
|
75
|
+
logger.info(`pruned ${pruned} preset file(s) beyond the ${MAX_PRESET_FILES}-file bound`);
|
|
76
|
+
}
|
|
77
|
+
return { loaded, pruned, skipped };
|
|
78
|
+
}
|
|
79
|
+
// Env-resolving entry point for the boot paths; undefined means disabled.
|
|
80
|
+
export function loadPresets(env = process.env) {
|
|
81
|
+
const dir = resolvePresetsDir(env);
|
|
82
|
+
return dir ? loadPresetDir(dir) : undefined;
|
|
83
|
+
}
|
|
84
|
+
function rankPresetFiles(dir, names) {
|
|
85
|
+
const ranked = [];
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
try {
|
|
88
|
+
ranked.push({ name, mtimeMs: statSync(join(dir, name)).mtimeMs });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Vanished between readdir and stat — nothing to load or prune.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
ranked.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
95
|
+
return ranked;
|
|
96
|
+
}
|
|
97
|
+
function pruneRanked(dir, ranked) {
|
|
98
|
+
let pruned = 0;
|
|
99
|
+
for (const { name } of ranked) {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(join(dir, name));
|
|
102
|
+
pruned++;
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.warn(`could not prune preset file ${name}: ${describeError(err)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return pruned;
|
|
109
|
+
}
|
|
110
|
+
// The writer the loader was waiting for: the suggest loop saves an accepted
|
|
111
|
+
// preset so the next server start loads it like any hand-placed file. A
|
|
112
|
+
// failure leaves the in-memory preset working — persistence is best-effort,
|
|
113
|
+
// exactly like loading.
|
|
114
|
+
export function savePreset(preset, env = process.env) {
|
|
115
|
+
const dir = resolvePresetsDir(env);
|
|
116
|
+
if (!dir) {
|
|
117
|
+
return { persisted: false, reason: 'preset-directory-disabled' };
|
|
118
|
+
}
|
|
119
|
+
const shape = presetFileSchema.safeParse(preset);
|
|
120
|
+
if (!shape.success) {
|
|
121
|
+
return {
|
|
122
|
+
persisted: false,
|
|
123
|
+
reason: shape.error.issues[0]?.message ?? 'invalid preset shape',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const key = normalizeSiteKey(preset.site);
|
|
127
|
+
if (!key) {
|
|
128
|
+
return { persisted: false, reason: 'site-is-not-a-hostname' };
|
|
129
|
+
}
|
|
130
|
+
const path = join(dir, `${key}.json`);
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(dir, { recursive: true });
|
|
133
|
+
writeFileSync(path, `${JSON.stringify(shape.data, null, 2)}\n`);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return {
|
|
137
|
+
persisted: false,
|
|
138
|
+
reason: describeError(err),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const over = rankPresetFiles(dir, readdirSync(dir).filter(name => name.endsWith('.json')));
|
|
142
|
+
pruneRanked(dir, over.slice(MAX_PRESET_FILES));
|
|
143
|
+
return { path, persisted: true };
|
|
144
|
+
}
|
|
145
|
+
function loadPresetFile(path) {
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
logger.warn(`${path}: not readable JSON (${describeError(err)}), preset skipped`);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const result = presetFileSchema.safeParse(parsed);
|
|
155
|
+
if (!result.success) {
|
|
156
|
+
logger.warn(`${path}: ${result.error.issues[0]?.message}, preset skipped`);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (!normalizeSiteKey(result.data.site)) {
|
|
160
|
+
logger.warn(`${path}: site is not a hostname, preset skipped`);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
addPreset(result.data);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Disk side of the preset store: one <site>.json file per preset, read into
|
|
2
|
+
// the in-memory store at boot. Local to the user's machine, ordinary files;
|
|
3
|
+
// the server never fetches and nothing expires by clock — staleness is
|
|
4
|
+
// detector-checked per page (policy/presets.ts), so eviction here is disk
|
|
5
|
+
// hygiene only.
|
|
6
|
+
|
|
7
|
+
import { describeError } from '@v1nvn/agentic-core';
|
|
8
|
+
import {
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
statSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
|
|
20
|
+
import type { SitePreset } from './policy/presets.ts';
|
|
21
|
+
|
|
22
|
+
import { logger } from './logger.ts';
|
|
23
|
+
import { addPreset, normalizeSiteKey } from './policy/presets.ts';
|
|
24
|
+
|
|
25
|
+
export const PRESETS_DIR_ENV = 'READABILITY_PRESETS_DIR';
|
|
26
|
+
|
|
27
|
+
export const MAX_PRESET_FILES = 64;
|
|
28
|
+
|
|
29
|
+
// An empty scope applies nothing while reporting applied:true, and detectors
|
|
30
|
+
// only mean something as a non-empty set — reject both at the file boundary.
|
|
31
|
+
const presetFileSchema = z.object({
|
|
32
|
+
site: z.string().min(1),
|
|
33
|
+
detectors: z.array(z.string().min(1)).min(1),
|
|
34
|
+
scope: z
|
|
35
|
+
.object({
|
|
36
|
+
include: z.string().min(1).optional(),
|
|
37
|
+
exclude: z.array(z.string().min(1)).min(1).optional(),
|
|
38
|
+
})
|
|
39
|
+
.refine(
|
|
40
|
+
scope => scope.include !== undefined || scope.exclude !== undefined,
|
|
41
|
+
{
|
|
42
|
+
message: 'scope must carry include or exclude',
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export interface PresetCacheReport {
|
|
48
|
+
readonly loaded: number;
|
|
49
|
+
readonly pruned: number;
|
|
50
|
+
readonly skipped: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Empty string disables preset loading; an absolute path wins over the
|
|
54
|
+
// platform default.
|
|
55
|
+
export function resolvePresetsDir(
|
|
56
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
57
|
+
): string | undefined {
|
|
58
|
+
const override = env[PRESETS_DIR_ENV];
|
|
59
|
+
if (override !== undefined) {
|
|
60
|
+
return override.trim() === '' ? undefined : override;
|
|
61
|
+
}
|
|
62
|
+
const root =
|
|
63
|
+
env.XDG_CACHE_HOME ||
|
|
64
|
+
join(
|
|
65
|
+
homedir(),
|
|
66
|
+
process.platform === 'darwin' ? 'Library/Caches' : '.cache',
|
|
67
|
+
);
|
|
68
|
+
return join(root, 'readability', 'presets');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A preset that cannot load is a warning, never a boot failure — the pipeline
|
|
72
|
+
// is designed to run without one.
|
|
73
|
+
export function loadPresetDir(dir: string): PresetCacheReport {
|
|
74
|
+
let names: string[];
|
|
75
|
+
try {
|
|
76
|
+
names = readdirSync(dir)
|
|
77
|
+
.filter(name => name.endsWith('.json'))
|
|
78
|
+
.sort();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
81
|
+
logger.debug(`no preset directory at ${dir}`);
|
|
82
|
+
} else {
|
|
83
|
+
logger.warn(`preset directory unreadable: ${dir}`);
|
|
84
|
+
}
|
|
85
|
+
return { loaded: 0, pruned: 0, skipped: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ranked = rankPresetFiles(dir, names);
|
|
89
|
+
|
|
90
|
+
const pruned = pruneRanked(dir, ranked.slice(MAX_PRESET_FILES));
|
|
91
|
+
|
|
92
|
+
let loaded = 0;
|
|
93
|
+
let skipped = 0;
|
|
94
|
+
for (const { name } of ranked.slice(0, MAX_PRESET_FILES)) {
|
|
95
|
+
const path = join(dir, name);
|
|
96
|
+
if (loadPresetFile(path)) {
|
|
97
|
+
loaded++;
|
|
98
|
+
} else {
|
|
99
|
+
skipped++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (loaded > 0) {
|
|
103
|
+
logger.info(`loaded ${loaded} site preset(s) from ${dir}`);
|
|
104
|
+
}
|
|
105
|
+
if (pruned > 0) {
|
|
106
|
+
logger.info(
|
|
107
|
+
`pruned ${pruned} preset file(s) beyond the ${MAX_PRESET_FILES}-file bound`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { loaded, pruned, skipped };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Env-resolving entry point for the boot paths; undefined means disabled.
|
|
114
|
+
export function loadPresets(
|
|
115
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
116
|
+
): PresetCacheReport | undefined {
|
|
117
|
+
const dir = resolvePresetsDir(env);
|
|
118
|
+
return dir ? loadPresetDir(dir) : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function rankPresetFiles(
|
|
122
|
+
dir: string,
|
|
123
|
+
names: string[],
|
|
124
|
+
): { mtimeMs: number; name: string }[] {
|
|
125
|
+
const ranked: { mtimeMs: number; name: string }[] = [];
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
try {
|
|
128
|
+
ranked.push({ name, mtimeMs: statSync(join(dir, name)).mtimeMs });
|
|
129
|
+
} catch {
|
|
130
|
+
// Vanished between readdir and stat — nothing to load or prune.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
ranked.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
134
|
+
return ranked;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pruneRanked(
|
|
138
|
+
dir: string,
|
|
139
|
+
ranked: { mtimeMs: number; name: string }[],
|
|
140
|
+
): number {
|
|
141
|
+
let pruned = 0;
|
|
142
|
+
for (const { name } of ranked) {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(join(dir, name));
|
|
145
|
+
pruned++;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger.warn(`could not prune preset file ${name}: ${describeError(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return pruned;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface PersistReport {
|
|
154
|
+
readonly path?: string;
|
|
155
|
+
readonly persisted: boolean;
|
|
156
|
+
readonly reason?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The writer the loader was waiting for: the suggest loop saves an accepted
|
|
160
|
+
// preset so the next server start loads it like any hand-placed file. A
|
|
161
|
+
// failure leaves the in-memory preset working — persistence is best-effort,
|
|
162
|
+
// exactly like loading.
|
|
163
|
+
export function savePreset(
|
|
164
|
+
preset: SitePreset,
|
|
165
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
166
|
+
): PersistReport {
|
|
167
|
+
const dir = resolvePresetsDir(env);
|
|
168
|
+
if (!dir) {
|
|
169
|
+
return { persisted: false, reason: 'preset-directory-disabled' };
|
|
170
|
+
}
|
|
171
|
+
const shape = presetFileSchema.safeParse(preset);
|
|
172
|
+
if (!shape.success) {
|
|
173
|
+
return {
|
|
174
|
+
persisted: false,
|
|
175
|
+
reason: shape.error.issues[0]?.message ?? 'invalid preset shape',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const key = normalizeSiteKey(preset.site);
|
|
179
|
+
if (!key) {
|
|
180
|
+
return { persisted: false, reason: 'site-is-not-a-hostname' };
|
|
181
|
+
}
|
|
182
|
+
const path = join(dir, `${key}.json`);
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(dir, { recursive: true });
|
|
185
|
+
writeFileSync(path, `${JSON.stringify(shape.data, null, 2)}\n`);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return {
|
|
188
|
+
persisted: false,
|
|
189
|
+
reason: describeError(err),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const over = rankPresetFiles(
|
|
193
|
+
dir,
|
|
194
|
+
readdirSync(dir).filter(name => name.endsWith('.json')),
|
|
195
|
+
);
|
|
196
|
+
pruneRanked(dir, over.slice(MAX_PRESET_FILES));
|
|
197
|
+
return { path, persisted: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function loadPresetFile(path: string): boolean {
|
|
201
|
+
let parsed: unknown;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
logger.warn(
|
|
206
|
+
`${path}: not readable JSON (${describeError(err)}), preset skipped`,
|
|
207
|
+
);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const result = presetFileSchema.safeParse(parsed);
|
|
211
|
+
if (!result.success) {
|
|
212
|
+
logger.warn(`${path}: ${result.error.issues[0]?.message}, preset skipped`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (!normalizeSiteKey(result.data.site)) {
|
|
216
|
+
logger.warn(`${path}: site is not a hostname, preset skipped`);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
addPreset(result.data);
|
|
220
|
+
return true;
|
|
221
|
+
}
|