bareloop 0.41.0 → 0.43.0
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/CHANGELOG.md +109 -1
- package/README.md +13 -21
- package/bareloop.context.md +97 -34
- package/package.json +4 -3
- package/src/authorflow.js +337 -29
- package/src/authoring.js +92 -7
- package/src/authorjob.js +84 -16
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +14 -5
- package/src/authorscout.js +108 -0
- package/src/declaredclose.js +25 -14
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/inputlook.js +301 -0
- package/src/interviewrun.js +0 -1
- package/src/job.js +40 -12
- package/src/jobblock.js +7 -2
- package/src/kinds.js +84 -3
- package/src/labelscan.js +170 -0
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +121 -34
- package/src/panel/authorsession.js +154 -45
- package/src/panel/index.html +845 -490
- package/src/panel/jobplan.js +34 -11
- package/src/panel/runroutes.js +163 -5
- package/src/panel/scheduler.js +156 -0
- package/src/panel/schedules.js +667 -0
- package/src/panel/schedulestart.js +243 -0
- package/src/panel/server.js +467 -48
- package/src/planrun.js +42 -9
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +804 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- package/src/source.js +166 -1
- package/src/trigger.js +48 -0
- package/src/triggerclock.js +127 -0
- package/src/triggerfiles.js +215 -0
- package/src/triggerledger.js +79 -0
- package/src/u-readout.js +1 -1
- package/src/userrun.js +20 -9
- package/types/authorflow.d.ts +267 -13
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +7 -5
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +70 -0
- package/types/declaredclose.d.ts +5 -3
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/inputlook.d.ts +238 -0
- package/types/job.d.ts +8 -1
- package/types/jobblock.d.ts +8 -1
- package/types/labelscan.d.ts +38 -0
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorroutes.d.ts +34 -1
- package/types/panel/authorsession.d.ts +55 -14
- package/types/panel/jobplan.d.ts +12 -4
- package/types/panel/runroutes.d.ts +32 -1
- package/types/panel/scheduler.d.ts +17 -0
- package/types/panel/schedules.d.ts +412 -0
- package/types/panel/schedulestart.d.ts +106 -0
- package/types/panel/server.d.ts +145 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +286 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -0
- package/types/source.d.ts +94 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -0
- package/types/userrun.d.ts +11 -0
package/src/kinds.js
CHANGED
|
@@ -61,13 +61,14 @@
|
|
|
61
61
|
|
|
62
62
|
import { spawn } from 'node:child_process';
|
|
63
63
|
import { realpathSync } from 'node:fs';
|
|
64
|
-
import { readFile, stat, mkdtemp, rm, symlink } from 'node:fs/promises';
|
|
64
|
+
import { readFile, stat, lstat, mkdtemp, rm, symlink } from 'node:fs/promises';
|
|
65
65
|
import { tmpdir } from 'node:os';
|
|
66
|
-
import { join, sep } from 'node:path';
|
|
66
|
+
import { join, sep, dirname } from 'node:path';
|
|
67
67
|
import { globToPrefix, isNonEmptyString } from './validate.js';
|
|
68
68
|
import { CLOSE_ENV_DENY } from './ralph.js';
|
|
69
69
|
import { runLocate, decide, validateCard, LOCATE_AXES, LOCATE_LABEL } from './judged.js';
|
|
70
70
|
import { rateSourceFields } from './text.js';
|
|
71
|
+
import { gradeStep, stepFileRel, STEP_MAX_BYTES, citedLabels, sha256Of } from './rubricstep.js';
|
|
71
72
|
|
|
72
73
|
/** the close's two judgment exits — a stage's verdict, in the shape the
|
|
73
74
|
* hand-written closes already spoke (exit code is truth) */
|
|
@@ -140,7 +141,7 @@ export const SIGNS = Object.freeze([1, -1]);
|
|
|
140
141
|
* executor runs that the catalogue never names is a capability nothing can
|
|
141
142
|
* reach. `judged-floor` joined at softgreen module 2 — the runner and the
|
|
142
143
|
* catalogue unlock in ONE commit for exactly that reason. */
|
|
143
|
-
export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms', 'judged-floor']);
|
|
144
|
+
export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms', 'judged-floor', 'rubric-step']);
|
|
144
145
|
/**
|
|
145
146
|
* The judged stage's ladder: ONE attempt plus ONE retry, and no more.
|
|
146
147
|
*
|
|
@@ -1526,6 +1527,85 @@ async function runFilesChanged(stage, ctx) {
|
|
|
1526
1527
|
});
|
|
1527
1528
|
}
|
|
1528
1529
|
|
|
1530
|
+
/**
|
|
1531
|
+
* PART 1a — the `rubric-step` kind: grade ONE job line's step file (output/steps/N.json) with bareguard's rubric primitive.
|
|
1532
|
+
* It spawns nothing and buys nothing. The inputs a step cites are read from the frozen tree (`input/...`) and held to the
|
|
1533
|
+
* sha256 the SOURCE DOOR recorded in `<into>/source.json` (`inputs: [{label, rel, sha256}]`), so an input that changed after
|
|
1534
|
+
* the freeze is a stopped instrument, never a green. The rubric is built here, at every run, from the stage's own parameters
|
|
1535
|
+
* (the inputs belong to the run, not to the signed job). A step file that is missing, cut off, or not a JSON object is a RED
|
|
1536
|
+
* (the worker has not finished the line); an instrument that cannot read what it must is a STOP (97), never a red.
|
|
1537
|
+
* @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>}
|
|
1538
|
+
*/
|
|
1539
|
+
async function runRubricStep(stage, ctx) {
|
|
1540
|
+
const p = stage.params;
|
|
1541
|
+
const step = p.step;
|
|
1542
|
+
const stop = (/** @type {string} */ why, /** @type {string} */ fault = STOP_FAULTS.FAILED) => stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}": ${why}`, { step }, fault);
|
|
1543
|
+
// the source door's record of what was frozen: beside the tree, one level up (`<into>/source.json`, `<into>/tree`)
|
|
1544
|
+
/** @type {any} */
|
|
1545
|
+
let manifest;
|
|
1546
|
+
try { manifest = JSON.parse(await readFile(join(dirname(ctx.workdir), 'source.json'), 'utf8')); } catch (e) {
|
|
1547
|
+
return stop(`the source record (source.json beside the job's tree) could not be read: ${/** @type {Error} */ (e).message}`);
|
|
1548
|
+
}
|
|
1549
|
+
const recorded = Array.isArray(manifest?.inputs) ? manifest.inputs : [];
|
|
1550
|
+
/** @type {Record<string, string>} */
|
|
1551
|
+
const inputShas = {};
|
|
1552
|
+
/** @type {Record<string, string>} */
|
|
1553
|
+
const inputs = {};
|
|
1554
|
+
for (const label of citedLabels(p)) {
|
|
1555
|
+
const row = recorded.find((/** @type {any} */ r) => r && r.label === label);
|
|
1556
|
+
if (!row || typeof row.rel !== 'string' || typeof row.sha256 !== 'string') return stop(`the step cites "${label}", but the source record has no input with that label`);
|
|
1557
|
+
let buf;
|
|
1558
|
+
try { buf = await readFile(join(ctx.workdir, row.rel)); } catch (e) { return stop(`input "${label}" (${row.rel}) could not be read: ${/** @type {Error} */ (e).message}`); }
|
|
1559
|
+
if (sha256Of(buf) !== row.sha256) return stop(`input "${label}" (${row.rel}) no longer matches the bytes frozen at the source door`);
|
|
1560
|
+
inputShas[label] = row.sha256;
|
|
1561
|
+
inputs[label] = buf.toString('utf8');
|
|
1562
|
+
}
|
|
1563
|
+
// the step file: a regular file, read once, bounded
|
|
1564
|
+
const abs = join(ctx.workdir, stepFileRel(step));
|
|
1565
|
+
/** @type {string|null} */
|
|
1566
|
+
let bytes = null;
|
|
1567
|
+
let notRegular = false;
|
|
1568
|
+
try {
|
|
1569
|
+
const ls = await lstat(abs);
|
|
1570
|
+
if (!ls.isFile()) notRegular = true;
|
|
1571
|
+
else if (ls.size > STEP_MAX_BYTES) return stop(`${stepFileRel(step)} is over the ${STEP_MAX_BYTES} byte limit the rubric reads`);
|
|
1572
|
+
else bytes = (await readFile(abs)).toString('utf8');
|
|
1573
|
+
} catch (e) {
|
|
1574
|
+
if (/** @type {any} */ (e)?.code !== 'ENOENT') return stop(`${stepFileRel(step)} could not be read: ${/** @type {Error} */ (e).message}`);
|
|
1575
|
+
}
|
|
1576
|
+
// PART 1b: a prose step's numbers are tied to the quotes the earlier fact lines' step files hold (those stages ran, and were
|
|
1577
|
+
// green, before this one: the close is first-red-wins in stage order, which the validator pins). Unreadable = a stopped instrument.
|
|
1578
|
+
/** @type {Record<number, string[]>} */
|
|
1579
|
+
const factQuotes = {};
|
|
1580
|
+
/** @type {number[]} */
|
|
1581
|
+
const factMissing = [];
|
|
1582
|
+
const tie = Array.isArray(p.parts) ? p.parts.find((/** @type {any} */ x) => x && x.class === 'countable' && x.check === 'numbersFromFacts') : null;
|
|
1583
|
+
for (const n of tie ? tie.from : []) {
|
|
1584
|
+
try {
|
|
1585
|
+
const parsedFacts = JSON.parse(await readFile(join(ctx.workdir, stepFileRel(n)), 'utf8'));
|
|
1586
|
+
const results = Array.isArray(parsedFacts?.results) ? parsedFacts.results : [];
|
|
1587
|
+
factQuotes[n] = results.map((/** @type {any} */ r) => (r && typeof r.quote === 'string' ? r.quote : ''));
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
// M2: an earlier line's file that is not there (the seed read, before any worker wrote) is a normal red, never an instrument fault
|
|
1590
|
+
if (/** @type {any} */ (e)?.code === 'ENOENT') { factMissing.push(n); continue; }
|
|
1591
|
+
return stop(`the checked facts of line ${n} (${stepFileRel(n)}) could not be read for the number tie: ${/** @type {Error} */ (e).message}`);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
const graded = await gradeStep({ params: p, bytes: notRegular ? null : bytes, outputBytes: notRegular ? null : bytes, inputs, factQuotes, factMissing }, inputShas);
|
|
1595
|
+
if (graded.verdict === 'stopped') return stop(`the rubric stopped: ${graded.fault}`, STOP_FAULTS.CRASHED);
|
|
1596
|
+
const gap = new Gap(ctx.gapKeep, ctx.gapCap);
|
|
1597
|
+
if (notRegular) gap.push(`${stage.name}: line ${step}: ${stepFileRel(step)} is not a regular file`);
|
|
1598
|
+
for (const l of graded.gapLines) gap.push(`${stage.name}: ${l}`);
|
|
1599
|
+
const red = graded.verdict === 'red' || notRegular;
|
|
1600
|
+
return result(stage, {
|
|
1601
|
+
verdict: red ? 'red' : 'green',
|
|
1602
|
+
exitCode: red ? EXIT_RED : EXIT_GREEN,
|
|
1603
|
+
gapLines: gap.render(),
|
|
1604
|
+
// the trend number (`trendValueOf`, src/declaredclose.js, reads `detail.redCount` for a stage with no measurement)
|
|
1605
|
+
detail: { ...graded.detail, ...(notRegular ? { redCount: 1 } : {}) },
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1529
1609
|
/**
|
|
1530
1610
|
* THE SIGNER'S ANSWER, normalised at the seam that accepts it — the library half
|
|
1531
1611
|
* of the three-button gate (2026-08-12 §1).
|
|
@@ -1954,6 +2034,7 @@ const RUNNERS = {
|
|
|
1954
2034
|
'files-changed': runFilesChanged,
|
|
1955
2035
|
'human-confirms': runHumanConfirms,
|
|
1956
2036
|
'judged-floor': runJudgedFloor,
|
|
2037
|
+
'rubric-step': runRubricStep,
|
|
1957
2038
|
};
|
|
1958
2039
|
|
|
1959
2040
|
/**
|
package/src/labelscan.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// THE LABEL LIST (PART 1c, docs/product/ITEM33-BUILD.md "3. The look", "LABELS ARE PICKED, NEVER TYPED"): before the look, code scans a
|
|
2
|
+
// source at $0 and lists, per shape (money, number, date, time, text), the words that sit right before a value of that shape,
|
|
3
|
+
// with how often each occurs. The look and the drafter pick a label ONLY from this list (by id or exact text); a label that is not on
|
|
4
|
+
// it is a $0 red naming it. ONE generic scan for every page or file - there is no per-site table here and never will be.
|
|
5
|
+
//
|
|
6
|
+
// How a label is found (all of it plain string work, no model):
|
|
7
|
+
// - each line is cleaned (any run of white space, U+202F / U+00A0 included, becomes one space; bracketed tags like `[ref=549]` that a
|
|
8
|
+
// page reader adds are dropped);
|
|
9
|
+
// - values are found in a fixed order of shapes: date, time, money, number, and last `text` = any other token that holds a digit
|
|
10
|
+
// (a version like 0.41.0, an id). A value is only a place to look BEFORE: bareguard reads the real value at run time;
|
|
11
|
+
// - the words before a value are the text between the previous value on the line and this one, cut to its last one or two
|
|
12
|
+
// tokens (and the whole gap when it is short); a label holds no digit;
|
|
13
|
+
// - text facts also get the words before each REPEATED capitalised phrase (an airport, a city, a name): `Leaves` before one phrase,
|
|
14
|
+
// `arrives at` before another.
|
|
15
|
+
// A label is listed when it occurs at least MIN_COUNT times. Nothing here decides what a label MEANS.
|
|
16
|
+
|
|
17
|
+
/** the shapes a list is made for */
|
|
18
|
+
export const LABEL_SHAPES = Object.freeze(['money', 'number', 'date', 'time', 'text']);
|
|
19
|
+
/** a label must occur at least this often to be listed (1: a one-page document with one total still has its label; the list is cut to the highest counts) */
|
|
20
|
+
export const MIN_COUNT = 1;
|
|
21
|
+
/** how many labels one shape's list holds at most (the highest counts first) */
|
|
22
|
+
export const MAX_PER_SHAPE = 40;
|
|
23
|
+
/** the longest label, in characters */
|
|
24
|
+
const MAX_LABEL = 40;
|
|
25
|
+
/** the longest whole gap taken as a label when it is short */
|
|
26
|
+
const MAX_GAP_LABEL = 24;
|
|
27
|
+
/** a phrase must repeat this often to be a repeated phrase */
|
|
28
|
+
const MIN_PHRASE = 3;
|
|
29
|
+
/** how many repeated phrases are looked behind */
|
|
30
|
+
const MAX_PHRASES = 20;
|
|
31
|
+
/** a line is scanned up to this many characters, and for this many values (a minified page is one huge line; the bound keeps the scan linear) */
|
|
32
|
+
const MAX_LINE = 20000;
|
|
33
|
+
const MAX_VALUES_PER_LINE = 400;
|
|
34
|
+
/** only this much of a source is scanned (a source over it is scanned up to a line end) */
|
|
35
|
+
export const SCAN_MAX_CHARS = 2_000_000;
|
|
36
|
+
|
|
37
|
+
const WS = /[\s ]+/g;
|
|
38
|
+
const TAG = /\[[A-Za-z]+=[^\]\s]*\]/g;
|
|
39
|
+
const MONTH = '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
|
|
40
|
+
/** value finders in priority order: an earlier shape owns a span a later one would also match */
|
|
41
|
+
const FINDERS = /** @type {[string, RegExp][]} */ ([
|
|
42
|
+
['date', new RegExp(`\\b\\d{4}-\\d{2}-\\d{2}\\b|\\b\\d{1,2}/\\d{1,2}/\\d{2,4}\\b|\\b${MONTH}\\.? \\d{1,2}(?:, \\d{4})?\\b|\\b\\d{1,2} ${MONTH}\\.? \\d{4}\\b`, 'g')],
|
|
43
|
+
['time', /\b\d{1,2}:\d{2}(?: ?[AaPp]\.?[Mm]\.?)?(?![\w:])/g],
|
|
44
|
+
['money', /[$€£] ?\d[\d,]*(?:\.\d+)?|\b\d[\d,]*(?:\.\d+)? ?(?:US dollars?|dollars?|USD|euros?|EUR|pounds?|GBP)\b/g],
|
|
45
|
+
['number', /(?<![\w.,])\d[\d,]*(?:\.\d+)?(?![\w]|\.\d)/g],
|
|
46
|
+
['text', /[A-Za-z]*\d[\w.\-]*/g],
|
|
47
|
+
]);
|
|
48
|
+
const PHRASE = /\b[A-Z][a-z]+(?: [A-Z][a-z]+)+\b/g;
|
|
49
|
+
|
|
50
|
+
/** @typedef {{id: string, label: string, count: number, example: string}} LabelEntry */
|
|
51
|
+
/** @typedef {Record<'money'|'number'|'date'|'time'|'text', LabelEntry[]>} LabelScan */
|
|
52
|
+
|
|
53
|
+
/** @param {string} t @returns {string} white space made one space, quote marks at the edges dropped, trimmed - how a label is compared */
|
|
54
|
+
export function normLabel(t) {
|
|
55
|
+
return String(t ?? '').replace(WS, ' ').trim().replace(/^["'“”]+|["'“”]+$/g, '').trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} gap @returns {string[]} the labels a gap of text before a value offers */
|
|
59
|
+
function candidates(gap) {
|
|
60
|
+
const g = normLabel(gap);
|
|
61
|
+
if (g === '') return [];
|
|
62
|
+
const tokens = g.split(' ');
|
|
63
|
+
/** @type {Set<string>} */
|
|
64
|
+
const out = new Set();
|
|
65
|
+
out.add(normLabel(tokens.at(-1) ?? ''));
|
|
66
|
+
if (tokens.length >= 2) out.add(normLabel(tokens.slice(-2).join(' ')));
|
|
67
|
+
if (g.length <= MAX_GAP_LABEL) out.add(g);
|
|
68
|
+
return [...out].filter((c) => c !== '' && c.length <= MAX_LABEL && !/\d/.test(c) && !/ref=/.test(c));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Scan one source text for the labels that sit before values, per shape.
|
|
73
|
+
* @param {string} text
|
|
74
|
+
* @returns {LabelScan}
|
|
75
|
+
*/
|
|
76
|
+
export function scanLabels(text) {
|
|
77
|
+
let src = String(text ?? '');
|
|
78
|
+
if (src.length > SCAN_MAX_CHARS) { const nl = src.lastIndexOf('\n', SCAN_MAX_CHARS); src = src.slice(0, nl > 0 ? nl : SCAN_MAX_CHARS); }
|
|
79
|
+
/** @type {Record<string, Map<string, {count: number, example: string}>>} */
|
|
80
|
+
const seen = Object.fromEntries(LABEL_SHAPES.map((s) => [s, new Map()]));
|
|
81
|
+
const bump = (/** @type {string} */ shape, /** @type {string} */ label, /** @type {string} */ example) => {
|
|
82
|
+
const m = seen[shape];
|
|
83
|
+
const cur = m.get(label);
|
|
84
|
+
if (cur) cur.count += 1; else m.set(label, { count: 1, example });
|
|
85
|
+
};
|
|
86
|
+
/** @type {Map<string, number>} */
|
|
87
|
+
const phraseCount = new Map();
|
|
88
|
+
/** @type {string[]} */
|
|
89
|
+
const lines = [];
|
|
90
|
+
/** @type {{line: string, at: number, phrase: string}[]} */
|
|
91
|
+
const variants = [];
|
|
92
|
+
for (const raw of src.split('\n')) {
|
|
93
|
+
const line = raw.slice(0, MAX_LINE).replace(TAG, ' ').replace(WS, ' ').trim();
|
|
94
|
+
if (line === '') continue;
|
|
95
|
+
lines.push(line);
|
|
96
|
+
for (const m of line.matchAll(PHRASE)) {
|
|
97
|
+
// a capitalised run may start with a sentence-initial word ("Leaves San Francisco ..."): the run is also read without its first word
|
|
98
|
+
const words = m[0].split(' ');
|
|
99
|
+
let off = m.index ?? 0;
|
|
100
|
+
for (let j = 0; j <= 1 && words.length - j >= 2; j += 1) {
|
|
101
|
+
const phrase = words.slice(j).join(' ');
|
|
102
|
+
phraseCount.set(phrase, (phraseCount.get(phrase) ?? 0) + 1);
|
|
103
|
+
variants.push({ line, at: off, phrase });
|
|
104
|
+
off += words[j].length + 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
/** @type {{shape: string, s: number, e: number, v: string}[]} */
|
|
110
|
+
const found = [];
|
|
111
|
+
for (const [shape, re] of FINDERS) {
|
|
112
|
+
for (const m of line.matchAll(re)) {
|
|
113
|
+
const s = m.index ?? 0;
|
|
114
|
+
const e = s + m[0].length;
|
|
115
|
+
if (found.length >= MAX_VALUES_PER_LINE) break;
|
|
116
|
+
if (found.some((f) => s < f.e && e > f.s)) continue;
|
|
117
|
+
found.push({ shape, s, e, v: m[0] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
found.sort((a, b) => a.s - b.s);
|
|
121
|
+
let prevEnd = 0;
|
|
122
|
+
for (const f of found) {
|
|
123
|
+
for (const c of candidates(line.slice(prevEnd, f.s))) bump(f.shape, c, f.v);
|
|
124
|
+
prevEnd = f.e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// the words before each REPEATED capitalised phrase (a place, a name): text facts
|
|
128
|
+
const phrases = new Set([...phraseCount].filter(([, n]) => n >= MIN_PHRASE).sort((a, b) => b[1] - a[1]).slice(0, MAX_PHRASES).map(([p]) => p));
|
|
129
|
+
for (const v of variants) {
|
|
130
|
+
if (!phrases.has(v.phrase)) continue;
|
|
131
|
+
for (const c of candidates(v.line.slice(0, v.at))) bump('text', c, v.phrase);
|
|
132
|
+
}
|
|
133
|
+
/** @type {Record<string, string>} */
|
|
134
|
+
const prefix = { money: 'm', number: 'n', date: 'd', time: 't', text: 'x' };
|
|
135
|
+
return /** @type {LabelScan} */ (Object.fromEntries(LABEL_SHAPES.map((shape) => {
|
|
136
|
+
const rows = [...seen[shape]].filter(([, v]) => v.count >= MIN_COUNT)
|
|
137
|
+
.sort((a, b) => b[1].count - a[1].count || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
|
|
138
|
+
.slice(0, MAX_PER_SHAPE)
|
|
139
|
+
.map(([label, v], i) => ({ id: `${prefix[shape]}${i + 1}`, label, count: v.count, example: v.example }));
|
|
140
|
+
return [shape, rows];
|
|
141
|
+
})));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Is this label on the list for this shape? Compared the way a label is read: white space of any kind is one space, quote marks at the
|
|
146
|
+
* edges are dropped. `idOrText` may be a list id (`t1`) or the exact words.
|
|
147
|
+
* @param {LabelScan} scan @param {string} shape @param {unknown} idOrText
|
|
148
|
+
* @returns {LabelEntry|null}
|
|
149
|
+
*/
|
|
150
|
+
export function labelOnList(scan, shape, idOrText) {
|
|
151
|
+
const rows = scan[/** @type {'money'} */ (shape)];
|
|
152
|
+
if (!rows || typeof idOrText !== 'string') return null;
|
|
153
|
+
const asId = rows.find((r) => r.id === idOrText.trim());
|
|
154
|
+
if (asId) return asId;
|
|
155
|
+
const want = normLabel(idOrText);
|
|
156
|
+
return rows.find((r) => r.label === want) ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The list as the look and the drafter are shown it, one block per shape that has any label.
|
|
161
|
+
* @param {string} inputLabel the input the list belongs to @param {LabelScan} scan
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function renderLabelList(inputLabel, scan) {
|
|
165
|
+
const blocks = LABEL_SHAPES.filter((s) => scan[/** @type {'money'} */ (s)].length > 0).map((s) => {
|
|
166
|
+
const rows = scan[/** @type {'money'} */ (s)].map((r) => ` ${r.id} ${JSON.stringify(r.label)} x${r.count} e.g. ${JSON.stringify(r.example)}`);
|
|
167
|
+
return `${s} (the words right before a ${s === 'text' ? 'repeated phrase or an id/version' : s}):\n${rows.join('\n')}`;
|
|
168
|
+
});
|
|
169
|
+
return `LABELS IN THE INPUT "${inputLabel}" - pick a label ONLY from this list (by its id or its exact words); a label that is not here is refused:\n\n${blocks.length ? blocks.join('\n\n') : '(no repeated label was found before a value)'}`;
|
|
170
|
+
}
|
package/src/pagefetch.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// URL inputs (PART 1c, docs/product/ITEM33-BUILD.md "4. URL inputs" + live fix L1, PRD item 33 RF21/RF22): an Inputs line may be a web
|
|
2
|
+
// address the person pastes (a results page). bareloop fetches it with barebrowse, READ ONLY, headless and incognito, as published:
|
|
3
|
+
//
|
|
4
|
+
// page = await connect({ incognito: true, blockPrivateNetwork: true })
|
|
5
|
+
// await page.goto(url); await page.waitForNetworkIdle({ idle, timeout }); await page.snapshot({ mode: 'act' })
|
|
6
|
+
//
|
|
7
|
+
// L1: one-shot `browse()` snapshots at load, before the page's own scripts fill a results list (the same URL gave 77,951 / 8,801 / 719
|
|
8
|
+
// chars). So we wait for the network to go quiet (bounded) before the snapshot. Generic: no site selector. The snapshot mode is the one
|
|
9
|
+
// proven in step 0 ('read' and 'full' returned almost nothing from a results page). The text that comes back is the input. An empty
|
|
10
|
+
// fetch is a plain-words stop, never a silent empty input. Nothing here touches a model.
|
|
11
|
+
|
|
12
|
+
import { SECRET_PATTERNS } from './validate.js';
|
|
13
|
+
|
|
14
|
+
/** the session options (incognito: no auth ever enters; blockPrivateNetwork: SSRF guard); ONE frozen object so a test can pin them */
|
|
15
|
+
export const CONNECT_OPTIONS = Object.freeze({ incognito: true, blockPrivateNetwork: true });
|
|
16
|
+
/** the snapshot options step 0 proved */
|
|
17
|
+
export const SNAPSHOT_OPTIONS = Object.freeze({ mode: 'act' });
|
|
18
|
+
/** the page's network must be quiet this long (ms) before we read it: long enough to span the gap between a page's first load and the
|
|
19
|
+
* script requests that fill the results, short enough not to stretch a plain page */
|
|
20
|
+
export const NETWORK_IDLE_MS = 1500;
|
|
21
|
+
/** the most we wait for the network to go quiet (ms); a page that never settles (polling, streaming) is read as it stands after this */
|
|
22
|
+
export const NETWORK_IDLE_TIMEOUT_MS = 20_000;
|
|
23
|
+
/** the most we wait for the page to load at all (ms) */
|
|
24
|
+
export const GOTO_TIMEOUT_MS = 30_000;
|
|
25
|
+
/** a page that comes back with fewer characters than this (after trimming) is treated as "nothing came back" */
|
|
26
|
+
export const MIN_PAGE_CHARS = 200;
|
|
27
|
+
/** the page text left after the site's own header, menus and footer are taken out must hold at least this many characters, or the page is
|
|
28
|
+
* "only site chrome" (L2: a 719-character fetch of Google's header and sign-in passed the 200-character floor) */
|
|
29
|
+
export const MIN_CONTENT_CHARS = 100;
|
|
30
|
+
/** the page reader's landmark roles for a site's own chrome (the ARIA names of header, menu and footer); everything under one is not the page's content */
|
|
31
|
+
const CHROME_ROLES = new Set(['banner', 'navigation', 'contentinfo']);
|
|
32
|
+
/** our own deadline around one whole fetch (a hung browser leaves no trace; this is the only thing that fires on the absence of an answer) */
|
|
33
|
+
export const PAGE_FETCH_TIMEOUT_MS = 90_000;
|
|
34
|
+
|
|
35
|
+
/** @param {unknown} v @returns {boolean} an http(s) address (the one spelling of "this Inputs value is a web address") */
|
|
36
|
+
export function isWebUrl(v) {
|
|
37
|
+
return typeof v === 'string' && /^https?:\/\//i.test(v.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How much of a fetched page is the page's own content: the snapshot's header lines (url, size note, hint) and every part of the tree
|
|
42
|
+
* under a banner / navigation / contentinfo landmark are taken out, the page reader's bracketed tags are removed, and what is left is
|
|
43
|
+
* counted. Generic - no site names, only the ARIA landmark roles. Text that is not a snapshot tree (a plain file) has no landmark, so it
|
|
44
|
+
* counts whole.
|
|
45
|
+
* @param {string} text @returns {number}
|
|
46
|
+
*/
|
|
47
|
+
export function pageContentChars(text) {
|
|
48
|
+
const lines = String(text ?? '').split('\n');
|
|
49
|
+
let skipIndent = -1;
|
|
50
|
+
let n = 0;
|
|
51
|
+
let head = true;
|
|
52
|
+
for (const raw of lines) {
|
|
53
|
+
if (head && (/^url: /.test(raw) || /^[\d,]+ chars → /.test(raw) || /^hint: /.test(raw))) continue;
|
|
54
|
+
head = false;
|
|
55
|
+
const m = /^(\s*)- ([A-Za-z_]+)\b/.exec(raw);
|
|
56
|
+
const indent = m ? m[1].length : raw.length - raw.trimStart().length;
|
|
57
|
+
if (skipIndent >= 0) {
|
|
58
|
+
if (raw.trim() === '' || indent > skipIndent) continue;
|
|
59
|
+
skipIndent = -1;
|
|
60
|
+
}
|
|
61
|
+
if (m && CHROME_ROLES.has(m[2])) { skipIndent = indent; continue; }
|
|
62
|
+
n += raw.replace(/\[[A-Za-z]+=[^\]\s]*\]/g, ' ').replace(/^\s*-\s*/, '').replace(/\s+/g, ' ').trim().length;
|
|
63
|
+
}
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {{goto: (url: string, timeout?: number) => Promise<void>, waitForNetworkIdle: (o?: {idle?: number, timeout?: number}) => Promise<void>,
|
|
69
|
+
* snapshot: (o?: {mode?: string}) => Promise<string>, close: () => Promise<void>}} FetchPage the part of barebrowse's page handle the fetch uses
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch one page's text. `connect` is injected (a test never reaches the network); the default is barebrowse's own `connect`.
|
|
74
|
+
* @param {string} url
|
|
75
|
+
* @param {{connect?: (opts: Record<string, any>) => Promise<FetchPage>, timeoutMs?: number, idleMs?: number, idleTimeoutMs?: number, closeWaitMs?: number}} [o]
|
|
76
|
+
* @returns {Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>} `error` is plain words, without a spend line
|
|
77
|
+
*/
|
|
78
|
+
export async function fetchPageText(url, { connect, timeoutMs = PAGE_FETCH_TIMEOUT_MS, idleMs = NETWORK_IDLE_MS, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS, closeWaitMs = 3000 } = {}) {
|
|
79
|
+
let u;
|
|
80
|
+
try { u = new URL(url.trim()); } catch { return { ok: false, error: `${url} is not a web address bareloop can read` }; }
|
|
81
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: `${url} is not an http or https address` };
|
|
82
|
+
if (u.username !== '' || u.password !== '') return { ok: false, error: 'a web address with a user name or password in it is refused - bareloop reads a page with no credentials at all' };
|
|
83
|
+
if (SECRET_PATTERNS.some((re) => re.test(u.href))) return { ok: false, error: 'the web address carries a known secret shape - secrets never enter a job' };
|
|
84
|
+
/** @type {(opts: Record<string, any>) => Promise<FetchPage>} */
|
|
85
|
+
let open;
|
|
86
|
+
if (connect) open = connect;
|
|
87
|
+
else {
|
|
88
|
+
try { open = /** @type {any} */ ((await import('barebrowse')).connect); } catch (e) { return { ok: false, error: `the page reader (barebrowse) could not be loaded: ${/** @type {Error} */ (e).message}` }; }
|
|
89
|
+
}
|
|
90
|
+
/** @type {NodeJS.Timeout|undefined} */
|
|
91
|
+
let timer;
|
|
92
|
+
/** @type {Promise<FetchPage>|undefined} */
|
|
93
|
+
let opening;
|
|
94
|
+
try {
|
|
95
|
+
opening = open({ ...CONNECT_OPTIONS });
|
|
96
|
+
const page = opening;
|
|
97
|
+
const text = await Promise.race([
|
|
98
|
+
(async () => {
|
|
99
|
+
const p = await page;
|
|
100
|
+
await p.goto(u.href, GOTO_TIMEOUT_MS);
|
|
101
|
+
// bounded: a page that never goes quiet (polling, streaming) is read as it stands - the wait is for a page that is still filling
|
|
102
|
+
try { await p.waitForNetworkIdle({ idle: idleMs, timeout: idleTimeoutMs }); } catch { /* read what is there */ }
|
|
103
|
+
return p.snapshot({ ...SNAPSHOT_OPTIONS });
|
|
104
|
+
})(),
|
|
105
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`no answer inside ${Math.round(timeoutMs / 1000)} seconds`)), timeoutMs); }),
|
|
106
|
+
]);
|
|
107
|
+
if (typeof text !== 'string') return { ok: false, error: `${u.href} did not come back as text` };
|
|
108
|
+
if (/\[BOT CHALLENGE DETECTED\]/.test(text)) return { ok: false, error: `${u.href} asked for a human check (a bot challenge), so the page could not be read` };
|
|
109
|
+
const size = text.trim().length;
|
|
110
|
+
if (size < MIN_PAGE_CHARS) return { ok: false, error: `${u.href} came back with ${size === 0 ? 'no text' : `only ${size} characters of text`} (less than ${MIN_PAGE_CHARS}) - the page did not load, or holds nothing to read` };
|
|
111
|
+
if (pageContentChars(text) < MIN_CONTENT_CHARS) return { ok: false, error: `${u.href} came back with only the site's own header and menus - the page did not load its content, so there is nothing to read` };
|
|
112
|
+
return { ok: true, text, url: u.href };
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return { ok: false, error: `${u.href} could not be read: ${/** @type {Error} */ (e)?.message ?? String(e)}` };
|
|
115
|
+
} finally {
|
|
116
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
117
|
+
// always close the browser, also when the deadline fired while it was still starting or loading
|
|
118
|
+
if (opening) {
|
|
119
|
+
/** @type {NodeJS.Timeout|undefined} */
|
|
120
|
+
let w;
|
|
121
|
+
await Promise.race([opening.then((p) => p.close()).catch(() => {}), new Promise((r) => { w = setTimeout(r, closeWaitMs); })]);
|
|
122
|
+
if (w !== undefined) clearTimeout(w);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|