openbriefing 0.1.1 → 0.3.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 +18 -8
- package/dist/cli.js +249 -50
- package/dist/fetch.d.ts +33 -0
- package/dist/fetch.js +122 -0
- package/dist/obs.d.ts +41 -4
- package/dist/obs.js +308 -30
- package/fixtures/expected.json +636 -37
- package/fixtures/feed-modified-from-pubdate.xml +21 -0
- package/fixtures/feed-modified-from-updated.xml +19 -0
- package/fixtures/feed-rss.xml +2 -0
- package/fixtures/highlight-label-orphan.html +15 -0
- package/fixtures/highlight-limits.html +20 -0
- package/fixtures/highlight-not-in-text.html +17 -0
- package/fixtures/highlight-reserved.html +16 -0
- package/fixtures/highlights.html +18 -0
- package/fixtures/modified-bad-then-article.html +14 -0
- package/fixtures/modified-from-article.html +13 -0
- package/fixtures/modified-obs-over-article.html +14 -0
- package/fixtures/unescaped-quote.html +12 -0
- package/fixtures/valid-full.html +3 -0
- package/package.json +5 -1
package/dist/obs.js
CHANGED
|
@@ -15,6 +15,9 @@ export const SIZES = {
|
|
|
15
15
|
keep: 40,
|
|
16
16
|
attribution: 12,
|
|
17
17
|
keepMax: 3,
|
|
18
|
+
highlight: 60,
|
|
19
|
+
highlightLabel: 6,
|
|
20
|
+
highlightMax: 5,
|
|
18
21
|
tiers: {
|
|
19
22
|
short: { target: [40, 80] },
|
|
20
23
|
medium: { target: [200, 350] },
|
|
@@ -36,6 +39,7 @@ export function countWords(text) {
|
|
|
36
39
|
const t = text.trim();
|
|
37
40
|
return t === "" ? 0 : t.split(/\s+/u).length;
|
|
38
41
|
}
|
|
42
|
+
const newBag = () => ({ props: new Map(), highlights: [] });
|
|
39
43
|
const KNOWN = new Set([
|
|
40
44
|
"obs:modified",
|
|
41
45
|
"obs:summary",
|
|
@@ -45,6 +49,8 @@ const KNOWN = new Set([
|
|
|
45
49
|
"obs:title",
|
|
46
50
|
"obs:headline",
|
|
47
51
|
"obs:keep",
|
|
52
|
+
"obs:highlight",
|
|
53
|
+
"obs:highlight:label",
|
|
48
54
|
"obs:derive",
|
|
49
55
|
"obs:derive:translate",
|
|
50
56
|
"obs:derive:combine",
|
|
@@ -55,14 +61,33 @@ const KNOWN = new Set([
|
|
|
55
61
|
"obs:version",
|
|
56
62
|
]);
|
|
57
63
|
function put(bag, key, value) {
|
|
58
|
-
const list = bag.get(key);
|
|
64
|
+
const list = bag.props.get(key);
|
|
59
65
|
if (list)
|
|
60
66
|
list.push(value);
|
|
61
67
|
else
|
|
62
|
-
bag.set(key, [value]);
|
|
68
|
+
bag.props.set(key, [value]);
|
|
63
69
|
}
|
|
70
|
+
/** Files one property, routing highlights and their labels to the ordered list (§3.1). */
|
|
71
|
+
function collect(bag, key, value, warnings) {
|
|
72
|
+
if (key === "obs:highlight") {
|
|
73
|
+
bag.highlights.push({ text: value });
|
|
74
|
+
}
|
|
75
|
+
else if (key === "obs:highlight:label") {
|
|
76
|
+
const last = bag.highlights[bag.highlights.length - 1];
|
|
77
|
+
if (last === undefined)
|
|
78
|
+
warnings.push("obs:highlight:label before any obs:highlight; it is ignored (§3.1)");
|
|
79
|
+
else if (last.label === undefined)
|
|
80
|
+
last.label = value;
|
|
81
|
+
else
|
|
82
|
+
warnings.push(`obs:highlight #${bag.highlights.length} has two labels; the first is used (§3.1)`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
put(bag, key, value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const isEmpty = (bag) => bag.props.size === 0 && bag.highlights.length === 0;
|
|
64
89
|
function first(bag, key, warnings) {
|
|
65
|
-
const list = bag.get(key);
|
|
90
|
+
const list = bag.props.get(key);
|
|
66
91
|
if (!list || list.length === 0)
|
|
67
92
|
return undefined;
|
|
68
93
|
if (list.length > 1 && key !== "obs:keep") {
|
|
@@ -77,14 +102,14 @@ function cleanText(key, raw, warnings) {
|
|
|
77
102
|
let text = raw;
|
|
78
103
|
if (TAG_RE.test(text)) {
|
|
79
104
|
text = text.replace(TAG_RE, "");
|
|
80
|
-
warnings.push(`${key}: HTML removed; values are plain text (§
|
|
105
|
+
warnings.push(`${key}: HTML removed; values are plain text (§9)`);
|
|
81
106
|
}
|
|
82
107
|
TAG_RE.lastIndex = 0;
|
|
83
108
|
text = text.replace(/\r\n?/gu, "\n").trim();
|
|
84
109
|
if (text === "")
|
|
85
110
|
return undefined;
|
|
86
111
|
if (URL_RE.test(text))
|
|
87
|
-
warnings.push(`${key}: contains a URL; summaries should not (§
|
|
112
|
+
warnings.push(`${key}: contains a URL; summaries should not (§9)`);
|
|
88
113
|
return text;
|
|
89
114
|
}
|
|
90
115
|
function enumValue(key, raw, allowed, fallback, warnings) {
|
|
@@ -102,7 +127,7 @@ function validModified(v) {
|
|
|
102
127
|
return ISO_RE.test(v) && !Number.isNaN(Date.parse(v));
|
|
103
128
|
}
|
|
104
129
|
const TRACKING = /^(?:utm_.*|fbclid|gclid)$/iu;
|
|
105
|
-
/** URL comparison of §
|
|
130
|
+
/** URL comparison of §12: scheme and host case, trailing slash, tracking parameters. */
|
|
106
131
|
export function normalizeUrl(input, base) {
|
|
107
132
|
let u;
|
|
108
133
|
try {
|
|
@@ -130,11 +155,13 @@ function sizeWarning(key, words, max, warnings) {
|
|
|
130
155
|
function build(bag, ctx, extraWarnings = []) {
|
|
131
156
|
const errors = [];
|
|
132
157
|
const warnings = [...extraWarnings];
|
|
133
|
-
for (const key of bag.keys()) {
|
|
158
|
+
for (const key of bag.props.keys()) {
|
|
134
159
|
if (KNOWN.has(key))
|
|
135
160
|
continue;
|
|
136
161
|
if (key.startsWith("obs:audio:") || key === "obs:audio")
|
|
137
|
-
continue; // reserved, §
|
|
162
|
+
continue; // reserved, §15
|
|
163
|
+
if (key === "obs:highlight:start" || key === "obs:highlight:end")
|
|
164
|
+
continue; // reserved, §15
|
|
138
165
|
warnings.push(`${key} is not an OBS v1 property and is ignored (§3.1)`);
|
|
139
166
|
}
|
|
140
167
|
// Text values, cleaned once.
|
|
@@ -156,13 +183,22 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
156
183
|
warnings.push(`obs:version ${n} is newer than this reader (v1); unknown tags are ignored`);
|
|
157
184
|
}
|
|
158
185
|
}
|
|
159
|
-
// obs:modified
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
186
|
+
// The effective modified time (§13): obs:modified, else the carrier's own date, else none.
|
|
187
|
+
let modified;
|
|
188
|
+
let modifiedFrom = "none";
|
|
189
|
+
const rawModified = first(bag, "obs:modified", warnings)?.trim();
|
|
190
|
+
if (rawModified !== undefined && rawModified !== "") {
|
|
191
|
+
if (validModified(rawModified)) {
|
|
192
|
+
modified = rawModified;
|
|
193
|
+
modifiedFrom = "obs";
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
warnings.push(`obs:modified: "${rawModified}" is not an ISO 8601 date and time; it is ignored (§13)`);
|
|
197
|
+
}
|
|
163
198
|
}
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
if (modified === undefined && ctx.alt !== undefined && validModified(ctx.alt.value)) {
|
|
200
|
+
modified = ctx.alt.value;
|
|
201
|
+
modifiedFrom = ctx.alt.from;
|
|
166
202
|
}
|
|
167
203
|
// Summaries (§4)
|
|
168
204
|
const tiered = new Map();
|
|
@@ -199,7 +235,7 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
199
235
|
if (headline !== undefined)
|
|
200
236
|
sizeWarning("obs:headline", countWords(headline), SIZES.headline, warnings);
|
|
201
237
|
// Keep lines (§6)
|
|
202
|
-
const keepAll = (bag.get("obs:keep") ?? [])
|
|
238
|
+
const keepAll = (bag.props.get("obs:keep") ?? [])
|
|
203
239
|
.map((raw) => cleanText("obs:keep", raw, warnings))
|
|
204
240
|
.filter((v) => v !== undefined);
|
|
205
241
|
if (keepAll.length > SIZES.keepMax) {
|
|
@@ -207,6 +243,31 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
207
243
|
}
|
|
208
244
|
const keep = keepAll.slice(0, SIZES.keepMax);
|
|
209
245
|
keep.forEach((k, i) => sizeWarning(`obs:keep #${i + 1}`, countWords(k), SIZES.keep, warnings));
|
|
246
|
+
// Highlights (§7)
|
|
247
|
+
const highlightsAll = [];
|
|
248
|
+
for (const raw of bag.highlights) {
|
|
249
|
+
const t = cleanText("obs:highlight", raw.text, warnings);
|
|
250
|
+
if (t === undefined)
|
|
251
|
+
continue;
|
|
252
|
+
const h = { text: t, words: countWords(t) };
|
|
253
|
+
if (raw.label !== undefined) {
|
|
254
|
+
const label = cleanText("obs:highlight:label", raw.label, warnings);
|
|
255
|
+
if (label !== undefined)
|
|
256
|
+
h.label = label;
|
|
257
|
+
}
|
|
258
|
+
if (typeof raw.inContent === "boolean")
|
|
259
|
+
h.inContent = raw.inContent;
|
|
260
|
+
highlightsAll.push(h);
|
|
261
|
+
}
|
|
262
|
+
if (highlightsAll.length > SIZES.highlightMax) {
|
|
263
|
+
warnings.push(`obs:highlight appears ${highlightsAll.length} times; only the first ${SIZES.highlightMax} are used (§3)`);
|
|
264
|
+
}
|
|
265
|
+
const highlights = highlightsAll.slice(0, SIZES.highlightMax);
|
|
266
|
+
highlights.forEach((h, i) => {
|
|
267
|
+
sizeWarning(`obs:highlight #${i + 1}`, h.words, SIZES.highlight, warnings);
|
|
268
|
+
if (h.label !== undefined)
|
|
269
|
+
sizeWarning(`obs:highlight:label #${i + 1}`, countWords(h.label), SIZES.highlightLabel, warnings);
|
|
270
|
+
});
|
|
210
271
|
// Derivation (§5)
|
|
211
272
|
const mode = enumValue("obs:derive", first(bag, "obs:derive", warnings), ["verbatim", "anchor", "open"], "anchor", warnings);
|
|
212
273
|
const translate = enumValue("obs:derive:translate", first(bag, "obs:derive:translate", warnings), ["yes", "no"], "yes", warnings) === "yes";
|
|
@@ -216,7 +277,7 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
216
277
|
sizeWarning("obs:attribution", countWords(attribution), SIZES.attribution, warnings);
|
|
217
278
|
const preference = enumValue("obs:preference", first(bag, "obs:preference", warnings), ["summary", "full"], "summary", warnings);
|
|
218
279
|
const lang = first(bag, "obs:lang", warnings)?.trim() || ctx.lang || undefined;
|
|
219
|
-
// Identity (§
|
|
280
|
+
// Identity (§12)
|
|
220
281
|
let url;
|
|
221
282
|
let describesThisPage = true;
|
|
222
283
|
const rawUrl = first(bag, "obs:url", warnings)?.trim();
|
|
@@ -231,10 +292,12 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
231
292
|
}
|
|
232
293
|
const brief = {
|
|
233
294
|
version,
|
|
234
|
-
modified:
|
|
295
|
+
...(modified !== undefined ? { modified } : {}),
|
|
296
|
+
modifiedFrom,
|
|
235
297
|
describesThisPage,
|
|
236
298
|
summaries,
|
|
237
299
|
keep,
|
|
300
|
+
highlights,
|
|
238
301
|
derive: { mode, translate, combine },
|
|
239
302
|
preference,
|
|
240
303
|
};
|
|
@@ -250,6 +313,34 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
250
313
|
brief.lang = lang;
|
|
251
314
|
return { brief, errors, warnings };
|
|
252
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* The loose comparison of §7: compatibility forms, straight quotes for curly,
|
|
318
|
+
* one kind of dash, one kind of space, one case. Words themselves must match.
|
|
319
|
+
*/
|
|
320
|
+
export function looseText(s) {
|
|
321
|
+
return s
|
|
322
|
+
.normalize("NFKC")
|
|
323
|
+
.replace(/[\u2018\u2019\u201a\u201b\u2032]/gu, "'")
|
|
324
|
+
.replace(/[\u201c\u201d\u201e\u201f\u2033]/gu, '"')
|
|
325
|
+
.replace(/[\u2010-\u2015\u2212]/gu, "-")
|
|
326
|
+
.replace(/\s*-\s*/gu, "-")
|
|
327
|
+
.replace(/\s+/gu, " ")
|
|
328
|
+
.trim()
|
|
329
|
+
.toLowerCase();
|
|
330
|
+
}
|
|
331
|
+
/** Sets inContent on each highlight from the page's text; skipped when the body is empty (§7). */
|
|
332
|
+
function checkContent(brief, bodyText, warnings) {
|
|
333
|
+
const body = bodyText === undefined ? "" : looseText(bodyText);
|
|
334
|
+
if (body === "")
|
|
335
|
+
return;
|
|
336
|
+
for (const h of brief.highlights) {
|
|
337
|
+
h.inContent = body.includes(looseText(h.text));
|
|
338
|
+
if (!h.inContent) {
|
|
339
|
+
const head = h.text.split(/\s+/u).slice(0, 6).join(" ");
|
|
340
|
+
warnings.push(`obs:highlight "${head}…" is not in the page's text (§7)`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
253
344
|
function guard(fn) {
|
|
254
345
|
try {
|
|
255
346
|
return fn();
|
|
@@ -260,13 +351,24 @@ function guard(fn) {
|
|
|
260
351
|
}
|
|
261
352
|
// ---------------------------------------------------------------------------
|
|
262
353
|
// Pages (§3.1)
|
|
354
|
+
function metaContent(doc, key) {
|
|
355
|
+
for (const meta of Array.from(doc.getElementsByTagName("meta"))) {
|
|
356
|
+
const k = (meta.getAttribute("property") ?? meta.getAttribute("name") ?? "").trim().toLowerCase();
|
|
357
|
+
if (k !== key)
|
|
358
|
+
continue;
|
|
359
|
+
const v = meta.getAttribute("content")?.trim();
|
|
360
|
+
if (v)
|
|
361
|
+
return v;
|
|
362
|
+
}
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
263
365
|
/**
|
|
264
366
|
* Reads a page's obs: meta tags. `pageUrl` is where the page was fetched
|
|
265
367
|
* from; the page's <link rel="canonical"> takes precedence for §11.
|
|
266
368
|
*/
|
|
267
369
|
export function parseObs(doc, pageUrl) {
|
|
268
370
|
return guard(() => {
|
|
269
|
-
const bag =
|
|
371
|
+
const bag = newBag();
|
|
270
372
|
const warnings = [];
|
|
271
373
|
let inBody = false;
|
|
272
374
|
for (const meta of Array.from(doc.getElementsByTagName("meta"))) {
|
|
@@ -280,9 +382,13 @@ export function parseObs(doc, pageUrl) {
|
|
|
280
382
|
continue;
|
|
281
383
|
if (!inBody && doc.body && doc.body.contains(meta))
|
|
282
384
|
inBody = true;
|
|
283
|
-
|
|
385
|
+
// An unescaped double quote ends the attribute early; the rest becomes attributes with a quote in their names.
|
|
386
|
+
if (Array.from(meta.attributes).some((a) => a.name.includes('"'))) {
|
|
387
|
+
warnings.push(`${key}: the value is cut short at a double quote; write a double quote inside a value as " (§9)`);
|
|
388
|
+
}
|
|
389
|
+
collect(bag, key, content, warnings);
|
|
284
390
|
}
|
|
285
|
-
if (bag
|
|
391
|
+
if (isEmpty(bag))
|
|
286
392
|
return { errors: ["No obs: tags on the page"], warnings };
|
|
287
393
|
if (inBody)
|
|
288
394
|
warnings.push("Some obs: tags are in <body>; they belong in <head> (§3.1)");
|
|
@@ -294,11 +400,17 @@ export function parseObs(doc, pageUrl) {
|
|
|
294
400
|
ctx.canonical = canonical;
|
|
295
401
|
if (lang !== undefined)
|
|
296
402
|
ctx.lang = lang;
|
|
297
|
-
|
|
403
|
+
const articleModified = metaContent(doc, "article:modified_time");
|
|
404
|
+
if (articleModified !== undefined)
|
|
405
|
+
ctx.alt = { value: articleModified, from: "article" };
|
|
406
|
+
const result = build(bag, ctx, warnings);
|
|
407
|
+
if (result.brief)
|
|
408
|
+
checkContent(result.brief, doc.body?.textContent ?? undefined, result.warnings);
|
|
409
|
+
return result;
|
|
298
410
|
});
|
|
299
411
|
}
|
|
300
412
|
// ---------------------------------------------------------------------------
|
|
301
|
-
// Feeds (§
|
|
413
|
+
// Feeds (§14)
|
|
302
414
|
function obsChildren(item) {
|
|
303
415
|
return Array.from(item.children).filter((el) => {
|
|
304
416
|
if (el.namespaceURI === OBS_NS)
|
|
@@ -329,18 +441,42 @@ function itemLink(item) {
|
|
|
329
441
|
}
|
|
330
442
|
return undefined;
|
|
331
443
|
}
|
|
444
|
+
/** The item's own date (§13): Atom <updated>, or RSS <pubDate> turned into ISO 8601. */
|
|
445
|
+
function itemDate(item) {
|
|
446
|
+
for (const el of Array.from(item.children)) {
|
|
447
|
+
if (el.namespaceURI === OBS_NS)
|
|
448
|
+
continue;
|
|
449
|
+
const text = el.textContent?.trim();
|
|
450
|
+
if (!text)
|
|
451
|
+
continue;
|
|
452
|
+
if (el.localName === "updated" && validModified(text))
|
|
453
|
+
return text;
|
|
454
|
+
if (el.localName === "pubDate") {
|
|
455
|
+
const ms = Date.parse(text);
|
|
456
|
+
if (!Number.isNaN(ms))
|
|
457
|
+
return new Date(ms).toISOString();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
332
462
|
/**
|
|
333
463
|
* Reads the OBS elements of one RSS <item> or Atom <entry>. `itemUrl`
|
|
334
464
|
* overrides the item's own link when given.
|
|
335
465
|
*/
|
|
336
466
|
export function parseFeedObs(item, itemUrl) {
|
|
337
467
|
return guard(() => {
|
|
338
|
-
const bag =
|
|
468
|
+
const bag = newBag();
|
|
339
469
|
const warnings = [];
|
|
340
470
|
for (const el of obsChildren(item)) {
|
|
341
471
|
const name = localName(el);
|
|
342
472
|
const value = el.textContent ?? "";
|
|
343
|
-
if (name === "
|
|
473
|
+
if (name === "highlight") {
|
|
474
|
+
collect(bag, "obs:highlight", value, warnings);
|
|
475
|
+
const label = el.getAttribute("label");
|
|
476
|
+
if (label !== null)
|
|
477
|
+
collect(bag, "obs:highlight:label", label, warnings);
|
|
478
|
+
}
|
|
479
|
+
else if (name === "summary") {
|
|
344
480
|
const length = el.getAttribute("length")?.trim().toLowerCase();
|
|
345
481
|
if (length === undefined || length === "")
|
|
346
482
|
put(bag, "obs:summary", value);
|
|
@@ -363,13 +499,16 @@ export function parseFeedObs(item, itemUrl) {
|
|
|
363
499
|
put(bag, `obs:${name}`, value);
|
|
364
500
|
}
|
|
365
501
|
}
|
|
366
|
-
if (bag
|
|
502
|
+
if (isEmpty(bag))
|
|
367
503
|
return { errors: ["No OBS elements on the item"], warnings };
|
|
368
504
|
const link = itemUrl ?? itemLink(item);
|
|
369
505
|
const canonical = link ? normalizeUrl(link) : undefined;
|
|
370
506
|
const ctx = {};
|
|
371
507
|
if (canonical !== undefined)
|
|
372
508
|
ctx.canonical = canonical;
|
|
509
|
+
const date = itemDate(item);
|
|
510
|
+
if (date !== undefined)
|
|
511
|
+
ctx.alt = { value: date, from: "feed" };
|
|
373
512
|
return build(bag, ctx, warnings);
|
|
374
513
|
});
|
|
375
514
|
}
|
|
@@ -382,7 +521,7 @@ export function validateObs(input) {
|
|
|
382
521
|
return { errors: ["Expected an object in the ObsBrief shape"], warnings: [] };
|
|
383
522
|
}
|
|
384
523
|
const o = input;
|
|
385
|
-
const bag =
|
|
524
|
+
const bag = newBag();
|
|
386
525
|
const warnings = [];
|
|
387
526
|
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
388
527
|
const set = (key, v) => {
|
|
@@ -392,7 +531,14 @@ export function validateObs(input) {
|
|
|
392
531
|
else if (v !== undefined && v !== null)
|
|
393
532
|
warnings.push(`${key} is not a string; it is ignored`);
|
|
394
533
|
};
|
|
395
|
-
|
|
534
|
+
// A modified time that came from the carrier is carried through with its source.
|
|
535
|
+
const ctx = {};
|
|
536
|
+
if ((o.modifiedFrom === "article" || o.modifiedFrom === "feed") && typeof o.modified === "string") {
|
|
537
|
+
ctx.alt = { value: o.modified, from: o.modifiedFrom };
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
set("obs:modified", o.modified);
|
|
541
|
+
}
|
|
396
542
|
if (typeof o.version === "number")
|
|
397
543
|
put(bag, "obs:version", String(o.version));
|
|
398
544
|
set("obs:url", o.url);
|
|
@@ -422,6 +568,26 @@ export function validateObs(input) {
|
|
|
422
568
|
if (Array.isArray(o.keep))
|
|
423
569
|
for (const k of o.keep)
|
|
424
570
|
set("obs:keep", k);
|
|
571
|
+
if (Array.isArray(o.highlights)) {
|
|
572
|
+
for (const h of o.highlights) {
|
|
573
|
+
if (!h || typeof h !== "object")
|
|
574
|
+
continue;
|
|
575
|
+
const rec = h;
|
|
576
|
+
const t = str(rec.text);
|
|
577
|
+
if (t === undefined) {
|
|
578
|
+
warnings.push("A highlight has no text; it is ignored");
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const raw = { text: t };
|
|
582
|
+
const label = str(rec.label);
|
|
583
|
+
if (label !== undefined)
|
|
584
|
+
raw.label = label;
|
|
585
|
+
// The producer saw the page; its verdict is carried through.
|
|
586
|
+
if (typeof rec.inContent === "boolean")
|
|
587
|
+
raw.inContent = rec.inContent;
|
|
588
|
+
bag.highlights.push(raw);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
425
591
|
if (o.derive && typeof o.derive === "object") {
|
|
426
592
|
const d = o.derive;
|
|
427
593
|
set("obs:derive", d.mode);
|
|
@@ -430,7 +596,9 @@ export function validateObs(input) {
|
|
|
430
596
|
if (typeof d.combine === "boolean")
|
|
431
597
|
put(bag, "obs:derive:combine", d.combine ? "yes" : "no");
|
|
432
598
|
}
|
|
433
|
-
|
|
599
|
+
if (isEmpty(bag))
|
|
600
|
+
return { errors: ["No summary: obs:summary or a tiered summary is required (§3)"], warnings };
|
|
601
|
+
const result = build(bag, ctx, warnings);
|
|
434
602
|
if (result.brief && typeof o.describesThisPage === "boolean") {
|
|
435
603
|
result.brief.describesThisPage = o.describesThisPage;
|
|
436
604
|
}
|
|
@@ -438,12 +606,122 @@ export function validateObs(input) {
|
|
|
438
606
|
});
|
|
439
607
|
}
|
|
440
608
|
// ---------------------------------------------------------------------------
|
|
441
|
-
// Feed against page (§
|
|
442
|
-
/**
|
|
609
|
+
// Feed against page (§14)
|
|
610
|
+
/**
|
|
611
|
+
* The later effective modified time stands. When the two are equal, or either
|
|
612
|
+
* side has none, the page stands.
|
|
613
|
+
*/
|
|
443
614
|
export function mergeObs(page, feed) {
|
|
444
615
|
if (!page)
|
|
445
616
|
return feed;
|
|
446
617
|
if (!feed)
|
|
447
618
|
return page;
|
|
619
|
+
if (page.modified === undefined || feed.modified === undefined)
|
|
620
|
+
return page;
|
|
448
621
|
return Date.parse(feed.modified) > Date.parse(page.modified) ? feed : page;
|
|
449
622
|
}
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Change detection (§13)
|
|
625
|
+
/**
|
|
626
|
+
* A stable hash of what a brief says: every text value and every setting, in a
|
|
627
|
+
* fixed order. Two briefs with the same fingerprint present the same. It leaves
|
|
628
|
+
* out the modified time, and everything that depends on where the tags were
|
|
629
|
+
* read rather than on what they say (the language fallback, describesThisPage,
|
|
630
|
+
* a highlight's inContent), so a page and its feed item agree. Readers compare
|
|
631
|
+
* fingerprints to tell a change, above all when there is no modified time.
|
|
632
|
+
*
|
|
633
|
+
* SHA-256 of the canonical JSON below, in hex, first 16 characters.
|
|
634
|
+
*/
|
|
635
|
+
export function fingerprint(brief) {
|
|
636
|
+
const canonical = JSON.stringify([
|
|
637
|
+
"obs1",
|
|
638
|
+
brief.version,
|
|
639
|
+
brief.url ?? null,
|
|
640
|
+
brief.title ?? null,
|
|
641
|
+
brief.headline ?? null,
|
|
642
|
+
brief.summaries.map((s) => [s.tier, s.text]),
|
|
643
|
+
brief.keep,
|
|
644
|
+
brief.highlights.map((h) => [h.text, h.label ?? null]),
|
|
645
|
+
[brief.derive.mode, brief.derive.translate, brief.derive.combine],
|
|
646
|
+
brief.attribution ?? null,
|
|
647
|
+
brief.preference,
|
|
648
|
+
]);
|
|
649
|
+
return sha256Hex(utf8(canonical)).slice(0, 16);
|
|
650
|
+
}
|
|
651
|
+
/** UTF-8 bytes, without TextEncoder so the module needs no platform API. */
|
|
652
|
+
function utf8(text) {
|
|
653
|
+
const out = [];
|
|
654
|
+
for (const ch of text) {
|
|
655
|
+
const c = ch.codePointAt(0);
|
|
656
|
+
if (c < 0x80)
|
|
657
|
+
out.push(c);
|
|
658
|
+
else if (c < 0x800)
|
|
659
|
+
out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
|
|
660
|
+
else if (c < 0x10000)
|
|
661
|
+
out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
662
|
+
else
|
|
663
|
+
out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
}
|
|
667
|
+
const SHA_K = [
|
|
668
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
669
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
670
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
671
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
672
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
673
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
674
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
675
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
676
|
+
];
|
|
677
|
+
/** SHA-256 (FIPS 180-4), synchronous, so fingerprint() is too. */
|
|
678
|
+
function sha256Hex(bytes) {
|
|
679
|
+
const h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
|
680
|
+
const bits = bytes.length * 8;
|
|
681
|
+
const msg = bytes.slice();
|
|
682
|
+
msg.push(0x80);
|
|
683
|
+
while (msg.length % 64 !== 56)
|
|
684
|
+
msg.push(0);
|
|
685
|
+
const hi = Math.floor(bits / 0x100000000);
|
|
686
|
+
const lo = bits >>> 0;
|
|
687
|
+
msg.push((hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255, (lo >>> 24) & 255, (lo >>> 16) & 255, (lo >>> 8) & 255, lo & 255);
|
|
688
|
+
const w = new Array(64).fill(0);
|
|
689
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
690
|
+
for (let i = 0; i < msg.length; i += 64) {
|
|
691
|
+
for (let t = 0; t < 16; t++)
|
|
692
|
+
w[t] = ((msg[i + t * 4] << 24) | (msg[i + t * 4 + 1] << 16) | (msg[i + t * 4 + 2] << 8) | msg[i + t * 4 + 3]) >>> 0;
|
|
693
|
+
for (let t = 16; t < 64; t++) {
|
|
694
|
+
const a = w[t - 15];
|
|
695
|
+
const b = w[t - 2];
|
|
696
|
+
const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
|
|
697
|
+
const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
|
|
698
|
+
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
|
|
699
|
+
}
|
|
700
|
+
let [a, b, c, d, e, f, g, k] = h;
|
|
701
|
+
for (let t = 0; t < 64; t++) {
|
|
702
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
703
|
+
const ch = (e & f) ^ (~e & g);
|
|
704
|
+
const t1 = (k + S1 + ch + SHA_K[t] + w[t]) >>> 0;
|
|
705
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
706
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
707
|
+
const t2 = (S0 + maj) >>> 0;
|
|
708
|
+
k = g;
|
|
709
|
+
g = f;
|
|
710
|
+
f = e;
|
|
711
|
+
e = (d + t1) >>> 0;
|
|
712
|
+
d = c;
|
|
713
|
+
c = b;
|
|
714
|
+
b = a;
|
|
715
|
+
a = (t1 + t2) >>> 0;
|
|
716
|
+
}
|
|
717
|
+
h[0] = (h[0] + a) >>> 0;
|
|
718
|
+
h[1] = (h[1] + b) >>> 0;
|
|
719
|
+
h[2] = (h[2] + c) >>> 0;
|
|
720
|
+
h[3] = (h[3] + d) >>> 0;
|
|
721
|
+
h[4] = (h[4] + e) >>> 0;
|
|
722
|
+
h[5] = (h[5] + f) >>> 0;
|
|
723
|
+
h[6] = (h[6] + g) >>> 0;
|
|
724
|
+
h[7] = (h[7] + k) >>> 0;
|
|
725
|
+
}
|
|
726
|
+
return h.map((x) => x.toString(16).padStart(8, "0")).join("");
|
|
727
|
+
}
|