openbriefing 0.2.0 → 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 +17 -8
- package/dist/cli.js +241 -50
- package/dist/fetch.d.ts +33 -0
- package/dist/fetch.js +122 -0
- package/dist/obs.d.ts +21 -3
- package/dist/obs.js +178 -10
- package/fixtures/expected.json +380 -40
- package/fixtures/feed-modified-from-pubdate.xml +21 -0
- package/fixtures/feed-modified-from-updated.xml +19 -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/package.json +5 -1
package/dist/obs.js
CHANGED
|
@@ -183,13 +183,22 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
183
183
|
warnings.push(`obs:version ${n} is newer than this reader (v1); unknown tags are ignored`);
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
-
// obs:modified
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
|
190
198
|
}
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
if (modified === undefined && ctx.alt !== undefined && validModified(ctx.alt.value)) {
|
|
200
|
+
modified = ctx.alt.value;
|
|
201
|
+
modifiedFrom = ctx.alt.from;
|
|
193
202
|
}
|
|
194
203
|
// Summaries (§4)
|
|
195
204
|
const tiered = new Map();
|
|
@@ -283,7 +292,8 @@ function build(bag, ctx, extraWarnings = []) {
|
|
|
283
292
|
}
|
|
284
293
|
const brief = {
|
|
285
294
|
version,
|
|
286
|
-
modified:
|
|
295
|
+
...(modified !== undefined ? { modified } : {}),
|
|
296
|
+
modifiedFrom,
|
|
287
297
|
describesThisPage,
|
|
288
298
|
summaries,
|
|
289
299
|
keep,
|
|
@@ -341,6 +351,17 @@ function guard(fn) {
|
|
|
341
351
|
}
|
|
342
352
|
// ---------------------------------------------------------------------------
|
|
343
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
|
+
}
|
|
344
365
|
/**
|
|
345
366
|
* Reads a page's obs: meta tags. `pageUrl` is where the page was fetched
|
|
346
367
|
* from; the page's <link rel="canonical"> takes precedence for §11.
|
|
@@ -361,6 +382,10 @@ export function parseObs(doc, pageUrl) {
|
|
|
361
382
|
continue;
|
|
362
383
|
if (!inBody && doc.body && doc.body.contains(meta))
|
|
363
384
|
inBody = true;
|
|
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
|
+
}
|
|
364
389
|
collect(bag, key, content, warnings);
|
|
365
390
|
}
|
|
366
391
|
if (isEmpty(bag))
|
|
@@ -375,6 +400,9 @@ export function parseObs(doc, pageUrl) {
|
|
|
375
400
|
ctx.canonical = canonical;
|
|
376
401
|
if (lang !== undefined)
|
|
377
402
|
ctx.lang = lang;
|
|
403
|
+
const articleModified = metaContent(doc, "article:modified_time");
|
|
404
|
+
if (articleModified !== undefined)
|
|
405
|
+
ctx.alt = { value: articleModified, from: "article" };
|
|
378
406
|
const result = build(bag, ctx, warnings);
|
|
379
407
|
if (result.brief)
|
|
380
408
|
checkContent(result.brief, doc.body?.textContent ?? undefined, result.warnings);
|
|
@@ -413,6 +441,24 @@ function itemLink(item) {
|
|
|
413
441
|
}
|
|
414
442
|
return undefined;
|
|
415
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
|
+
}
|
|
416
462
|
/**
|
|
417
463
|
* Reads the OBS elements of one RSS <item> or Atom <entry>. `itemUrl`
|
|
418
464
|
* overrides the item's own link when given.
|
|
@@ -460,6 +506,9 @@ export function parseFeedObs(item, itemUrl) {
|
|
|
460
506
|
const ctx = {};
|
|
461
507
|
if (canonical !== undefined)
|
|
462
508
|
ctx.canonical = canonical;
|
|
509
|
+
const date = itemDate(item);
|
|
510
|
+
if (date !== undefined)
|
|
511
|
+
ctx.alt = { value: date, from: "feed" };
|
|
463
512
|
return build(bag, ctx, warnings);
|
|
464
513
|
});
|
|
465
514
|
}
|
|
@@ -482,7 +531,14 @@ export function validateObs(input) {
|
|
|
482
531
|
else if (v !== undefined && v !== null)
|
|
483
532
|
warnings.push(`${key} is not a string; it is ignored`);
|
|
484
533
|
};
|
|
485
|
-
|
|
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
|
+
}
|
|
486
542
|
if (typeof o.version === "number")
|
|
487
543
|
put(bag, "obs:version", String(o.version));
|
|
488
544
|
set("obs:url", o.url);
|
|
@@ -540,7 +596,9 @@ export function validateObs(input) {
|
|
|
540
596
|
if (typeof d.combine === "boolean")
|
|
541
597
|
put(bag, "obs:derive:combine", d.combine ? "yes" : "no");
|
|
542
598
|
}
|
|
543
|
-
|
|
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);
|
|
544
602
|
if (result.brief && typeof o.describesThisPage === "boolean") {
|
|
545
603
|
result.brief.describesThisPage = o.describesThisPage;
|
|
546
604
|
}
|
|
@@ -549,11 +607,121 @@ export function validateObs(input) {
|
|
|
549
607
|
}
|
|
550
608
|
// ---------------------------------------------------------------------------
|
|
551
609
|
// Feed against page (§14)
|
|
552
|
-
/**
|
|
610
|
+
/**
|
|
611
|
+
* The later effective modified time stands. When the two are equal, or either
|
|
612
|
+
* side has none, the page stands.
|
|
613
|
+
*/
|
|
553
614
|
export function mergeObs(page, feed) {
|
|
554
615
|
if (!page)
|
|
555
616
|
return feed;
|
|
556
617
|
if (!feed)
|
|
557
618
|
return page;
|
|
619
|
+
if (page.modified === undefined || feed.modified === undefined)
|
|
620
|
+
return page;
|
|
558
621
|
return Date.parse(feed.modified) > Date.parse(page.modified) ? feed : page;
|
|
559
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
|
+
}
|