@relvehq/agent-ready 0.4.1 → 0.4.3
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 +12 -4
- package/SETUP.md +1 -1
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +4 -1
- package/dist/core/convert.d.ts +17 -0
- package/dist/core/convert.js +181 -8
- package/dist/core/enrich.d.ts +11 -2
- package/dist/core/enrich.js +53 -3
- package/dist/core/faq.d.ts +39 -0
- package/dist/core/faq.js +232 -0
- package/dist/core/resolve.js +5 -3
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/refine/check.d.ts +1 -1
- package/dist/refine/check.js +39 -5
- package/dist/refine/extract.js +20 -2
- package/dist/refine/finalize.d.ts +2 -0
- package/dist/refine/finalize.js +32 -0
- package/dist/refine/refine.js +4 -2
- package/dist/refine/remote.d.ts +1 -1
- package/dist/refine/types.d.ts +2 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +191 -3
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +120 -15
- package/dist/wp/relve-agent-ready/readme.txt +4 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ they got before.
|
|
|
18
18
|
On a real tool page: raw HTML 97,944 tokens, a generic converter 4,618, a
|
|
19
19
|
refined file 1,695.
|
|
20
20
|
|
|
21
|
-
**Start here:** the onboarding guide at <https://
|
|
21
|
+
**Start here:** the onboarding guide at <https://engine.relve.ai/search/agent-ready>
|
|
22
22
|
(every stack, the WordPress plugin download, and how to test it).
|
|
23
23
|
|
|
24
24
|
## Requirements
|
|
@@ -135,11 +135,18 @@ choose what to serve: that would be cloaking.
|
|
|
135
135
|
1. a **template** for the route (`"/blog/:slug"`), written from your data
|
|
136
136
|
2. a **stored** file (`store.get(path)`), e.g. one written by `refine`
|
|
137
137
|
3. **convert**: fetch the page's own HTML (public, no cookies) and turn its
|
|
138
|
-
main content into Markdown (Readability + turndown, GFM tables).
|
|
139
|
-
|
|
138
|
+
main content into Markdown (Readability + turndown, GFM tables).
|
|
139
|
+
Readability is used only when it keeps most of `<main>`'s text, numbers
|
|
140
|
+
and tables; otherwise `<main>` is converted whole. Closed accordion
|
|
141
|
+
answers are kept, loading placeholders ("Loading…", skeletons) are
|
|
142
|
+
dropped, and FAQ answers the HTML lacks are filled in word for word from
|
|
143
|
+
the page's FAQPage JSON-LD, under the page's own FAQ heading (never added
|
|
144
|
+
to a page that shows no FAQ). Under 200 characters of real text is
|
|
145
|
+
discarded.
|
|
140
146
|
4. nothing usable: an `Accept` request gets the normal HTML page, a `.md`
|
|
141
147
|
request gets a 404
|
|
142
|
-
4. **Enrich**: YAML front matter (title, canonical, source), facts from JSON-LD, token count.
|
|
148
|
+
4. **Enrich**: YAML front matter (title, description, canonical, source), facts from JSON-LD, token count.
|
|
149
|
+
A doc that brings its own front matter keeps it; only a missing title or description is added.
|
|
143
150
|
5. **Respond**:
|
|
144
151
|
|
|
145
152
|
```
|
|
@@ -367,6 +374,7 @@ if (d.markdown) {
|
|
|
367
374
|
| `internalPrefix` | `/__md` | rewrite target |
|
|
368
375
|
| `fetchTimeoutMs` | `8000` | page fetch for convert |
|
|
369
376
|
| `minChars` | `200` | quality gate for convert |
|
|
377
|
+
| `maxHtmlBytes` | `5242880` (5 MB) | largest page HTML convert reads; raise it for one very large page |
|
|
370
378
|
|
|
371
379
|
## Commands
|
|
372
380
|
|
package/SETUP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Setting up @relvehq/agent-ready
|
|
2
2
|
|
|
3
|
-
A step-by-step guide for the developer who adds this package to a site. The onboarding page with the same steps, the WordPress plugin download and a test block: https://
|
|
3
|
+
A step-by-step guide for the developer who adds this package to a site. The onboarding page with the same steps, the WordPress plugin download and a test block: https://engine.relve.ai/search/agent-ready
|
|
4
4
|
|
|
5
5
|
## The one command
|
|
6
6
|
|
package/dist/core/config.d.ts
CHANGED
|
@@ -107,6 +107,8 @@ export interface AgentReadyConfig {
|
|
|
107
107
|
fetchTimeoutMs?: number;
|
|
108
108
|
/** Quality gate: converted Markdown with less plain text than this is discarded. Default 200. */
|
|
109
109
|
minChars?: number;
|
|
110
|
+
/** Largest page HTML the converter reads; bigger pages get no Markdown. Default 5 MB. */
|
|
111
|
+
maxHtmlBytes?: number;
|
|
110
112
|
}
|
|
111
113
|
export interface ResolvedAgentReadyConfig {
|
|
112
114
|
readonly __resolved: true;
|
|
@@ -127,6 +129,7 @@ export interface ResolvedAgentReadyConfig {
|
|
|
127
129
|
fetchOrigin: ((request: Request) => string) | null;
|
|
128
130
|
fetchTimeoutMs: number;
|
|
129
131
|
minChars: number;
|
|
132
|
+
maxHtmlBytes: number;
|
|
130
133
|
}
|
|
131
134
|
/** Never served as Markdown, whatever the venture's config says. */
|
|
132
135
|
export declare const ALWAYS_EXCLUDE: readonly string[];
|
|
@@ -134,6 +137,8 @@ export declare const DEFAULT_CACHE_CONTROL = "public, s-maxage=300, stale-while-
|
|
|
134
137
|
export declare const DEFAULT_NEGOTIATED_CACHE_CONTROL = "private, max-age=300";
|
|
135
138
|
export declare const DEFAULT_INTERNAL_PREFIX = "/__md";
|
|
136
139
|
export declare const DEFAULT_INTERNAL_HEADER = "x-agent-ready-internal";
|
|
140
|
+
/** Default cap on the page HTML the converter reads (maxHtmlBytes). */
|
|
141
|
+
export declare const DEFAULT_MAX_HTML_BYTES: number;
|
|
137
142
|
/** Request header the middleware adapter sets on its rewrite: how Markdown was asked for. */
|
|
138
143
|
export declare const VIA_HEADER = "x-agent-ready-via";
|
|
139
144
|
export declare function defineAgentReady(config: AgentReadyConfig | ResolvedAgentReadyConfig): ResolvedAgentReadyConfig;
|
package/dist/core/config.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// EDGE-SAFE. No Node APIs. Middleware bundles import this module.
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.VIA_HEADER = exports.DEFAULT_INTERNAL_HEADER = exports.DEFAULT_INTERNAL_PREFIX = exports.DEFAULT_NEGOTIATED_CACHE_CONTROL = exports.DEFAULT_CACHE_CONTROL = exports.ALWAYS_EXCLUDE = void 0;
|
|
10
|
+
exports.VIA_HEADER = exports.DEFAULT_MAX_HTML_BYTES = exports.DEFAULT_INTERNAL_HEADER = exports.DEFAULT_INTERNAL_PREFIX = exports.DEFAULT_NEGOTIATED_CACHE_CONTROL = exports.DEFAULT_CACHE_CONTROL = exports.ALWAYS_EXCLUDE = void 0;
|
|
11
11
|
exports.defineAgentReady = defineAgentReady;
|
|
12
12
|
exports.isEnabled = isEnabled;
|
|
13
13
|
exports.isExcluded = isExcluded;
|
|
@@ -30,6 +30,8 @@ exports.DEFAULT_CACHE_CONTROL = "public, s-maxage=300, stale-while-revalidate=86
|
|
|
30
30
|
exports.DEFAULT_NEGOTIATED_CACHE_CONTROL = "private, max-age=300";
|
|
31
31
|
exports.DEFAULT_INTERNAL_PREFIX = "/__md";
|
|
32
32
|
exports.DEFAULT_INTERNAL_HEADER = "x-agent-ready-internal";
|
|
33
|
+
/** Default cap on the page HTML the converter reads (maxHtmlBytes). */
|
|
34
|
+
exports.DEFAULT_MAX_HTML_BYTES = 5 * 1024 * 1024;
|
|
33
35
|
/** Request header the middleware adapter sets on its rewrite: how Markdown was asked for. */
|
|
34
36
|
exports.VIA_HEADER = "x-agent-ready-via";
|
|
35
37
|
function defineAgentReady(config) {
|
|
@@ -57,6 +59,7 @@ function defineAgentReady(config) {
|
|
|
57
59
|
fetchOrigin: c.fetchOrigin ?? null,
|
|
58
60
|
fetchTimeoutMs: c.fetchTimeoutMs ?? 8000,
|
|
59
61
|
minChars: c.minChars ?? 200,
|
|
62
|
+
maxHtmlBytes: c.maxHtmlBytes && c.maxHtmlBytes > 0 ? c.maxHtmlBytes : exports.DEFAULT_MAX_HTML_BYTES,
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
/** The master switch. A throwing `enabled` function counts as off. */
|
package/dist/core/convert.d.ts
CHANGED
|
@@ -19,4 +19,21 @@ export declare function htmlToMarkdown(html: string, opts: ConvertOptions): Conv
|
|
|
19
19
|
* Scripts never run here, so do the swap ourselves before [hidden] is stripped.
|
|
20
20
|
*/
|
|
21
21
|
export declare function unfoldNextStreaming(doc: El): void;
|
|
22
|
+
/**
|
|
23
|
+
* Accordions and FAQ toggles: the question is a <button> wired to its panel by
|
|
24
|
+
* aria-controls, and a closed panel is [hidden] (or rendered empty). Show the
|
|
25
|
+
* panel and keep the question as text, before buttons and [hidden] are
|
|
26
|
+
* stripped. Tabs, popups and menus are left alone, and so is a panel that is a
|
|
27
|
+
* menu, listbox or dialog or holds navigation; a trigger whose panel isn't on
|
|
28
|
+
* the page is left too. Hidden elements whose class names an accordion or FAQ
|
|
29
|
+
* are shown as well.
|
|
30
|
+
*/
|
|
31
|
+
export declare function openDisclosures(doc: El): void;
|
|
32
|
+
/**
|
|
33
|
+
* Loading placeholders: busy regions, progress bars, skeleton / shimmer /
|
|
34
|
+
* spinner / loader elements (small, no media), and blocks whose whole text is
|
|
35
|
+
* a loading message ("Loading reviews…"). Real text that only starts with
|
|
36
|
+
* "Loading" stays: the message needs an ellipsis, or to be the bare word.
|
|
37
|
+
*/
|
|
38
|
+
export declare function dropPlaceholders(doc: El): void;
|
|
22
39
|
export {};
|
package/dist/core/convert.js
CHANGED
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
// 1. Read metadata (title, description, lang, canonical, JSON-LD) from <head>.
|
|
5
5
|
// 2. Unfold Next.js streamed Suspense content (<div hidden id="S:n">) into
|
|
6
6
|
// its placeholder, so App Router pages convert with their real content.
|
|
7
|
-
// 3.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// 5.
|
|
7
|
+
// 3. Open accordions (keep the question, show the panel) and drop loading
|
|
8
|
+
// placeholders, before buttons and [hidden] go.
|
|
9
|
+
// 4. Strip chrome: script/style/nav/footer/forms/dialogs/cookie banners.
|
|
10
|
+
// 5. Pick the main content: Mozilla Readability, unless it returns too little
|
|
11
|
+
// of <main>/<article>/<body> (text, numbers or tables), which are used instead.
|
|
12
|
+
// 6. Absolutize links and images, flatten block-level links, give header-less
|
|
11
13
|
// tables a header row so GFM can render them.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
+
// 7. Turndown + GFM (tables, strikethrough, task lists), then collapse whitespace.
|
|
15
|
+
// 8. FAQ answers the HTML lacks (closed accordion panels are often empty)
|
|
16
|
+
// are filled from the FAQPage JSON-LD, under the page's own FAQ heading.
|
|
17
|
+
// 9. Quality gate: under `minChars` of plain text -> null (caller serves HTML).
|
|
14
18
|
//
|
|
15
19
|
// NODE ONLY (linkedom, turndown). Never import this from middleware.
|
|
16
20
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
@@ -19,10 +23,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
19
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
24
|
exports.htmlToMarkdown = htmlToMarkdown;
|
|
21
25
|
exports.unfoldNextStreaming = unfoldNextStreaming;
|
|
26
|
+
exports.openDisclosures = openDisclosures;
|
|
27
|
+
exports.dropPlaceholders = dropPlaceholders;
|
|
22
28
|
const linkedom_1 = require("linkedom");
|
|
23
29
|
const readability_1 = require("@mozilla/readability");
|
|
24
30
|
const turndown_1 = __importDefault(require("turndown"));
|
|
25
31
|
const turndown_plugin_gfm_1 = require("turndown-plugin-gfm");
|
|
32
|
+
const faq_1 = require("./faq");
|
|
26
33
|
const JUNK = [
|
|
27
34
|
"script",
|
|
28
35
|
"style",
|
|
@@ -57,6 +64,19 @@ const JUNK = [
|
|
|
57
64
|
].join(",");
|
|
58
65
|
const COOKIE_RE = /(^|[\s_-])(cookie|cookies|consent|gdpr|onetrust|cookiebot|cc-window|cc-banner|cmp)([\s_-]|$)/i;
|
|
59
66
|
const BLOCK = "div,p,section,article,header,ul,ol,li,table,h1,h2,h3,h4,h5,h6,blockquote,pre,figure";
|
|
67
|
+
// Accordions (changes/agent-ready-quality).
|
|
68
|
+
const DISCLOSURE_CLASS_RE = /(^|[\s_-])(accordion|faqs?)([\s_-]|$)/i;
|
|
69
|
+
const NOT_A_DISCLOSURE_ROLE = /^(tab|menuitem|combobox|option)$/i;
|
|
70
|
+
const NOT_A_PANEL_ROLE = /^(menu|menubar|listbox|dialog|alertdialog|navigation|tree|grid)$/i;
|
|
71
|
+
// Expander labels are controls, not content: their panel opens, the label goes.
|
|
72
|
+
const CONTROL_LABEL_RE = /^(?:(?:show|read|view|see|load)\s+(?:more|less|all)|expand|collapse|toggle|open|close)\b/i;
|
|
73
|
+
// Loading placeholders.
|
|
74
|
+
const PLACEHOLDER_RE = /(^|[\s_-])(skeleton|shimmer|spinner|loader|loading)([\s_-]|$)|(^|\s)animate-(pulse|spin)(\s|$)/i;
|
|
75
|
+
const LOADING_TEXT_RE = /^(?:loading|fetching|please wait)\b[^.!?]{0,40}(?:…|\.{3})$|^(?:loading|fetching|please wait)\.?$/i;
|
|
76
|
+
const PLACEHOLDER_MAX_TEXT = 200;
|
|
77
|
+
// Data-heavy pages: Readability must keep this share of <main>'s numbers and tables.
|
|
78
|
+
const KEEP_DATA_SHARE = 0.8;
|
|
79
|
+
const MIN_NUMBERS_TO_JUDGE = 10;
|
|
60
80
|
let turndown = null;
|
|
61
81
|
function getTurndown() {
|
|
62
82
|
if (turndown)
|
|
@@ -82,6 +102,15 @@ function getTurndown() {
|
|
|
82
102
|
return (firstCell ? "| " : " ") + text + " |";
|
|
83
103
|
},
|
|
84
104
|
});
|
|
105
|
+
// A <summary> that is only text is a question or a label: keep it bold. One
|
|
106
|
+
// holding a heading renders as that heading.
|
|
107
|
+
td.addRule("agentReadySummary", {
|
|
108
|
+
filter: (node) => node.nodeName === "SUMMARY" && !node.querySelector("h1, h2, h3, h4, h5, h6"),
|
|
109
|
+
replacement(content) {
|
|
110
|
+
const text = content.replace(/\s+/g, " ").trim();
|
|
111
|
+
return text ? `\n\n**${text}**\n\n` : "";
|
|
112
|
+
},
|
|
113
|
+
});
|
|
85
114
|
td.remove(["script", "style", "noscript", "iframe", "button", "form", "svg"]);
|
|
86
115
|
turndown = td;
|
|
87
116
|
return td;
|
|
@@ -95,6 +124,8 @@ function htmlToMarkdown(html, opts) {
|
|
|
95
124
|
const meta = readMeta(document, pageUrl);
|
|
96
125
|
const firstH1 = textOf(document.querySelector("h1"));
|
|
97
126
|
unfoldNextStreaming(document);
|
|
127
|
+
openDisclosures(document);
|
|
128
|
+
dropPlaceholders(document);
|
|
98
129
|
stripChrome(document);
|
|
99
130
|
const root = document.querySelector("main") || document.querySelector("article") || document.body || document.documentElement;
|
|
100
131
|
const rootStrategy = document.querySelector("main")
|
|
@@ -112,8 +143,14 @@ function htmlToMarkdown(html, opts) {
|
|
|
112
143
|
const article = new readability_1.Readability(rdoc, { charThreshold: minChars, keepClasses: false }).parse();
|
|
113
144
|
const articleText = textLength(article?.textContent ?? "");
|
|
114
145
|
// Readability is great on articles but can keep a fraction of a listing or
|
|
115
|
-
// profile page. Use it only when it kept most of what <main> holds
|
|
116
|
-
|
|
146
|
+
// profile page. Use it only when it kept most of what <main> holds: its
|
|
147
|
+
// text, and on data-heavy pages its numbers and tables (a tool page kept
|
|
148
|
+
// 65-87% of the text but 20-70% of the prices, traffic and scores).
|
|
149
|
+
if (article &&
|
|
150
|
+
article.content &&
|
|
151
|
+
articleText >= minChars &&
|
|
152
|
+
articleText >= rootText * 0.6 &&
|
|
153
|
+
keepsTheData(root, article.content, article.textContent ?? "")) {
|
|
117
154
|
contentHtml = article.content;
|
|
118
155
|
strategy = "readability";
|
|
119
156
|
readabilityTitle = article.title || undefined;
|
|
@@ -131,6 +168,9 @@ function htmlToMarkdown(html, opts) {
|
|
|
131
168
|
const heading = firstH1 || readabilityTitle || meta.title;
|
|
132
169
|
if (heading && !/^#\s/m.test(markdown))
|
|
133
170
|
markdown = `# ${oneLine(heading)}\n\n${markdown}`;
|
|
171
|
+
const faq = (0, faq_1.faqPairs)(meta.jsonLd ?? []);
|
|
172
|
+
if (faq.length)
|
|
173
|
+
markdown = (0, faq_1.fillFaqAnswers)(markdown, faq, (pair) => faqAnswerMarkdown(pair, pageUrl));
|
|
134
174
|
const textLen = plainTextLength(markdown);
|
|
135
175
|
if (!markdown || textLen < minChars)
|
|
136
176
|
return null;
|
|
@@ -186,6 +226,96 @@ function unfoldNextStreaming(doc) {
|
|
|
186
226
|
segment.parentNode.removeChild(segment);
|
|
187
227
|
}
|
|
188
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Accordions and FAQ toggles: the question is a <button> wired to its panel by
|
|
231
|
+
* aria-controls, and a closed panel is [hidden] (or rendered empty). Show the
|
|
232
|
+
* panel and keep the question as text, before buttons and [hidden] are
|
|
233
|
+
* stripped. Tabs, popups and menus are left alone, and so is a panel that is a
|
|
234
|
+
* menu, listbox or dialog or holds navigation; a trigger whose panel isn't on
|
|
235
|
+
* the page is left too. Hidden elements whose class names an accordion or FAQ
|
|
236
|
+
* are shown as well.
|
|
237
|
+
*/
|
|
238
|
+
function openDisclosures(doc) {
|
|
239
|
+
for (const trigger of Array.from(doc.querySelectorAll("[aria-controls][aria-expanded]"))) {
|
|
240
|
+
if (trigger.hasAttribute("aria-haspopup") || NOT_A_DISCLOSURE_ROLE.test(trigger.getAttribute("role") || ""))
|
|
241
|
+
continue;
|
|
242
|
+
const panels = String(trigger.getAttribute("aria-controls"))
|
|
243
|
+
.split(/\s+/)
|
|
244
|
+
.filter(Boolean)
|
|
245
|
+
.map((id) => byId(doc, id))
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
if (!panels.length)
|
|
248
|
+
continue;
|
|
249
|
+
const notAPanel = (p) => NOT_A_PANEL_ROLE.test(p.getAttribute("role") || "") ||
|
|
250
|
+
String(p.nodeName).toLowerCase() === "nav" ||
|
|
251
|
+
!!p.querySelector("nav, [role=navigation], [role=menu]");
|
|
252
|
+
if (panels.some(notAPanel))
|
|
253
|
+
continue;
|
|
254
|
+
panels.forEach(show);
|
|
255
|
+
keepTriggerText(doc, trigger);
|
|
256
|
+
}
|
|
257
|
+
for (const el of Array.from(doc.querySelectorAll("[class]"))) {
|
|
258
|
+
if (DISCLOSURE_CLASS_RE.test(el.getAttribute("class") || ""))
|
|
259
|
+
show(el);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function byId(doc, id) {
|
|
263
|
+
if (doc.getElementById)
|
|
264
|
+
return doc.getElementById(id);
|
|
265
|
+
return doc.querySelector(`[id="${id.replace(/["\\]/g, "\\$&")}"]`);
|
|
266
|
+
}
|
|
267
|
+
function show(el) {
|
|
268
|
+
el.removeAttribute("hidden");
|
|
269
|
+
if (String(el.getAttribute("aria-hidden")).toLowerCase() === "true")
|
|
270
|
+
el.removeAttribute("aria-hidden");
|
|
271
|
+
const style = el.getAttribute("style");
|
|
272
|
+
if (style)
|
|
273
|
+
el.setAttribute("style", style.replace(/(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*;?/gi, ""));
|
|
274
|
+
}
|
|
275
|
+
/** A question <button> becomes its text: plain inside a heading, else a bold paragraph. */
|
|
276
|
+
function keepTriggerText(doc, trigger) {
|
|
277
|
+
if (String(trigger.nodeName).toLowerCase() !== "button" || !trigger.parentNode)
|
|
278
|
+
return;
|
|
279
|
+
for (const icon of Array.from(trigger.querySelectorAll("svg, [aria-hidden=true]")))
|
|
280
|
+
remove(icon);
|
|
281
|
+
const text = oneLine(trigger.textContent || "");
|
|
282
|
+
if (!text || CONTROL_LABEL_RE.test(text))
|
|
283
|
+
return;
|
|
284
|
+
if (trigger.closest && trigger.closest("h1, h2, h3, h4, h5, h6, summary, dt")) {
|
|
285
|
+
trigger.parentNode.replaceChild(doc.createTextNode(text), trigger);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const p = doc.createElement("p");
|
|
289
|
+
const strong = doc.createElement("strong");
|
|
290
|
+
strong.textContent = text;
|
|
291
|
+
p.appendChild(strong);
|
|
292
|
+
trigger.parentNode.replaceChild(p, trigger);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Loading placeholders: busy regions, progress bars, skeleton / shimmer /
|
|
296
|
+
* spinner / loader elements (small, no media), and blocks whose whole text is
|
|
297
|
+
* a loading message ("Loading reviews…"). Real text that only starts with
|
|
298
|
+
* "Loading" stays: the message needs an ellipsis, or to be the bare word.
|
|
299
|
+
*/
|
|
300
|
+
function dropPlaceholders(doc) {
|
|
301
|
+
const small = (el) => textLength(el.textContent || "") < PLACEHOLDER_MAX_TEXT && !el.querySelector("img, picture, video, table");
|
|
302
|
+
const skip = (el) => /^(html|body|main|img|picture|video|source|td|th)$/.test(String(el.nodeName).toLowerCase());
|
|
303
|
+
for (const el of Array.from(doc.querySelectorAll('[aria-busy="true"], [role="progressbar"]'))) {
|
|
304
|
+
if (!skip(el) && small(el))
|
|
305
|
+
remove(el);
|
|
306
|
+
}
|
|
307
|
+
for (const el of Array.from(doc.querySelectorAll("[id], [class]"))) {
|
|
308
|
+
if (skip(el))
|
|
309
|
+
continue;
|
|
310
|
+
const hay = `${el.getAttribute("id") || ""} ${el.getAttribute("class") || ""}`;
|
|
311
|
+
if (PLACEHOLDER_RE.test(hay) && small(el))
|
|
312
|
+
remove(el);
|
|
313
|
+
}
|
|
314
|
+
for (const el of Array.from(doc.querySelectorAll("p, div, span, li, section, small, em, strong"))) {
|
|
315
|
+
if (el.parentNode && LOADING_TEXT_RE.test(oneLine(el.textContent || "")))
|
|
316
|
+
remove(el);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
189
319
|
function stripChrome(doc) {
|
|
190
320
|
for (const el of Array.from(doc.querySelectorAll(JUNK)))
|
|
191
321
|
remove(el);
|
|
@@ -289,6 +419,49 @@ function headerRows(doc) {
|
|
|
289
419
|
}
|
|
290
420
|
}
|
|
291
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Readability kept the data: 80% of <main>'s distinct numbers (judged when it
|
|
424
|
+
* has 10 or more) and 80% of its tables.
|
|
425
|
+
*/
|
|
426
|
+
function keepsTheData(root, articleHtml, articleText) {
|
|
427
|
+
if (!root)
|
|
428
|
+
return true;
|
|
429
|
+
const rootNums = distinctNumbers(root.textContent || "");
|
|
430
|
+
if (rootNums.size >= MIN_NUMBERS_TO_JUDGE) {
|
|
431
|
+
const kept = distinctNumbers(articleText);
|
|
432
|
+
let n = 0;
|
|
433
|
+
for (const x of rootNums)
|
|
434
|
+
if (kept.has(x))
|
|
435
|
+
n++;
|
|
436
|
+
if (n < rootNums.size * KEEP_DATA_SHARE)
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
const rootTables = root.querySelectorAll("table").length;
|
|
440
|
+
const keptTables = (articleHtml.match(/<table\b/gi) ?? []).length;
|
|
441
|
+
return rootTables === 0 || keptTables >= rootTables * KEEP_DATA_SHARE;
|
|
442
|
+
}
|
|
443
|
+
/** Numbers of 2+ characters, thousands separators removed ("1,400" = "1400"). */
|
|
444
|
+
function distinctNumbers(s) {
|
|
445
|
+
// Typed: under the site's tsconfig, `match() ?? []` is a union whose
|
|
446
|
+
// .filter callback gets `never` (the site build fails on it).
|
|
447
|
+
const nums = s.replace(/(\d),(?=\d{3})/g, "$1").match(/\d+(?:\.\d+)?/g) ?? [];
|
|
448
|
+
return new Set(nums.filter((n) => n.length > 1));
|
|
449
|
+
}
|
|
450
|
+
/** An FAQ answer from JSON-LD as Markdown: HTML through turndown, plain text as it is. */
|
|
451
|
+
function faqAnswerMarkdown(pair, pageUrl) {
|
|
452
|
+
if ((0, faq_1.answerHasHtml)(pair)) {
|
|
453
|
+
const { document: adoc } = (0, linkedom_1.parseHTML)(`<!doctype html><html><head></head><body>${pair.answer}</body></html>`);
|
|
454
|
+
absolutize(adoc, pageUrl);
|
|
455
|
+
const md = tidy(getTurndown().turndown(adoc.body.innerHTML));
|
|
456
|
+
if (md)
|
|
457
|
+
return md;
|
|
458
|
+
}
|
|
459
|
+
// Plain answers can carry Markdown links to site paths: make them absolute.
|
|
460
|
+
return (0, faq_1.plainAnswer)(pair.answerText).replace(/\]\((\/[^)\s]*)\)/g, (m, path) => {
|
|
461
|
+
const abs = absolute(path, pageUrl);
|
|
462
|
+
return abs ? `](${abs})` : m;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
292
465
|
function unwrap(el) {
|
|
293
466
|
const parent = el.parentNode;
|
|
294
467
|
if (!parent)
|
package/dist/core/enrich.d.ts
CHANGED
|
@@ -16,12 +16,21 @@ export declare function estimateTokens(text: string): number;
|
|
|
16
16
|
export declare function hasFrontMatter(markdown: string): boolean;
|
|
17
17
|
/**
|
|
18
18
|
* Builds the final Markdown body. A doc that already carries front matter
|
|
19
|
-
* (hand-written or stored docs usually do)
|
|
20
|
-
*
|
|
19
|
+
* (hand-written or stored docs usually do) keeps its block, so the package
|
|
20
|
+
* never stacks a second one on top of an author's; only a `title` or
|
|
21
|
+
* `description` the block lacks is filled in from the doc's metadata.
|
|
21
22
|
*/
|
|
22
23
|
export declare function enrich(doc: MarkdownDoc, meta: EnrichMeta): Enriched;
|
|
23
24
|
/** A YAML front-matter block. Empty values are dropped; strings are JSON-quoted (valid YAML). */
|
|
24
25
|
export declare function frontMatter(fields: Record<string, string | number | null | undefined>): string;
|
|
26
|
+
/** Top-level keys of the doc's front matter; empty when it has none. */
|
|
27
|
+
export declare function frontMatterKeys(markdown: string): Set<string>;
|
|
28
|
+
/**
|
|
29
|
+
* Sets one key of the doc's front matter: replaces its value (with any
|
|
30
|
+
* indented continuation lines) or adds it after `title`, else first. Other
|
|
31
|
+
* lines are kept as they are. A doc without front matter is returned unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export declare function setFrontMatterField(markdown: string, key: string, value: string): string;
|
|
25
34
|
/**
|
|
26
35
|
* A short "## Structured data" section from the page's JSON-LD, or null when
|
|
27
36
|
* nothing useful is there. Only well-known, flat facts are listed; the raw
|
package/dist/core/enrich.js
CHANGED
|
@@ -7,6 +7,8 @@ exports.estimateTokens = estimateTokens;
|
|
|
7
7
|
exports.hasFrontMatter = hasFrontMatter;
|
|
8
8
|
exports.enrich = enrich;
|
|
9
9
|
exports.frontMatter = frontMatter;
|
|
10
|
+
exports.frontMatterKeys = frontMatterKeys;
|
|
11
|
+
exports.setFrontMatterField = setFrontMatterField;
|
|
10
12
|
exports.jsonLdFacts = jsonLdFacts;
|
|
11
13
|
/** Rough token count: ~4 characters per token for English prose. */
|
|
12
14
|
function estimateTokens(text) {
|
|
@@ -17,13 +19,20 @@ function hasFrontMatter(markdown) {
|
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* Builds the final Markdown body. A doc that already carries front matter
|
|
20
|
-
* (hand-written or stored docs usually do)
|
|
21
|
-
*
|
|
22
|
+
* (hand-written or stored docs usually do) keeps its block, so the package
|
|
23
|
+
* never stacks a second one on top of an author's; only a `title` or
|
|
24
|
+
* `description` the block lacks is filled in from the doc's metadata.
|
|
22
25
|
*/
|
|
23
26
|
function enrich(doc, meta) {
|
|
24
27
|
const markdown = doc.markdown.trim();
|
|
25
28
|
if (hasFrontMatter(markdown)) {
|
|
26
|
-
|
|
29
|
+
let md = markdown;
|
|
30
|
+
const keys = frontMatterKeys(md);
|
|
31
|
+
if (doc.title && !keys.has("title"))
|
|
32
|
+
md = setFrontMatterField(md, "title", doc.title);
|
|
33
|
+
if (doc.description && !keys.has("description"))
|
|
34
|
+
md = setFrontMatterField(md, "description", doc.description);
|
|
35
|
+
const body = md + "\n";
|
|
27
36
|
return { body, tokens: estimateTokens(body) };
|
|
28
37
|
}
|
|
29
38
|
const fm = frontMatter({
|
|
@@ -50,6 +59,47 @@ function frontMatter(fields) {
|
|
|
50
59
|
lines.push("---");
|
|
51
60
|
return lines.join("\n");
|
|
52
61
|
}
|
|
62
|
+
const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;
|
|
63
|
+
/** Top-level keys of the doc's front matter; empty when it has none. */
|
|
64
|
+
function frontMatterKeys(markdown) {
|
|
65
|
+
const keys = new Set();
|
|
66
|
+
const m = FRONT_MATTER_RE.exec(markdown);
|
|
67
|
+
if (!m)
|
|
68
|
+
return keys;
|
|
69
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
70
|
+
const k = /^([A-Za-z_][\w-]*):/.exec(line);
|
|
71
|
+
if (k)
|
|
72
|
+
keys.add(k[1]);
|
|
73
|
+
}
|
|
74
|
+
return keys;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Sets one key of the doc's front matter: replaces its value (with any
|
|
78
|
+
* indented continuation lines) or adds it after `title`, else first. Other
|
|
79
|
+
* lines are kept as they are. A doc without front matter is returned unchanged.
|
|
80
|
+
*/
|
|
81
|
+
function setFrontMatterField(markdown, key, value) {
|
|
82
|
+
const m = FRONT_MATTER_RE.exec(markdown);
|
|
83
|
+
if (!m)
|
|
84
|
+
return markdown;
|
|
85
|
+
const lines = m[1].split(/\r?\n/);
|
|
86
|
+
const entry = `${key}: ${yamlScalar(value)}`;
|
|
87
|
+
const span = (start) => {
|
|
88
|
+
let end = start + 1;
|
|
89
|
+
while (end < lines.length && /^[ \t]/.test(lines[end]))
|
|
90
|
+
end++;
|
|
91
|
+
return end;
|
|
92
|
+
};
|
|
93
|
+
const at = lines.findIndex((l) => l.startsWith(`${key}:`));
|
|
94
|
+
if (at >= 0) {
|
|
95
|
+
lines.splice(at, span(at) - at, entry);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const title = lines.findIndex((l) => l.startsWith("title:"));
|
|
99
|
+
lines.splice(title >= 0 ? span(title) : 0, 0, entry);
|
|
100
|
+
}
|
|
101
|
+
return `---\n${lines.join("\n")}\n---${m[2] ? "\n" : ""}${markdown.slice(m[0].length)}`;
|
|
102
|
+
}
|
|
53
103
|
function yamlScalar(value) {
|
|
54
104
|
if (typeof value === "number")
|
|
55
105
|
return String(value);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface FaqPair {
|
|
2
|
+
/** The question, on one line. */
|
|
3
|
+
question: string;
|
|
4
|
+
/** The answer as the JSON-LD gives it (may hold HTML). */
|
|
5
|
+
answer: string;
|
|
6
|
+
/** The answer as plain text: tags stripped, entities decoded, one line. */
|
|
7
|
+
answerText: string;
|
|
8
|
+
}
|
|
9
|
+
/** Every Question / acceptedAnswer pair of the page's FAQPage blocks, in page order, deduped by question. */
|
|
10
|
+
export declare function faqPairs(jsonLd: unknown[]): FaqPair[];
|
|
11
|
+
/** True when the answer holds HTML markup rather than plain text. */
|
|
12
|
+
export declare function answerHasHtml(pair: FaqPair): boolean;
|
|
13
|
+
/** Does the page's text (one line per block) show an FAQ heading? */
|
|
14
|
+
export declare function pageShowsFaq(text: string): boolean;
|
|
15
|
+
/** Does the Markdown have an FAQ heading? */
|
|
16
|
+
export declare function hasFaqSection(markdown: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Convert: write each answer the FAQ section lacks, word for word, under its
|
|
19
|
+
* question line; a question with no line is appended at the end of the
|
|
20
|
+
* section as a heading one level down. No FAQ heading: returned unchanged.
|
|
21
|
+
*/
|
|
22
|
+
export declare function fillFaqAnswers(markdown: string, pairs: FaqPair[], render: (pair: FaqPair) => string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Refine: the FAQ section holds exactly the page's questions and answers, in
|
|
25
|
+
* the page's order. Null when the document has no FAQ section.
|
|
26
|
+
*/
|
|
27
|
+
export declare function replaceFaqSection(markdown: string, pairs: FaqPair[]): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Refine: add a "## FAQ" section with the page's questions and answers, before
|
|
30
|
+
* a closing "## About…" / "## Source…" section when the document has one,
|
|
31
|
+
* else at the end.
|
|
32
|
+
*/
|
|
33
|
+
export declare function addFaqSection(markdown: string, pairs: FaqPair[]): string;
|
|
34
|
+
/** Pairs whose answer is not in the FAQ section word for word; null when there is no section. */
|
|
35
|
+
export declare function missingFaqAnswers(markdown: string, pairs: FaqPair[]): FaqPair[] | null;
|
|
36
|
+
/** A plain answer as a Markdown paragraph: a leading "#", ">", "- " or "1." can't start a block. */
|
|
37
|
+
export declare function plainAnswer(text: string): string;
|
|
38
|
+
/** HTML or text -> one line of plain text. */
|
|
39
|
+
export declare function plainText(s: string): string;
|