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/README.md
CHANGED
|
@@ -2,17 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
This package is the reference parser and checker for the [Open Briefing Standard (OBS)](https://openbriefingstandard.org). OBS is how a publisher says how their content should be summarized and presented when software consumes it.
|
|
4
4
|
|
|
5
|
-
**Version 0.
|
|
5
|
+
**Version 0.3.** It contains the parser and `openbriefing check`, and reads spec version 1 (release candidate). One tag, `obs:summary`, is enough: `obs:modified` is optional, and the parser reports where a modified time came from. `check` reads a page, a feed or a whole sitemap from the web.
|
|
6
6
|
|
|
7
|
-
## Check a page or a
|
|
7
|
+
## Check a page, a feed or a site
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
+
npx openbriefing check https://example.com/a-post/
|
|
10
11
|
npx openbriefing check ./post.html
|
|
11
|
-
npx openbriefing check
|
|
12
|
-
|
|
12
|
+
npx openbriefing check https://example.com/rss/ --feed --pages
|
|
13
|
+
npx openbriefing check https://example.com/sitemap.xml --sitemap
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
- **A page:** prints each form with its word count, the keep lines, the highlights (and whether each is in the page's text), the modified time and its source, and a fingerprint.
|
|
17
|
+
- **`--feed`:** checks every item. With `--pages` it also fetches each item's page and says which of the two stands.
|
|
18
|
+
- **`--sitemap`:** one line per page (`ok`, `ok, 2 warnings`, `no tags`, `not usable`) and a total. `--limit 20` checks the first 20.
|
|
19
|
+
|
|
20
|
+
`check` exits 1 when tags are present but unusable, so a site's CI can gate on it. A page with no tags is not an error in a sitemap. Add `--json` for the parsed result. For a file with no canonical link, `--url` gives the page's address; `-` reads standard input.
|
|
21
|
+
|
|
22
|
+
Fetching is bounded: `http` and `https` only, 10 seconds, 5 MB, 5 redirects, and it names itself `openbriefing-check/<version>`.
|
|
16
23
|
|
|
17
24
|
## Use the parser
|
|
18
25
|
|
|
@@ -22,7 +29,8 @@ import { parseObs } from "openbriefing";
|
|
|
22
29
|
|
|
23
30
|
const dom = new JSDOM(html, { url });
|
|
24
31
|
const { brief, errors, warnings } = parseObs(dom.window.document, url);
|
|
25
|
-
// brief.summaries, brief.keep, brief.highlights (each { text, words, label?, inContent? }),
|
|
32
|
+
// brief.summaries, brief.keep, brief.highlights (each { text, words, label?, inContent? }),
|
|
33
|
+
// brief.modified (optional) and brief.modifiedFrom ("obs" | "article" | "feed" | "none"), …
|
|
26
34
|
```
|
|
27
35
|
|
|
28
36
|
| Export | What it does |
|
|
@@ -30,10 +38,11 @@ const { brief, errors, warnings } = parseObs(dom.window.document, url);
|
|
|
30
38
|
| `parseObs(document, pageUrl)` | Reads a page's `obs:` meta tags |
|
|
31
39
|
| `parseFeedObs(itemElement, itemUrl?)` | Reads one RSS `<item>` or Atom `<entry>` |
|
|
32
40
|
| `validateObs(object)` | Checks an object already in the `ObsBrief` shape |
|
|
33
|
-
| `mergeObs(page, feed)` | Applies the feed-against-page rule: the later
|
|
41
|
+
| `mergeObs(page, feed)` | Applies the feed-against-page rule: the later effective modified time stands; the page wins a tie, and when either has no time |
|
|
42
|
+
| `fingerprint(brief)` | A stable 16-character hash of every text value and setting. Compare two to tell whether a brief changed, with or without a modified time |
|
|
34
43
|
| `tierFor(words)`, `SIZES`, `normalizeUrl`, `looseText` | Helpers that use the spec's numbers and its loose text comparison |
|
|
35
44
|
|
|
36
|
-
The parser has no runtime dependencies. It takes any DOM `Document` (jsdom, linkedom, or a browser's `DOMParser`), makes no network requests, and never throws.
|
|
45
|
+
The parser has no runtime dependencies. It takes any DOM `Document` (jsdom, linkedom, or a browser's `DOMParser`), makes no network requests, and never throws. The bounded fetch that `check` uses is exported separately as `openbriefing/fetch`.
|
|
37
46
|
|
|
38
47
|
The test fixtures ship with the package in `openbriefing/fixtures/`, with `expected.json`.
|
|
39
48
|
|
package/dist/cli.js
CHANGED
|
@@ -1,52 +1,83 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* openbriefing check <file | -> [--url <page url>] [--json]
|
|
3
|
+
* openbriefing check <url | file | -> [--url <page url>] [--json]
|
|
4
|
+
* openbriefing check <feed url | file> --feed [--pages]
|
|
5
|
+
* openbriefing check <sitemap url | file> --sitemap [--limit <n>]
|
|
4
6
|
*
|
|
5
|
-
* Checks a
|
|
6
|
-
*
|
|
7
|
-
* Version 0.1 reads local files and standard input only; it makes no
|
|
8
|
-
* network requests.
|
|
7
|
+
* Checks a page, a feed or a whole sitemap for Open Briefing Standard tags
|
|
8
|
+
* and prints what a reader sees. Exits 1 when tags are present but unusable.
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { basename, resolve } from "node:path";
|
|
12
12
|
import { pathToFileURL } from "node:url";
|
|
13
13
|
import { JSDOM } from "jsdom";
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import { DEFAULT_LIMITS, fetchText, isHttpUrl, mapLimit, userAgent } from "./fetch.js";
|
|
15
|
+
import { countWords, fingerprint, mergeObs, parseFeedObs, parseObs, SIZES } from "./obs.js";
|
|
16
|
+
const VERSION = (() => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "0";
|
|
22
|
+
}
|
|
23
|
+
})();
|
|
24
|
+
const LIMITS = { ...DEFAULT_LIMITS, userAgent: userAgent(VERSION) };
|
|
25
|
+
const USAGE = `Usage:
|
|
26
|
+
openbriefing check <url | file | -> [--url <page url>] [--json]
|
|
27
|
+
openbriefing check <feed url | file> --feed [--pages]
|
|
28
|
+
openbriefing check <sitemap url | file> --sitemap [--limit <n>]
|
|
16
29
|
|
|
17
|
-
Checks a page or a
|
|
18
|
-
--url
|
|
19
|
-
--
|
|
30
|
+
Checks a page, a feed or a sitemap for Open Briefing Standard tags.
|
|
31
|
+
--url the page's address, when checking a file with no canonical link
|
|
32
|
+
--feed treat the input as an RSS or Atom feed (detected when omitted)
|
|
33
|
+
--pages with a feed: also fetch each item's page and say which one stands
|
|
34
|
+
--sitemap check every page a sitemap lists, one line each
|
|
35
|
+
--limit with a sitemap: check only the first <n> pages
|
|
36
|
+
--json print the parsed result as JSON
|
|
20
37
|
Spec: https://openbriefingstandard.org`;
|
|
21
38
|
function parseArgs(argv) {
|
|
22
|
-
const args = { json: false };
|
|
39
|
+
const args = { json: false, feed: false, pages: false, sitemap: false };
|
|
23
40
|
for (let i = 0; i < argv.length; i++) {
|
|
24
41
|
const a = argv[i];
|
|
25
42
|
if (a === "--json")
|
|
26
43
|
args.json = true;
|
|
27
|
-
else if (a === "--
|
|
28
|
-
args.
|
|
44
|
+
else if (a === "--feed")
|
|
45
|
+
args.feed = true;
|
|
46
|
+
else if (a === "--pages")
|
|
47
|
+
args.pages = true;
|
|
48
|
+
else if (a === "--sitemap")
|
|
49
|
+
args.sitemap = true;
|
|
50
|
+
else if (a === "--url") {
|
|
51
|
+
const v = argv[++i];
|
|
52
|
+
if (v === undefined)
|
|
53
|
+
args.bad = "--url needs an address";
|
|
54
|
+
else
|
|
55
|
+
args.url = v;
|
|
56
|
+
}
|
|
57
|
+
else if (a === "--limit") {
|
|
58
|
+
const n = Number.parseInt(argv[++i] ?? "", 10);
|
|
59
|
+
if (!Number.isInteger(n) || n < 1)
|
|
60
|
+
args.bad = "--limit needs a whole number above 0";
|
|
61
|
+
else
|
|
62
|
+
args.limit = n;
|
|
63
|
+
}
|
|
29
64
|
else if (a === "-h" || a === "--help")
|
|
30
65
|
args.command = "help";
|
|
66
|
+
else if (a === "-v" || a === "--version")
|
|
67
|
+
args.command = "version";
|
|
68
|
+
else if (a.startsWith("--"))
|
|
69
|
+
args.bad = `unknown option ${a}`;
|
|
31
70
|
else if (args.command === undefined)
|
|
32
71
|
args.command = a;
|
|
33
|
-
else if (args.
|
|
34
|
-
args.
|
|
72
|
+
else if (args.target === undefined)
|
|
73
|
+
args.target = a;
|
|
35
74
|
}
|
|
36
75
|
return args;
|
|
37
76
|
}
|
|
38
|
-
function readInput(file) {
|
|
39
|
-
return file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8");
|
|
40
|
-
}
|
|
41
|
-
function isFeed(text, file) {
|
|
42
|
-
if (/\.(xml|rss|atom)$/iu.test(file))
|
|
43
|
-
return true;
|
|
44
|
-
const head = text.slice(0, 500).trimStart();
|
|
45
|
-
return head.startsWith("<?xml") ? /<(rss|feed)\b/iu.test(text.slice(0, 2000)) : /^<(rss|feed)\b/iu.test(head);
|
|
46
|
-
}
|
|
47
77
|
/** Page text is untrusted: show control characters (ANSI escapes and the like) instead of sending them to the terminal. */
|
|
48
78
|
const safe = (s) => s.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
49
79
|
const cut = (s, n = 60) => safe((s.length > n ? `${s.slice(0, n - 1)}…` : s).replace(/\n/gu, " "));
|
|
80
|
+
const MODIFIED_FROM = { obs: "obs:modified", article: "article:modified_time", feed: "the feed item's date", none: "" };
|
|
50
81
|
function describe(label, r) {
|
|
51
82
|
const lines = [safe(label)];
|
|
52
83
|
const b = r.brief;
|
|
@@ -75,7 +106,8 @@ function describe(label, r) {
|
|
|
75
106
|
row("preference", b.preference);
|
|
76
107
|
if (b.lang !== undefined)
|
|
77
108
|
row("lang", safe(b.lang));
|
|
78
|
-
row("modified", safe(b.modified));
|
|
109
|
+
row("modified", b.modified === undefined ? "none; readers compare the text of the tags" : `${safe(b.modified)} (from ${MODIFIED_FROM[b.modifiedFrom]})`);
|
|
110
|
+
row("fingerprint", fingerprint(b));
|
|
79
111
|
if (b.url !== undefined)
|
|
80
112
|
row("url", `${safe(b.url)}${b.describesThisPage ? "" : " (describes another page)"}`);
|
|
81
113
|
}
|
|
@@ -86,46 +118,205 @@ function describe(label, r) {
|
|
|
86
118
|
lines.push(b ? ` ok: usable, ${r.warnings.length} warning${r.warnings.length === 1 ? "" : "s"}` : " not usable");
|
|
87
119
|
return lines.join("\n");
|
|
88
120
|
}
|
|
89
|
-
function
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
121
|
+
async function load(target, urlOverride) {
|
|
122
|
+
if (isHttpUrl(target)) {
|
|
123
|
+
const res = await fetchText(target, LIMITS);
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
return { error: `Cannot fetch ${safe(target)}: ${safe(res.error)}` };
|
|
126
|
+
return { text: res.text, url: urlOverride ?? res.url, label: res.url, contentType: res.contentType };
|
|
93
127
|
}
|
|
94
|
-
let text;
|
|
95
128
|
try {
|
|
96
|
-
text =
|
|
129
|
+
const text = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
|
|
130
|
+
const url = urlOverride ?? (target === "-" ? "https://localhost/" : pathToFileURL(resolve(target)).href);
|
|
131
|
+
return { text, url, label: target === "-" ? "(standard input)" : basename(target), contentType: "" };
|
|
97
132
|
}
|
|
98
133
|
catch (err) {
|
|
99
|
-
|
|
100
|
-
|
|
134
|
+
return { error: `Cannot read ${safe(target)}: ${err instanceof Error ? err.message : String(err)}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function looksLikeFeed(text, name, contentType) {
|
|
138
|
+
if (/\b(rss|atom)\+xml\b/iu.test(contentType))
|
|
139
|
+
return true;
|
|
140
|
+
if (/\.(rss|atom)$/iu.test(name))
|
|
141
|
+
return true;
|
|
142
|
+
const head = text.slice(0, 2000).trimStart();
|
|
143
|
+
return /^(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<\?[^>]*\?>\s*)*<(rss|feed)\b/iu.test(head);
|
|
144
|
+
}
|
|
145
|
+
const parsePage = (text, url) => parseObs(new JSDOM(text, { url }).window.document, url);
|
|
146
|
+
function feedItems(text) {
|
|
147
|
+
const doc = new JSDOM(text, { contentType: "text/xml" }).window.document;
|
|
148
|
+
return [...Array.from(doc.getElementsByTagName("item")), ...Array.from(doc.getElementsByTagName("entry"))];
|
|
149
|
+
}
|
|
150
|
+
function itemLink(item) {
|
|
151
|
+
for (const el of Array.from(item.children)) {
|
|
152
|
+
if (el.localName !== "link" || el.namespaceURI === "https://openbriefingstandard.org/ns#")
|
|
153
|
+
continue;
|
|
154
|
+
const href = el.getAttribute("href");
|
|
155
|
+
if (href !== null) {
|
|
156
|
+
const rel = el.getAttribute("rel");
|
|
157
|
+
if (rel === null || rel === "alternate")
|
|
158
|
+
return href.trim();
|
|
159
|
+
}
|
|
160
|
+
else if (el.textContent?.trim())
|
|
161
|
+
return el.textContent.trim();
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const NO_PAGE_TAGS = "No obs: tags on the page";
|
|
166
|
+
const NO_ITEM_TAGS = "No OBS elements on the item";
|
|
167
|
+
const hasNoTags = (r) => r.brief === undefined && (r.errors[0] === NO_PAGE_TAGS || r.errors[0] === NO_ITEM_TAGS);
|
|
168
|
+
async function checkFeed(args, loaded) {
|
|
169
|
+
const items = feedItems(loaded.text);
|
|
170
|
+
if (items.length === 0) {
|
|
171
|
+
if (args.json)
|
|
172
|
+
console.log("[]");
|
|
173
|
+
else
|
|
174
|
+
console.log(`${safe(loaded.label)}\n not usable: no <item> or <entry> elements`);
|
|
175
|
+
return 1;
|
|
101
176
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
if (isFeed(text, args.file)) {
|
|
105
|
-
const doc = new JSDOM(text, { contentType: "text/xml" }).window.document;
|
|
106
|
-
const items = [...Array.from(doc.getElementsByTagName("item")), ...Array.from(doc.getElementsByTagName("entry"))];
|
|
107
|
-
const results = items.map((item) => parseFeedObs(item));
|
|
177
|
+
const results = items.map((item) => parseFeedObs(item));
|
|
178
|
+
if (!args.pages) {
|
|
108
179
|
if (args.json)
|
|
109
180
|
console.log(JSON.stringify(results, null, 2));
|
|
110
181
|
else
|
|
111
|
-
results.forEach((r, i) => console.log(describe(`${label}, item ${i + 1}`, r)));
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
182
|
+
results.forEach((r, i) => console.log(describe(`${loaded.label}, item ${i + 1}`, r)));
|
|
183
|
+
return results.some((r) => r.brief === undefined && !hasNoTags(r)) || results.every((r) => r.brief === undefined) ? 1 : 0;
|
|
184
|
+
}
|
|
185
|
+
// --pages: fetch each item's page and apply the feed-against-page rule (§14).
|
|
186
|
+
const rows = await mapLimit(items, 4, async (item, i) => {
|
|
187
|
+
const feed = results[i];
|
|
188
|
+
const link = itemLink(item);
|
|
189
|
+
if (link === undefined || !isHttpUrl(link))
|
|
190
|
+
return { link, feed, note: "the item has no link to fetch" };
|
|
191
|
+
const res = await fetchText(link, LIMITS);
|
|
192
|
+
if (!res.ok)
|
|
193
|
+
return { link, feed, note: `page not fetched: ${res.error}` };
|
|
194
|
+
const page = parsePage(res.text, res.url);
|
|
195
|
+
const stands = mergeObs(page.brief, feed.brief);
|
|
196
|
+
const which = stands === undefined ? "neither" : stands === page.brief ? "page" : "feed";
|
|
197
|
+
const same = page.brief && feed.brief ? fingerprint(page.brief) === fingerprint(feed.brief) : undefined;
|
|
198
|
+
return { link, feed, page, stands: which, same };
|
|
199
|
+
});
|
|
200
|
+
if (args.json)
|
|
201
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
202
|
+
else {
|
|
203
|
+
rows.forEach((row, i) => {
|
|
204
|
+
console.log(describe(`${loaded.label}, item ${i + 1}${row.link ? ` ${row.link}` : ""}`, row.feed));
|
|
205
|
+
if ("note" in row)
|
|
206
|
+
console.log(` page ${safe(row.note ?? "")}`);
|
|
207
|
+
else {
|
|
208
|
+
const p = row.page;
|
|
209
|
+
const state = p.brief ? `usable, ${p.warnings.length} warning${p.warnings.length === 1 ? "" : "s"}` : hasNoTags(p) ? "no tags" : "not usable";
|
|
210
|
+
const agree = row.same === undefined ? "" : row.same ? "; page and feed say the same" : "; page and feed differ";
|
|
211
|
+
console.log(` page ${state}${agree}; the ${row.stands} stands`);
|
|
212
|
+
if (!p.brief && !hasNoTags(p))
|
|
213
|
+
for (const e of p.errors)
|
|
214
|
+
console.log(` page error ${safe(e)}`);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const broken = rows.some((row) => (row.feed.brief === undefined && !hasNoTags(row.feed)) || ("page" in row && row.page !== undefined && row.page.brief === undefined && !hasNoTags(row.page)));
|
|
219
|
+
return broken ? 1 : 0;
|
|
220
|
+
}
|
|
221
|
+
const unescapeXml = (s) => s.replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, '"').replace(/'/gu, "'").replace(/&/gu, "&");
|
|
222
|
+
function locs(xml) {
|
|
223
|
+
return [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?\s*([^<\]]+?)\s*(?:\]\]>)?\s*<\/loc>/giu)].map((m) => unescapeXml(m[1]));
|
|
224
|
+
}
|
|
225
|
+
/** Image and video sitemaps nest their own <loc> elements; only a <url>'s direct one is a page. */
|
|
226
|
+
function pageLocs(xml) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const m of xml.matchAll(/<url\b[^>]*>([\s\S]*?)<\/url>/giu)) {
|
|
229
|
+
const inner = m[1].replace(/<(image|video|news):[a-z]+\b[\s\S]*?<\/\1:[a-z]+>/giu, "");
|
|
230
|
+
const first = locs(inner)[0];
|
|
231
|
+
if (first !== undefined)
|
|
232
|
+
out.push(first);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
async function checkSitemap(args, loaded) {
|
|
237
|
+
let pages;
|
|
238
|
+
if (/<sitemapindex\b/iu.test(loaded.text)) {
|
|
239
|
+
const children = locs(loaded.text).filter(isHttpUrl).slice(0, 50);
|
|
240
|
+
const sets = await mapLimit(children, 4, async (child) => {
|
|
241
|
+
const res = await fetchText(child, LIMITS);
|
|
242
|
+
return res.ok ? pageLocs(res.text) : [];
|
|
243
|
+
});
|
|
244
|
+
pages = sets.flat();
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
pages = pageLocs(loaded.text);
|
|
248
|
+
}
|
|
249
|
+
pages = [...new Set(pages.filter(isHttpUrl))];
|
|
250
|
+
const total = pages.length;
|
|
251
|
+
if (args.limit !== undefined)
|
|
252
|
+
pages = pages.slice(0, args.limit);
|
|
253
|
+
if (pages.length === 0) {
|
|
254
|
+
if (args.json)
|
|
255
|
+
console.log("[]");
|
|
256
|
+
else
|
|
257
|
+
console.log(`${safe(loaded.label)}\n no page addresses found`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
const rows = await mapLimit(pages, 4, async (url) => {
|
|
261
|
+
const res = await fetchText(url, LIMITS);
|
|
262
|
+
if (!res.ok)
|
|
263
|
+
return { url, status: "not fetched", warnings: 0, detail: res.error };
|
|
264
|
+
const result = parsePage(res.text, res.url);
|
|
265
|
+
if (result.brief)
|
|
266
|
+
return { url, status: "ok", warnings: result.warnings.length, result };
|
|
267
|
+
if (hasNoTags(result))
|
|
268
|
+
return { url, status: "no tags", warnings: 0, result };
|
|
269
|
+
return { url, status: "not usable", warnings: result.warnings.length, detail: result.errors.join("; "), result };
|
|
270
|
+
});
|
|
271
|
+
const count = (s) => rows.filter((r) => r.status === s).length;
|
|
272
|
+
if (args.json)
|
|
273
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
274
|
+
else {
|
|
275
|
+
for (const r of rows) {
|
|
276
|
+
const state = r.status === "ok" && r.warnings > 0 ? `ok, ${r.warnings} warning${r.warnings === 1 ? "" : "s"}` : r.status;
|
|
277
|
+
console.log(`${state.padEnd(16)} ${safe(r.url)}${r.detail && r.status !== "ok" ? ` (${safe(r.detail)})` : ""}`);
|
|
116
278
|
}
|
|
117
|
-
|
|
279
|
+
const parts = [`${rows.length} page${rows.length === 1 ? "" : "s"}`, `${count("ok")} ok`, `${count("no tags")} with no tags`, `${count("not usable")} not usable`];
|
|
280
|
+
if (count("not fetched") > 0)
|
|
281
|
+
parts.push(`${count("not fetched")} not fetched`);
|
|
282
|
+
console.log(`\n${parts.join(", ")}${rows.length < total ? ` (first ${rows.length} of ${total})` : ""}`);
|
|
283
|
+
}
|
|
284
|
+
return count("not usable") > 0 ? 1 : 0;
|
|
285
|
+
}
|
|
286
|
+
async function check(args) {
|
|
287
|
+
if (!args.target || args.bad) {
|
|
288
|
+
if (args.bad)
|
|
289
|
+
console.error(`${args.bad}\n`);
|
|
290
|
+
console.error(USAGE);
|
|
291
|
+
return 2;
|
|
118
292
|
}
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
293
|
+
const loaded = await load(args.target, args.url);
|
|
294
|
+
if ("error" in loaded) {
|
|
295
|
+
console.error(loaded.error);
|
|
296
|
+
return 2;
|
|
297
|
+
}
|
|
298
|
+
if (args.sitemap)
|
|
299
|
+
return checkSitemap(args, loaded);
|
|
300
|
+
if (args.feed || looksLikeFeed(loaded.text, args.target, loaded.contentType))
|
|
301
|
+
return checkFeed(args, loaded);
|
|
302
|
+
const r = parsePage(loaded.text, loaded.url);
|
|
303
|
+
console.log(args.json ? JSON.stringify(r, null, 2) : describe(loaded.label, r));
|
|
122
304
|
return r.brief ? 0 : 1;
|
|
123
305
|
}
|
|
124
|
-
function main(argv) {
|
|
306
|
+
async function main(argv) {
|
|
125
307
|
const args = parseArgs(argv);
|
|
126
308
|
if (args.command === "check")
|
|
127
309
|
return check(args);
|
|
310
|
+
if (args.command === "version") {
|
|
311
|
+
console.log(VERSION);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
128
314
|
console.error(USAGE);
|
|
129
315
|
return args.command === "help" ? 0 : 2;
|
|
130
316
|
}
|
|
131
|
-
|
|
317
|
+
main(process.argv.slice(2)).then((code) => {
|
|
318
|
+
process.exitCode = code;
|
|
319
|
+
}, (err) => {
|
|
320
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
321
|
+
process.exitCode = 2;
|
|
322
|
+
});
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network code for `openbriefing check`. Kept apart from obs.ts so the
|
|
3
|
+
* parser stays usable in a browser, with no platform API.
|
|
4
|
+
*
|
|
5
|
+
* Everything fetched is untrusted. The limits here bound time, size and
|
|
6
|
+
* redirects; callers escape control characters before printing.
|
|
7
|
+
*/
|
|
8
|
+
export interface FetchLimits {
|
|
9
|
+
/** Whole-request budget in milliseconds, redirects included. */
|
|
10
|
+
timeoutMs: number;
|
|
11
|
+
/** The body is cut off, and the fetch fails, past this many bytes. */
|
|
12
|
+
maxBytes: number;
|
|
13
|
+
maxRedirects: number;
|
|
14
|
+
userAgent: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const DEFAULT_LIMITS: Omit<FetchLimits, "userAgent">;
|
|
17
|
+
export type FetchResult = {
|
|
18
|
+
ok: true;
|
|
19
|
+
url: string;
|
|
20
|
+
contentType: string;
|
|
21
|
+
text: string;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
url: string;
|
|
25
|
+
error: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function userAgent(version: string): string;
|
|
28
|
+
/** True for an absolute http or https URL. */
|
|
29
|
+
export declare function isHttpUrl(input: string): boolean;
|
|
30
|
+
/** Fetches a page's text within the limits. Never throws. */
|
|
31
|
+
export declare function fetchText(input: string, limits: FetchLimits): Promise<FetchResult>;
|
|
32
|
+
/** Runs `fn` over `items`, at most `width` at a time, keeping the order of results. */
|
|
33
|
+
export declare function mapLimit<T, R>(items: readonly T[], width: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network code for `openbriefing check`. Kept apart from obs.ts so the
|
|
3
|
+
* parser stays usable in a browser, with no platform API.
|
|
4
|
+
*
|
|
5
|
+
* Everything fetched is untrusted. The limits here bound time, size and
|
|
6
|
+
* redirects; callers escape control characters before printing.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_LIMITS = {
|
|
9
|
+
timeoutMs: 10_000,
|
|
10
|
+
maxBytes: 5 * 1024 * 1024,
|
|
11
|
+
maxRedirects: 5,
|
|
12
|
+
};
|
|
13
|
+
export function userAgent(version) {
|
|
14
|
+
return `openbriefing-check/${version} (+https://openbriefingstandard.org)`;
|
|
15
|
+
}
|
|
16
|
+
/** True for an absolute http or https URL. */
|
|
17
|
+
export function isHttpUrl(input) {
|
|
18
|
+
try {
|
|
19
|
+
const u = new URL(input);
|
|
20
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Fetches a page's text within the limits. Never throws. */
|
|
27
|
+
export async function fetchText(input, limits) {
|
|
28
|
+
if (!isHttpUrl(input))
|
|
29
|
+
return { ok: false, url: input, error: "only http and https addresses are fetched" };
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
|
|
32
|
+
let url = input;
|
|
33
|
+
try {
|
|
34
|
+
for (let hop = 0;; hop++) {
|
|
35
|
+
const res = await fetch(url, {
|
|
36
|
+
redirect: "manual",
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
headers: { "user-agent": limits.userAgent, accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.5" },
|
|
39
|
+
});
|
|
40
|
+
if (res.status >= 300 && res.status < 400) {
|
|
41
|
+
const location = res.headers.get("location");
|
|
42
|
+
await res.body?.cancel();
|
|
43
|
+
if (!location)
|
|
44
|
+
return { ok: false, url, error: `HTTP ${res.status} with no Location` };
|
|
45
|
+
if (hop >= limits.maxRedirects)
|
|
46
|
+
return { ok: false, url, error: `more than ${limits.maxRedirects} redirects` };
|
|
47
|
+
const next = new URL(location, url).href;
|
|
48
|
+
if (!isHttpUrl(next))
|
|
49
|
+
return { ok: false, url, error: "redirected to an address that is not http or https" };
|
|
50
|
+
url = next;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
await res.body?.cancel();
|
|
55
|
+
return { ok: false, url, error: `HTTP ${res.status}` };
|
|
56
|
+
}
|
|
57
|
+
const declared = Number(res.headers.get("content-length") ?? "");
|
|
58
|
+
if (Number.isFinite(declared) && declared > limits.maxBytes) {
|
|
59
|
+
await res.body?.cancel();
|
|
60
|
+
return { ok: false, url, error: `larger than ${limits.maxBytes} bytes` };
|
|
61
|
+
}
|
|
62
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
63
|
+
const text = await readCapped(res, limits.maxBytes);
|
|
64
|
+
if (text === undefined)
|
|
65
|
+
return { ok: false, url, error: `larger than ${limits.maxBytes} bytes` };
|
|
66
|
+
return { ok: true, url, contentType, text };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const aborted = controller.signal.aborted;
|
|
71
|
+
return { ok: false, url, error: aborted ? `no answer within ${limits.timeoutMs / 1000} s` : err instanceof Error ? cause(err) : String(err) };
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function cause(err) {
|
|
78
|
+
const c = err.cause;
|
|
79
|
+
if (c instanceof Error && c.message)
|
|
80
|
+
return c.message;
|
|
81
|
+
return err.message;
|
|
82
|
+
}
|
|
83
|
+
async function readCapped(res, maxBytes) {
|
|
84
|
+
if (!res.body)
|
|
85
|
+
return "";
|
|
86
|
+
const reader = res.body.getReader();
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let total = 0;
|
|
89
|
+
for (;;) {
|
|
90
|
+
const { done, value } = await reader.read();
|
|
91
|
+
if (done)
|
|
92
|
+
break;
|
|
93
|
+
total += value.byteLength;
|
|
94
|
+
if (total > maxBytes) {
|
|
95
|
+
await reader.cancel();
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
chunks.push(value);
|
|
99
|
+
}
|
|
100
|
+
const all = new Uint8Array(total);
|
|
101
|
+
let at = 0;
|
|
102
|
+
for (const c of chunks) {
|
|
103
|
+
all.set(c, at);
|
|
104
|
+
at += c.byteLength;
|
|
105
|
+
}
|
|
106
|
+
return new TextDecoder("utf-8").decode(all);
|
|
107
|
+
}
|
|
108
|
+
/** Runs `fn` over `items`, at most `width` at a time, keeping the order of results. */
|
|
109
|
+
export async function mapLimit(items, width, fn) {
|
|
110
|
+
const out = new Array(items.length);
|
|
111
|
+
let next = 0;
|
|
112
|
+
const worker = async () => {
|
|
113
|
+
for (;;) {
|
|
114
|
+
const i = next++;
|
|
115
|
+
if (i >= items.length)
|
|
116
|
+
return;
|
|
117
|
+
out[i] = await fn(items[i], i);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await Promise.all(Array.from({ length: Math.min(width, items.length) }, worker));
|
|
121
|
+
return out;
|
|
122
|
+
}
|
package/dist/obs.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare const OBS_NS = "https://openbriefingstandard.org/ns#";
|
|
|
11
11
|
export type ObsTier = "short" | "medium" | "long";
|
|
12
12
|
export type DeriveMode = "verbatim" | "anchor" | "open";
|
|
13
13
|
export type Preference = "summary" | "full";
|
|
14
|
+
/** Where a brief's modified time came from (§13). */
|
|
15
|
+
export type ModifiedFrom = "obs" | "article" | "feed" | "none";
|
|
14
16
|
export interface ObsSummary {
|
|
15
17
|
tier: ObsTier;
|
|
16
18
|
text: string;
|
|
@@ -29,8 +31,10 @@ export interface ObsHighlight {
|
|
|
29
31
|
}
|
|
30
32
|
export interface ObsBrief {
|
|
31
33
|
version: number;
|
|
32
|
-
/**
|
|
33
|
-
modified
|
|
34
|
+
/** The effective modified time, ISO 8601 (§13). Absent when no source gave one. */
|
|
35
|
+
modified?: string;
|
|
36
|
+
/** Where `modified` came from: obs:modified, article:modified_time, the feed item's date, or nowhere. */
|
|
37
|
+
modifiedFrom: ModifiedFrom;
|
|
34
38
|
/** obs:url, normalized, when given. */
|
|
35
39
|
url?: string;
|
|
36
40
|
/** False when obs:url names content other than this page (§11). */
|
|
@@ -101,5 +105,19 @@ export declare function parseObs(doc: Document, pageUrl: string): ParseResult;
|
|
|
101
105
|
export declare function parseFeedObs(item: Element, itemUrl?: string): ParseResult;
|
|
102
106
|
/** Checks an object in the ObsBrief shape and rebuilds it under the same rules. */
|
|
103
107
|
export declare function validateObs(input: unknown): ParseResult;
|
|
104
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* The later effective modified time stands. When the two are equal, or either
|
|
110
|
+
* side has none, the page stands.
|
|
111
|
+
*/
|
|
105
112
|
export declare function mergeObs(page?: ObsBrief, feed?: ObsBrief): ObsBrief | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* A stable hash of what a brief says: every text value and every setting, in a
|
|
115
|
+
* fixed order. Two briefs with the same fingerprint present the same. It leaves
|
|
116
|
+
* out the modified time, and everything that depends on where the tags were
|
|
117
|
+
* read rather than on what they say (the language fallback, describesThisPage,
|
|
118
|
+
* a highlight's inContent), so a page and its feed item agree. Readers compare
|
|
119
|
+
* fingerprints to tell a change, above all when there is no modified time.
|
|
120
|
+
*
|
|
121
|
+
* SHA-256 of the canonical JSON below, in hex, first 16 characters.
|
|
122
|
+
*/
|
|
123
|
+
export declare function fingerprint(brief: ObsBrief): string;
|