sentinelayer-cli 0.46.4 → 0.48.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/README.md +63 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +46 -8
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +105 -47
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/auth.js +24 -4
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +607 -57
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +28 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +322 -71
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §EW04 — HTML/JSON-LD extraction with byte offsets.
|
|
3
|
+
*
|
|
4
|
+
* Pure extractor: takes bytes of an HTML page and returns structured facts
|
|
5
|
+
* (title, description, canonical, headings, links, JSON-LD) with byte-level
|
|
6
|
+
* evidence pointing back into the source. Unparseable JSON-LD becomes an
|
|
7
|
+
* `unverified` item, never a guess. All extracted items are labelled
|
|
8
|
+
* `epistemicType: "extracted"`, distinct from `"api"`.
|
|
9
|
+
*
|
|
10
|
+
* Works entirely in UTF-8 byte offsets — never uses JavaScript string indices.
|
|
11
|
+
*
|
|
12
|
+
* Library only. Node builtins only. No new dependency. No DOM, no JS execution.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/** Unicode-whitespace test — same as /\s/ — for whitespace collapse. */
|
|
17
|
+
const WS_RE = /\s/;
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Buffer search helpers (byte-level, not string-level)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function indexOf(buf, needle, start = 0) {
|
|
24
|
+
return buf.indexOf(needle, start);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function indexOfStr(buf, str, start = 0) {
|
|
28
|
+
return buf.indexOf(Buffer.from(str, "utf-8"), start);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Lightweight HTML tag-aware scanner (byte-level)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find the first '>' outside of single- and double-quoted attribute values.
|
|
37
|
+
* Returns byte position of '>', or -1 if not found.
|
|
38
|
+
*/
|
|
39
|
+
function findGtOutsideQuotes(bytes, start, limit) {
|
|
40
|
+
const bufLen = bytes.length;
|
|
41
|
+
const end = limit !== undefined && limit < bufLen ? limit : bufLen;
|
|
42
|
+
for (let i = start; i < end; i++) {
|
|
43
|
+
const b = bytes[i];
|
|
44
|
+
if (b === 0x22) { // double quote
|
|
45
|
+
for (i++; i < end; i++) {
|
|
46
|
+
if (bytes[i] === 0x22) break;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (b === 0x27) { // single quote
|
|
51
|
+
for (i++; i < end; i++) {
|
|
52
|
+
if (bytes[i] === 0x27) break;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (b === 0x3e) return i; // '>'
|
|
57
|
+
}
|
|
58
|
+
return -1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse attributes from a tag's inner text into a Map of lowercased name -> value.
|
|
63
|
+
* Handles unquoted, single-quoted, and double-quoted values.
|
|
64
|
+
*/
|
|
65
|
+
function parseAttrs(inner) {
|
|
66
|
+
const attrs = new Map();
|
|
67
|
+
let i = 0;
|
|
68
|
+
const len = inner.length;
|
|
69
|
+
|
|
70
|
+
while (i < len) {
|
|
71
|
+
// Skip whitespace
|
|
72
|
+
while (i < len && /\s/.test(inner[i])) i++;
|
|
73
|
+
if (i >= len) break;
|
|
74
|
+
|
|
75
|
+
// Find end of attribute name — scan until whitespace, '=', or end
|
|
76
|
+
let nameEnd = i;
|
|
77
|
+
while (nameEnd < len && !/\s/.test(inner[nameEnd]) && inner[nameEnd] !== '=') nameEnd++;
|
|
78
|
+
const name = inner.slice(i, nameEnd).toLowerCase();
|
|
79
|
+
i = nameEnd;
|
|
80
|
+
|
|
81
|
+
// Skip ASCII whitespace between name and possible '='
|
|
82
|
+
while (i < len && /\s/.test(inner[i])) i++;
|
|
83
|
+
|
|
84
|
+
if (i < len && inner[i] === '=') {
|
|
85
|
+
// '=' found: read the value
|
|
86
|
+
i++; // skip '='
|
|
87
|
+
// Skip whitespace after =
|
|
88
|
+
while (i < len && /\s/.test(inner[i])) i++;
|
|
89
|
+
|
|
90
|
+
if (i >= len) {
|
|
91
|
+
if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, ''); }
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (inner[i] === '"' || inner[i] === "'") {
|
|
96
|
+
const quote = inner[i];
|
|
97
|
+
i++;
|
|
98
|
+
let valStart = i;
|
|
99
|
+
while (i < len && inner[i] !== quote) {
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
const value = inner.slice(valStart, i);
|
|
103
|
+
if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, value); }
|
|
104
|
+
if (i < len) i++; // skip closing quote
|
|
105
|
+
} else {
|
|
106
|
+
// Unquoted value
|
|
107
|
+
let valStart = i;
|
|
108
|
+
while (i < len && !/\s/.test(inner[i])) i++;
|
|
109
|
+
const value = inner.slice(valStart, i);
|
|
110
|
+
if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, value); }
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
// No '=': valueless attribute
|
|
114
|
+
if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, ''); }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return attrs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find the next HTML tag starting at or after `pos` (byte position).
|
|
123
|
+
* Returns { tagName, start, end, selfClosing, raw, attrs, comment, unclosed? } or null.
|
|
124
|
+
*/
|
|
125
|
+
function nextTag(bytes, pos) {
|
|
126
|
+
const lt = indexOfStr(bytes, "<", pos);
|
|
127
|
+
if (lt === -1) return null;
|
|
128
|
+
|
|
129
|
+
// Check for <!DOCTYPE, <!--, <![CDATA[, <!> etc before scanning for >
|
|
130
|
+
if (lt + 1 < bytes.length) {
|
|
131
|
+
const afterLt = bytes[lt + 1];
|
|
132
|
+
|
|
133
|
+
// Comment: <!-- ... --> or <!--> (empty comment)
|
|
134
|
+
if (afterLt === 0x21 && lt + 4 <= bytes.length &&
|
|
135
|
+
bytes[lt + 2] === 0x2d && bytes[lt + 3] === 0x2d) { // !--
|
|
136
|
+
// Check for <!--> (empty comment)
|
|
137
|
+
if (lt + 5 <= bytes.length && bytes[lt + 4] === 0x3e) {
|
|
138
|
+
return { tagName: null, start: lt, end: lt + 5, selfClosing: false,
|
|
139
|
+
raw: "<!-->", attrs: new Map(), comment: true };
|
|
140
|
+
}
|
|
141
|
+
// Find -->
|
|
142
|
+
const commentEnd = indexOfStr(bytes, "-->", lt + 4);
|
|
143
|
+
if (commentEnd !== -1) {
|
|
144
|
+
const raw = bytes.subarray(lt, commentEnd + 3).toString("utf-8");
|
|
145
|
+
return { tagName: null, start: lt, end: commentEnd + 3, selfClosing: false,
|
|
146
|
+
raw, attrs: new Map(), comment: true };
|
|
147
|
+
}
|
|
148
|
+
// Unclosed comment: runs to end of input
|
|
149
|
+
const raw = bytes.subarray(lt).toString("utf-8");
|
|
150
|
+
return { tagName: null, start: lt, end: bytes.length, selfClosing: false,
|
|
151
|
+
raw, attrs: new Map(), comment: true, unclosed: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// <!> (empty comment per spec)
|
|
155
|
+
if (afterLt === 0x21 && lt + 2 < bytes.length && bytes[lt + 2] === 0x3e) {
|
|
156
|
+
return { tagName: null, start: lt, end: lt + 3, selfClosing: false,
|
|
157
|
+
raw: "<!>", attrs: new Map(), comment: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// DOCTYPE or other <!...>
|
|
161
|
+
if (afterLt === 0x21 || afterLt === 0x3f) { // ! or ?
|
|
162
|
+
const gt = indexOfStr(bytes, ">", lt + 2);
|
|
163
|
+
if (gt === -1) {
|
|
164
|
+
const raw = bytes.subarray(lt).toString("utf-8");
|
|
165
|
+
return { tagName: null, start: lt, end: bytes.length, selfClosing: false,
|
|
166
|
+
raw, attrs: new Map(), comment: true, unclosed: true };
|
|
167
|
+
}
|
|
168
|
+
const raw = bytes.subarray(lt, gt + 1).toString("utf-8");
|
|
169
|
+
return { tagName: null, start: lt, end: gt + 1, selfClosing: false,
|
|
170
|
+
raw, attrs: new Map(), comment: true };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Regular tag: find '>' outside quotes
|
|
175
|
+
const gt = findGtOutsideQuotes(bytes, lt + 1);
|
|
176
|
+
if (gt === -1) return null;
|
|
177
|
+
|
|
178
|
+
const raw = bytes.subarray(lt, gt + 1).toString("utf-8");
|
|
179
|
+
const inner = raw.slice(1, -1).trim();
|
|
180
|
+
|
|
181
|
+
// Check for closing tag </name>
|
|
182
|
+
// Keep '/' prefix for closing tags to maintain compatibility
|
|
183
|
+
let tagName = null;
|
|
184
|
+
if (inner.startsWith("/")) {
|
|
185
|
+
const afterSlash = inner.slice(1).trim();
|
|
186
|
+
const spaceIdx = afterSlash.search(/\s/);
|
|
187
|
+
const name = (spaceIdx === -1 ? afterSlash : afterSlash.slice(0, spaceIdx)).toLowerCase();
|
|
188
|
+
tagName = "/" + name;
|
|
189
|
+
} else {
|
|
190
|
+
const spaceIdx = inner.search(/\s/);
|
|
191
|
+
if (spaceIdx === -1) {
|
|
192
|
+
tagName = inner.replace(/\/$/, "").toLowerCase();
|
|
193
|
+
} else {
|
|
194
|
+
tagName = inner.slice(0, spaceIdx).toLowerCase();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const attrs = parseAttrs(inner);
|
|
199
|
+
|
|
200
|
+
const selfClosing = raw.endsWith("/>") || ["br", "hr", "img", "input", "meta", "link", "area", "base", "col", "embed", "source", "track", "wbr"].includes(tagName);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
tagName,
|
|
204
|
+
start: lt,
|
|
205
|
+
end: gt + 1,
|
|
206
|
+
selfClosing,
|
|
207
|
+
raw,
|
|
208
|
+
attrs,
|
|
209
|
+
comment: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Extract an attribute value from a tag's parsed attributes Map.
|
|
215
|
+
* Exact lowercased key match only.
|
|
216
|
+
*/
|
|
217
|
+
function attrValue(attrs, attrName) {
|
|
218
|
+
return attrs.get(attrName.toLowerCase()) ?? null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Find closing tag case-insensitively at byte level.
|
|
223
|
+
* Handles </TAGNAME>, </tagname>, </Tagname>, etc.
|
|
224
|
+
* Returns byte position AFTER the closing tag, or -1 if not found.
|
|
225
|
+
*/
|
|
226
|
+
function findCloseTagCi(bytes, tagName, startPos, missCache) {
|
|
227
|
+
const lowerTag = tagName.toLowerCase();
|
|
228
|
+
const bufLen = bytes.length;
|
|
229
|
+
|
|
230
|
+
// Memoised miss: if we already know this tag has no close from >= startPos, skip
|
|
231
|
+
if (missCache) {
|
|
232
|
+
const missFrom = missCache.get(lowerTag);
|
|
233
|
+
if (missFrom !== undefined && startPos >= missFrom) {
|
|
234
|
+
return -1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (let i = startPos; i <= bufLen - 3; i++) {
|
|
239
|
+
if (bytes[i] !== 0x3c) continue; // <
|
|
240
|
+
const next = bytes[i + 1];
|
|
241
|
+
if (next !== 0x2f && next !== 0x2F) continue; // /
|
|
242
|
+
|
|
243
|
+
// Try to match the tag name case-insensitively
|
|
244
|
+
const tagStart = i + 2;
|
|
245
|
+
if (tagStart + lowerTag.length > bufLen) continue;
|
|
246
|
+
|
|
247
|
+
let matched = true;
|
|
248
|
+
for (let j = 0; j < lowerTag.length; j++) {
|
|
249
|
+
const c = bytes[tagStart + j];
|
|
250
|
+
const expected = lowerTag.charCodeAt(j);
|
|
251
|
+
// Case-insensitive ASCII match
|
|
252
|
+
if ((c | 0x20) !== (expected | 0x20)) { matched = false; break; }
|
|
253
|
+
}
|
|
254
|
+
if (!matched) continue;
|
|
255
|
+
|
|
256
|
+
// Check for > after tag name (optionally with whitespace)
|
|
257
|
+
let after = tagStart + lowerTag.length;
|
|
258
|
+
while (after < bufLen && (bytes[after] === 0x20 || bytes[after] === 0x09 || bytes[after] === 0x0a || bytes[after] === 0x0d)) {
|
|
259
|
+
after++;
|
|
260
|
+
}
|
|
261
|
+
if (after < bufLen && bytes[after] === 0x3e) {
|
|
262
|
+
return after + 1; // past the >
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Record miss: any search from >= startPos for this tag will also miss
|
|
267
|
+
if (missCache) {
|
|
268
|
+
const prev = missCache.get(lowerTag);
|
|
269
|
+
if (prev === undefined || startPos < prev) {
|
|
270
|
+
missCache.set(lowerTag, startPos);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return -1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Entity decoding — single left-to-right pass
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
const NAMED_ENTITIES = Object.assign(Object.create(null), {
|
|
281
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00A0",
|
|
282
|
+
iexcl: "\u00A1", cent: "\u00A2", pound: "\u00A3", curren: "\u00A4",
|
|
283
|
+
yen: "\u00A5", brvbar: "\u00A6", sect: "\u00A7", uml: "\u00A8",
|
|
284
|
+
copy: "\u00A9", ordf: "\u00AA", laquo: "\u00AB", not: "\u00AC",
|
|
285
|
+
shy: "\u00AD", reg: "\u00AE", macr: "\u00AF", deg: "\u00B0",
|
|
286
|
+
plusmn: "\u00B1", sup2: "\u00B2", sup3: "\u00B3", acute: "\u00B4",
|
|
287
|
+
micro: "\u00B5", para: "\u00B6", middot: "\u00B7", cedil: "\u00B8",
|
|
288
|
+
sup1: "\u00B9", ordm: "\u00BA", raquo: "\u00BB", frac14: "\u00BC",
|
|
289
|
+
frac12: "\u00BD", frac34: "\u00BE", iquest: "\u00BF",
|
|
290
|
+
times: "\u00D7", divide: "\u00F7",
|
|
291
|
+
Agrave: "\u00C0", Aacute: "\u00C1", Acirc: "\u00C2", Atilde: "\u00C3",
|
|
292
|
+
Auml: "\u00C4", Aring: "\u00C5", AElig: "\u00C6", Ccedil: "\u00C7",
|
|
293
|
+
Egrave: "\u00C8", Eacute: "\u00C9", Ecirc: "\u00CA", Euml: "\u00CB",
|
|
294
|
+
Igrave: "\u00CC", Iacute: "\u00CD", Icirc: "\u00CE", Iuml: "\u00CF",
|
|
295
|
+
ETH: "\u00D0", Ntilde: "\u00D1", Ograve: "\u00D2", Oacute: "\u00D3",
|
|
296
|
+
Ocirc: "\u00D4", Otilde: "\u00D5", Ouml: "\u00D6", Oslash: "\u00D8",
|
|
297
|
+
Ugrave: "\u00D9", Uacute: "\u00DA", Ucirc: "\u00DB", Uuml: "\u00DC",
|
|
298
|
+
Yacute: "\u00DD", THORN: "\u00DE", szlig: "\u00DF",
|
|
299
|
+
agrave: "\u00E0", aacute: "\u00E1", acirc: "\u00E2", atilde: "\u00E3",
|
|
300
|
+
auml: "\u00E4", aring: "\u00E5", aelig: "\u00E6", ccedil: "\u00E7",
|
|
301
|
+
egrave: "\u00E8", eacute: "\u00E9", ecirc: "\u00EA", euml: "\u00EB",
|
|
302
|
+
igrave: "\u00EC", iacute: "\u00ED", icirc: "\u00EE", iuml: "\u00EF",
|
|
303
|
+
eth: "\u00F0", ntilde: "\u00F1", ograve: "\u00F2", oacute: "\u00F3",
|
|
304
|
+
ocirc: "\u00F4", otilde: "\u00F5", ouml: "\u00F6", oslash: "\u00F8",
|
|
305
|
+
ugrave: "\u00F9", uacute: "\u00FA", ucirc: "\u00FB", uuml: "\u00FC",
|
|
306
|
+
yacute: "\u00FD", thorn: "\u00FE", yuml: "\u00FF",
|
|
307
|
+
OElig: "\u0152", oelig: "\u0153", Scaron: "\u0160", scaron: "\u0161",
|
|
308
|
+
Yuml: "\u0178", fnof: "\u0192", circ: "\u02C6", tilde: "\u02DC",
|
|
309
|
+
Alpha: "\u0391", Beta: "\u0392", Gamma: "\u0393", Delta: "\u0394",
|
|
310
|
+
Epsilon: "\u0395", Zeta: "\u0396", Eta: "\u0397", Theta: "\u0398",
|
|
311
|
+
Iota: "\u0399", Kappa: "\u039A", Lambda: "\u039B", Mu: "\u039C",
|
|
312
|
+
Nu: "\u039D", Xi: "\u039E", Omicron: "\u039F", Pi: "\u03A0",
|
|
313
|
+
Rho: "\u03A1", Sigma: "\u03A3", Tau: "\u03A4", Upsilon: "\u03A5",
|
|
314
|
+
Phi: "\u03A6", Chi: "\u03A7", Psi: "\u03A8", Omega: "\u03A9",
|
|
315
|
+
alpha: "\u03B1", beta: "\u03B2", gamma: "\u03B3", delta: "\u03B4",
|
|
316
|
+
epsilon: "\u03B5", zeta: "\u03B6", eta: "\u03B7", theta: "\u03B8",
|
|
317
|
+
iota: "\u03B9", kappa: "\u03BA", lambda: "\u03BB", mu: "\u03BC",
|
|
318
|
+
nu: "\u03BD", xi: "\u03BE", omicron: "\u03BF", pi: "\u03C0",
|
|
319
|
+
rho: "\u03C1", sigmaf: "\u03C2", sigma: "\u03C3", tau: "\u03C4",
|
|
320
|
+
upsilon: "\u03C5", phi: "\u03C6", chi: "\u03C7", psi: "\u03C8",
|
|
321
|
+
omega: "\u03C9", thetasym: "\u03D1", upsih: "\u03D2", piv: "\u03D6",
|
|
322
|
+
ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009", zwnj: "\u200C",
|
|
323
|
+
zwj: "\u200D", lrm: "\u200E", rlm: "\u200F", ndash: "\u2013",
|
|
324
|
+
mdash: "\u2014", lsquo: "\u2018", rsquo: "\u2019", sbquo: "\u201A",
|
|
325
|
+
ldquo: "\u201C", rdquo: "\u201D", bdquo: "\u201E", dagger: "\u2020",
|
|
326
|
+
Dagger: "\u2021", bull: "\u2022", hellip: "\u2026", permil: "\u2030",
|
|
327
|
+
prime: "\u2032", Prime: "\u2033", lsaquo: "\u2039", rsaquo: "\u203A",
|
|
328
|
+
oline: "\u203E", frasl: "\u2044", euro: "\u20AC", image: "\u2111",
|
|
329
|
+
weierp: "\u2118", real: "\u211C", trade: "\u2122", alefsym: "\u2135",
|
|
330
|
+
larr: "\u2190", uarr: "\u2191", rarr: "\u2192", darr: "\u2193",
|
|
331
|
+
harr: "\u2194", crarr: "\u21B5", lArr: "\u21D0", uArr: "\u21D1",
|
|
332
|
+
rArr: "\u21D2", dArr: "\u21D3", hArr: "\u21D4", forall: "\u2200",
|
|
333
|
+
part: "\u2202", exist: "\u2203", empty: "\u2205", nabla: "\u2207",
|
|
334
|
+
isin: "\u2208", notin: "\u2209", ni: "\u220B", prod: "\u220F",
|
|
335
|
+
sum: "\u2211", minus: "\u2212", lowast: "\u2217", radic: "\u221A",
|
|
336
|
+
prop: "\u221D", infin: "\u221E", ang: "\u2220", and: "\u2227",
|
|
337
|
+
or: "\u2228", cap: "\u2229", cup: "\u222A", int: "\u222B",
|
|
338
|
+
there4: "\u2234", sim: "\u223C", cong: "\u2245", asymp: "\u2248",
|
|
339
|
+
ne: "\u2260", equiv: "\u2261", le: "\u2264", ge: "\u2265",
|
|
340
|
+
sub: "\u2282", sup: "\u2283", nsub: "\u2284", sube: "\u2286",
|
|
341
|
+
supe: "\u2287", oplus: "\u2295", otimes: "\u2297", perp: "\u22A5",
|
|
342
|
+
sdot: "\u22C5", lceil: "\u2308", rceil: "\u2309", lfloor: "\u230A",
|
|
343
|
+
rfloor: "\u230B", lang: "\u2329", rang: "\u232A", loz: "\u25CA",
|
|
344
|
+
spades: "\u2660", clubs: "\u2663", hearts: "\u2665", diams: "\u2666",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Decode HTML entities in a single left-to-right pass.
|
|
349
|
+
* - Named entities: decode if known, stay literal if unknown.
|
|
350
|
+
* - Numeric entities: decode if valid (1..0x10FFFF, non-surrogate), else U+FFFD.
|
|
351
|
+
* - Never throws. One pass only (no double-decoding).
|
|
352
|
+
*/
|
|
353
|
+
function decodeEntities(str) {
|
|
354
|
+
let out = '';
|
|
355
|
+
let i = 0;
|
|
356
|
+
const len = str.length;
|
|
357
|
+
|
|
358
|
+
while (i < len) {
|
|
359
|
+
if (str[i] === '&') {
|
|
360
|
+
// Look for ';'
|
|
361
|
+
let semiIdx = -1;
|
|
362
|
+
for (let j = i + 1; j < len && j < i + 32; j++) {
|
|
363
|
+
if (str[j] === ';') { semiIdx = j; break; }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (semiIdx === -1) {
|
|
367
|
+
// No semicolon: not an entity, emit & and continue
|
|
368
|
+
out += '&';
|
|
369
|
+
i++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const entity = str.slice(i + 1, semiIdx);
|
|
374
|
+
|
|
375
|
+
if (entity.length === 0) {
|
|
376
|
+
out += '&;';
|
|
377
|
+
i = semiIdx + 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Numeric entity
|
|
382
|
+
if (entity[0] === '#') {
|
|
383
|
+
const rest = entity.slice(1);
|
|
384
|
+
let codepoint;
|
|
385
|
+
if (rest.length > 0 && (rest[0] === 'x' || rest[0] === 'X')) {
|
|
386
|
+
codepoint = parseInt(rest.slice(1), 16);
|
|
387
|
+
} else {
|
|
388
|
+
codepoint = parseInt(rest, 10);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (Number.isNaN(codepoint) || codepoint < 1 || codepoint > 0x10FFFF ||
|
|
392
|
+
(codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
|
|
393
|
+
out += '\uFFFD';
|
|
394
|
+
} else {
|
|
395
|
+
out += String.fromCodePoint(codepoint);
|
|
396
|
+
}
|
|
397
|
+
i = semiIdx + 1;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Named entity
|
|
402
|
+
const lowerEntity = entity.toLowerCase();
|
|
403
|
+
const decoded = NAMED_ENTITIES[entity] ?? NAMED_ENTITIES[lowerEntity];
|
|
404
|
+
if (decoded !== undefined) {
|
|
405
|
+
out += decoded;
|
|
406
|
+
} else {
|
|
407
|
+
// Unknown named entity: stay literal
|
|
408
|
+
out += '&' + entity + ';';
|
|
409
|
+
}
|
|
410
|
+
i = semiIdx + 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
out += str[i];
|
|
415
|
+
i++;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Element categories per HTML spec
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
const RAW_TEXT_ELEMENTS = new Set([
|
|
426
|
+
"script", "style", "xmp", "iframe", "noembed", "noframes",
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
const RCDATA_ELEMENTS = new Set([
|
|
430
|
+
"title", "textarea",
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Text extraction between two byte positions
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
function textBetween(bytes, start, end) {
|
|
438
|
+
// Walk bytes directly with one forward cursor p from start to end.
|
|
439
|
+
// On 0x3C ('<'): skip the tag (findGtOutsideQuotes bounded by end).
|
|
440
|
+
// Each text run between tags is decoded ONCE via bytes.toString('utf-8', runStart, runEnd).
|
|
441
|
+
// 0x3C never occurs inside a UTF-8 multi-byte sequence, so scanning bytes is safe.
|
|
442
|
+
let content = "";
|
|
443
|
+
let lastWasSpace = false;
|
|
444
|
+
let p = start;
|
|
445
|
+
|
|
446
|
+
while (p < end) {
|
|
447
|
+
const b = bytes[p];
|
|
448
|
+
if (b === 0x3C) { // '<' - start of a tag
|
|
449
|
+
const gt = findGtOutsideQuotes(bytes, p + 1, end);
|
|
450
|
+
if (gt === -1) break; // unclosed tag: drop rest of span
|
|
451
|
+
p = gt + 1; // skip past '>'
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
// Text byte — accumulate until next '<' or end
|
|
455
|
+
const runStart = p;
|
|
456
|
+
while (p < end && bytes[p] !== 0x3C) p++;
|
|
457
|
+
// Decode this text run once
|
|
458
|
+
const runStr = bytes.toString('utf-8', runStart, p);
|
|
459
|
+
for (let i = 0; i < runStr.length; i++) {
|
|
460
|
+
const ch = runStr[i];
|
|
461
|
+
if (WS_RE.test(ch)) {
|
|
462
|
+
if (!lastWasSpace && content.length > 0) {
|
|
463
|
+
content += " ";
|
|
464
|
+
lastWasSpace = true;
|
|
465
|
+
}
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
content += ch;
|
|
469
|
+
lastWasSpace = false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return decodeEntities(content.trim());
|
|
474
|
+
}
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// Main extractor
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* extractFromHtml(bytes: Buffer) → extracted page facts with byte-level evidence.
|
|
481
|
+
*
|
|
482
|
+
* Never throws on any input. Returns an object with:
|
|
483
|
+
* title, description, canonical, headings[], links[], jsonLd[]
|
|
484
|
+
*/
|
|
485
|
+
export function extractFromHtml(bytes) {
|
|
486
|
+
try {
|
|
487
|
+
return extractFromHtmlImpl(bytes);
|
|
488
|
+
} catch {
|
|
489
|
+
return emptyResult();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function extractFromHtmlImpl(bytes) {
|
|
494
|
+
const missCache = new Map();
|
|
495
|
+
const result = {
|
|
496
|
+
title: null,
|
|
497
|
+
description: null,
|
|
498
|
+
canonical: null,
|
|
499
|
+
headings: [],
|
|
500
|
+
links: [],
|
|
501
|
+
jsonLd: [],
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
let headTitle = null;
|
|
505
|
+
let fallbackTitle = null;
|
|
506
|
+
let inHead = false;
|
|
507
|
+
let inSvg = false;
|
|
508
|
+
let inMath = false;
|
|
509
|
+
let pos = 0;
|
|
510
|
+
|
|
511
|
+
while (pos < bytes.length) {
|
|
512
|
+
const tag = nextTag(bytes, pos);
|
|
513
|
+
if (!tag) break;
|
|
514
|
+
|
|
515
|
+
// Skip comments
|
|
516
|
+
if (tag.comment) {
|
|
517
|
+
pos = tag.end;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const tagName = tag.tagName;
|
|
522
|
+
if (!tagName) { pos = tag.end; continue; }
|
|
523
|
+
|
|
524
|
+
// Track head / svg / math context
|
|
525
|
+
if (tagName === "head") { inHead = true; pos = tag.end; continue; }
|
|
526
|
+
if (tagName === "/head") { inHead = false; pos = tag.end; continue; }
|
|
527
|
+
if (tagName === "svg") { inSvg = true; pos = tag.end; continue; }
|
|
528
|
+
if (tagName === "/svg") { inSvg = false; pos = tag.end; continue; }
|
|
529
|
+
if (tagName === "math") { inMath = true; pos = tag.end; continue; }
|
|
530
|
+
if (tagName === "/math") { inMath = false; pos = tag.end; continue; }
|
|
531
|
+
|
|
532
|
+
// --- Structural skip for raw-text and RCDATA elements ---
|
|
533
|
+
if (RAW_TEXT_ELEMENTS.has(tagName)) {
|
|
534
|
+
const closeIdx = findCloseTagCi(bytes, tagName, tag.end, missCache);
|
|
535
|
+
|
|
536
|
+
if (tagName === "script") {
|
|
537
|
+
const typeAttr = attrValue(tag.attrs, "type");
|
|
538
|
+
if (typeAttr === "application/ld+json") {
|
|
539
|
+
// JSON-LD script: extract content between tags
|
|
540
|
+
let contentEnd = closeIdx;
|
|
541
|
+
if (contentEnd === -1) {
|
|
542
|
+
contentEnd = bytes.length;
|
|
543
|
+
} else {
|
|
544
|
+
for (let ci = contentEnd - 1; ci >= tag.end; ci--) {
|
|
545
|
+
if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const jsonText = bytes.toString("utf-8", tag.end, contentEnd).trim();
|
|
549
|
+
const evidenceEnd = closeIdx === -1 ? bytes.length : closeIdx;
|
|
550
|
+
const evidence = { start: tag.start, end: evidenceEnd };
|
|
551
|
+
let data = null;
|
|
552
|
+
let verified = false;
|
|
553
|
+
try {
|
|
554
|
+
data = JSON.parse(jsonText);
|
|
555
|
+
verified = true;
|
|
556
|
+
} catch {
|
|
557
|
+
// unparseable: store as unverified
|
|
558
|
+
}
|
|
559
|
+
result.jsonLd.push({
|
|
560
|
+
data,
|
|
561
|
+
raw: jsonText,
|
|
562
|
+
verified,
|
|
563
|
+
evidence,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (closeIdx !== -1) {
|
|
569
|
+
pos = closeIdx;
|
|
570
|
+
} else {
|
|
571
|
+
pos = bytes.length;
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (RCDATA_ELEMENTS.has(tagName)) {
|
|
577
|
+
const closeIdx = findCloseTagCi(bytes, tagName, tag.end, missCache);
|
|
578
|
+
let contentEnd = closeIdx;
|
|
579
|
+
if (contentEnd === -1) {
|
|
580
|
+
contentEnd = bytes.length;
|
|
581
|
+
} else {
|
|
582
|
+
for (let ci = contentEnd - 1; ci >= tag.end; ci--) {
|
|
583
|
+
if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (tagName === "title") {
|
|
588
|
+
if (!inSvg && !inMath) {
|
|
589
|
+
const rawTitleBytes = bytes.subarray(tag.end, contentEnd);
|
|
590
|
+
const titleText = decodeEntities(rawTitleBytes.toString("utf-8").trim());
|
|
591
|
+
if (inHead) {
|
|
592
|
+
headTitle = titleText;
|
|
593
|
+
} else if (fallbackTitle === null) {
|
|
594
|
+
fallbackTitle = titleText;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (closeIdx !== -1) {
|
|
600
|
+
pos = closeIdx;
|
|
601
|
+
} else {
|
|
602
|
+
pos = bytes.length;
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
// --- End structural skip ---
|
|
607
|
+
|
|
608
|
+
if (tagName === "meta") {
|
|
609
|
+
const name = attrValue(tag.attrs, "name") || attrValue(tag.attrs, "property");
|
|
610
|
+
const content = attrValue(tag.attrs, "content");
|
|
611
|
+
if (name === "description" && content) {
|
|
612
|
+
result.description = decodeEntities(content);
|
|
613
|
+
}
|
|
614
|
+
pos = tag.end;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (tagName === "link") {
|
|
619
|
+
const rel = attrValue(tag.attrs, "rel");
|
|
620
|
+
if (rel === "canonical") {
|
|
621
|
+
const href = attrValue(tag.attrs, "href");
|
|
622
|
+
if (href) result.canonical = href;
|
|
623
|
+
}
|
|
624
|
+
pos = tag.end;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (/^h[1-6]$/.test(tagName)) {
|
|
629
|
+
const level = parseInt(tagName[1], 10);
|
|
630
|
+
const closeEnd = findCloseTagCi(bytes, tagName, tag.end, missCache);
|
|
631
|
+
if (closeEnd !== -1) {
|
|
632
|
+
let contentEnd = closeEnd;
|
|
633
|
+
for (let ci = closeEnd - 1; ci >= tag.end; ci--) {
|
|
634
|
+
if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
|
|
635
|
+
}
|
|
636
|
+
const headingText = textBetween(bytes, tag.end, contentEnd);
|
|
637
|
+
result.headings.push({
|
|
638
|
+
level,
|
|
639
|
+
text: headingText,
|
|
640
|
+
evidence: { start: tag.start, end: closeEnd },
|
|
641
|
+
});
|
|
642
|
+
pos = closeEnd;
|
|
643
|
+
} else {
|
|
644
|
+
pos = tag.end;
|
|
645
|
+
}
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (tagName === "a") {
|
|
650
|
+
const href = attrValue(tag.attrs, "href");
|
|
651
|
+
const rel = attrValue(tag.attrs, "rel");
|
|
652
|
+
if (!href) {
|
|
653
|
+
pos = tag.end;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const closeEnd = findCloseTagCi(bytes, "a", tag.end, missCache);
|
|
657
|
+
if (closeEnd === -1) {
|
|
658
|
+
pos = tag.end;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
let contentEnd = closeEnd;
|
|
662
|
+
for (let ci = closeEnd - 1; ci >= tag.end; ci--) {
|
|
663
|
+
if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
|
|
664
|
+
}
|
|
665
|
+
const linkText = textBetween(bytes, tag.end, contentEnd);
|
|
666
|
+
result.links.push({
|
|
667
|
+
href,
|
|
668
|
+
rel,
|
|
669
|
+
text: linkText,
|
|
670
|
+
evidence: { start: tag.start, end: closeEnd },
|
|
671
|
+
});
|
|
672
|
+
pos = closeEnd;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
pos = tag.end;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
result.title = headTitle ?? fallbackTitle;
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function emptyResult() {
|
|
684
|
+
return {
|
|
685
|
+
title: null,
|
|
686
|
+
description: null,
|
|
687
|
+
canonical: null,
|
|
688
|
+
headings: [],
|
|
689
|
+
links: [],
|
|
690
|
+
jsonLd: [],
|
|
691
|
+
};
|
|
692
|
+
}
|