Skip to content

Commit 86099ec

Browse files
authored
refactor(web-fetch): move readability extraction to plugin
* refactor(web-fetch): move readability extraction to plugin * fix(web-fetch): cache extractor resolution by config * fix(test): remove redundant stat assertions
1 parent f102dda commit 86099ec

32 files changed

Lines changed: 1073 additions & 311 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919
- TUI/dependencies: remove direct `cli-highlight` usage from the OpenClaw TUI code-block renderer, keeping themed code coloring without the extra root dependency. Thanks @vincentkoc.
2020
- Diagnostics/OTEL: export run, model-call, and tool-execution diagnostic lifecycle events as OTEL spans without retaining live span state. Thanks @vincentkoc.
2121
- Providers/Anthropic Vertex: move the Vertex SDK runtime behind the bundled provider plugin so core no longer owns that provider-specific dependency. Thanks @vincentkoc.
22+
- Plugins/web fetch: move local Readability extraction into a bundled plugin so core no longer owns the Readability and DOM parser dependencies. Thanks @vincentkoc.
2223
- Plugins/activation: expose activation plan reasons and a richer plan API so callers can inspect why a plugin was selected while preserving existing id-list activation behavior. (#70943) Thanks @vincentkoc.
2324
- Plugins/source metadata: expose normalized install-source facts on provider and channel catalogs so onboarding can explain npm pinning, integrity state, and local availability before runtime loads. (#70951) Thanks @vincentkoc.
2425
- Plugins/catalog: pin the official external WeCom channel source to an exact npm release plus dist integrity, with a guard that official external sources stay integrity-pinned. (#70997) Thanks @vincentkoc.

docs/reference/api-usage-costs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ See [Web tools](/tools/web).
153153

154154
- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webFetch.apiKey`
155155

156-
If Firecrawl isn’t configured, the tool falls back to direct fetch + readability (no paid API).
156+
If Firecrawl isn’t configured, the tool falls back to direct fetch plus the bundled `web-readability` plugin (no paid API). Disable `plugins.entries.web-readability.enabled` to skip local Readability extraction.
157157

158158
See [Web tools](/tools/web).
159159

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2+
3+
export default definePluginEntry({
4+
id: "web-readability",
5+
name: "Web Readability Extraction",
6+
description: "Extract readable article content from local HTML web fetch responses.",
7+
register() {
8+
// Runtime is exposed through web-content-extractor.ts so hot web-fetch paths can
9+
// load only the narrow extractor artifact instead of the full plugin entrypoint.
10+
},
11+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"id": "web-readability",
3+
"enabledByDefault": true,
4+
"name": "Web Readability Extraction",
5+
"description": "Extract readable article content from local HTML web fetch responses.",
6+
"contracts": {
7+
"webContentExtractors": ["readability"]
8+
},
9+
"configSchema": {
10+
"type": "object",
11+
"additionalProperties": false,
12+
"properties": {}
13+
}
14+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "@openclaw/web-readability-plugin",
3+
"version": "2026.4.24",
4+
"private": true,
5+
"description": "OpenClaw local Readability web extraction plugin",
6+
"type": "module",
7+
"dependencies": {
8+
"@mozilla/readability": "^0.6.0",
9+
"linkedom": "^0.18.12"
10+
},
11+
"devDependencies": {
12+
"@openclaw/plugin-sdk": "workspace:*"
13+
},
14+
"openclaw": {
15+
"extensions": [
16+
"./index.ts"
17+
]
18+
}
19+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createReadabilityWebContentExtractor } from "./web-content-extractor.js";
3+
4+
const SAMPLE_HTML = `<!doctype html>
5+
<html lang="en">
6+
<head>
7+
<meta charset="utf-8" />
8+
<title>Example Article</title>
9+
</head>
10+
<body>
11+
<nav>
12+
<ul>
13+
<li><a href="/home">Home</a></li>
14+
<li><a href="/about">About</a></li>
15+
</ul>
16+
</nav>
17+
<main>
18+
<article>
19+
<h1>Example Article</h1>
20+
<p>Main content starts here with enough words to satisfy readability.</p>
21+
<p>Second paragraph for a bit more signal.</p>
22+
</article>
23+
</main>
24+
<footer>Footer text</footer>
25+
</body>
26+
</html>`;
27+
28+
describe("web readability extractor", () => {
29+
it("extracts readable text", async () => {
30+
const extractor = createReadabilityWebContentExtractor();
31+
const result = await extractor.extract({
32+
html: SAMPLE_HTML,
33+
url: "https://example.com/article",
34+
extractMode: "text",
35+
});
36+
expect(result?.text).toContain("Main content starts here");
37+
expect(result?.title).toBe("Example Article");
38+
});
39+
40+
it("extracts readable markdown", async () => {
41+
const extractor = createReadabilityWebContentExtractor();
42+
const result = await extractor.extract({
43+
html: SAMPLE_HTML,
44+
url: "https://example.com/article",
45+
extractMode: "markdown",
46+
});
47+
expect(result?.text).toContain("Main content starts here");
48+
expect(result?.title).toBe("Example Article");
49+
});
50+
});
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type {
2+
WebContentExtractionRequest,
3+
WebContentExtractionResult,
4+
WebContentExtractorPlugin,
5+
} from "openclaw/plugin-sdk/web-content-extractor";
6+
import {
7+
htmlToMarkdown,
8+
normalizeWhitespace,
9+
sanitizeHtml,
10+
stripInvisibleUnicode,
11+
} from "openclaw/plugin-sdk/web-content-extractor";
12+
13+
const READABILITY_MAX_HTML_CHARS = 1_000_000;
14+
const READABILITY_MAX_ESTIMATED_NESTING_DEPTH = 3_000;
15+
16+
type ParsedHtml = {
17+
document: Document;
18+
};
19+
20+
type ParseHtml = (html: string) => ParsedHtml;
21+
22+
type ReadabilityResult = {
23+
content?: string;
24+
textContent?: string | null;
25+
title?: string | null;
26+
};
27+
28+
type ReadabilityInstance = {
29+
parse(): ReadabilityResult | null;
30+
};
31+
32+
type ReadabilityConstructor = new (
33+
document: Document,
34+
options: { charThreshold: number },
35+
) => ReadabilityInstance;
36+
37+
type ReadabilityModule = {
38+
Readability: ReadabilityConstructor;
39+
};
40+
41+
type LinkedomModule = {
42+
parseHTML: ParseHtml;
43+
};
44+
45+
const READABILITY_MODULE = "@mozilla/readability";
46+
const LINKEDOM_MODULE = "linkedom";
47+
48+
let readabilityDepsPromise:
49+
| Promise<{
50+
Readability: ReadabilityConstructor;
51+
parseHTML: ParseHtml;
52+
}>
53+
| undefined;
54+
55+
async function loadReadabilityDeps(): Promise<{
56+
Readability: ReadabilityConstructor;
57+
parseHTML: ParseHtml;
58+
}> {
59+
if (!readabilityDepsPromise) {
60+
readabilityDepsPromise = Promise.all([
61+
import(READABILITY_MODULE) as Promise<ReadabilityModule>,
62+
import(LINKEDOM_MODULE) as Promise<LinkedomModule>,
63+
]).then(([readability, linkedom]) => ({
64+
Readability: readability.Readability,
65+
parseHTML: linkedom.parseHTML,
66+
}));
67+
}
68+
try {
69+
return await readabilityDepsPromise;
70+
} catch (error) {
71+
readabilityDepsPromise = undefined;
72+
throw error;
73+
}
74+
}
75+
76+
function normalizeLowercaseStringOrEmpty(value: string): string {
77+
return value.trim().toLowerCase();
78+
}
79+
80+
function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boolean {
81+
const voidTags = new Set([
82+
"area",
83+
"base",
84+
"br",
85+
"col",
86+
"embed",
87+
"hr",
88+
"img",
89+
"input",
90+
"link",
91+
"meta",
92+
"param",
93+
"source",
94+
"track",
95+
"wbr",
96+
]);
97+
98+
let depth = 0;
99+
const len = html.length;
100+
for (let i = 0; i < len; i++) {
101+
if (html.charCodeAt(i) !== 60) {
102+
continue;
103+
}
104+
const next = html.charCodeAt(i + 1);
105+
if (next === 33 || next === 63) {
106+
continue;
107+
}
108+
109+
let j = i + 1;
110+
let closing = false;
111+
if (html.charCodeAt(j) === 47) {
112+
closing = true;
113+
j += 1;
114+
}
115+
116+
while (j < len && html.charCodeAt(j) <= 32) {
117+
j += 1;
118+
}
119+
120+
const nameStart = j;
121+
while (j < len) {
122+
const c = html.charCodeAt(j);
123+
const isNameChar =
124+
(c >= 65 && c <= 90) ||
125+
(c >= 97 && c <= 122) ||
126+
(c >= 48 && c <= 57) ||
127+
c === 58 ||
128+
c === 45;
129+
if (!isNameChar) {
130+
break;
131+
}
132+
j += 1;
133+
}
134+
135+
const tagName = normalizeLowercaseStringOrEmpty(html.slice(nameStart, j));
136+
if (!tagName) {
137+
continue;
138+
}
139+
140+
if (closing) {
141+
depth = Math.max(0, depth - 1);
142+
continue;
143+
}
144+
if (voidTags.has(tagName)) {
145+
continue;
146+
}
147+
148+
let selfClosing = false;
149+
for (let k = j; k < len && k < j + 200; k++) {
150+
const c = html.charCodeAt(k);
151+
if (c === 62) {
152+
selfClosing = html.charCodeAt(k - 1) === 47;
153+
break;
154+
}
155+
}
156+
if (selfClosing) {
157+
continue;
158+
}
159+
160+
depth += 1;
161+
if (depth > maxDepth) {
162+
return true;
163+
}
164+
}
165+
return false;
166+
}
167+
168+
async function extractWithReadability(
169+
request: WebContentExtractionRequest,
170+
): Promise<WebContentExtractionResult | null> {
171+
const cleanHtml = await sanitizeHtml(request.html);
172+
if (
173+
cleanHtml.length > READABILITY_MAX_HTML_CHARS ||
174+
exceedsEstimatedHtmlNestingDepth(cleanHtml, READABILITY_MAX_ESTIMATED_NESTING_DEPTH)
175+
) {
176+
return null;
177+
}
178+
try {
179+
const { Readability, parseHTML } = await loadReadabilityDeps();
180+
const { document } = parseHTML(cleanHtml);
181+
try {
182+
(document as { baseURI?: string }).baseURI = request.url;
183+
} catch {
184+
// Best-effort base URI for relative links.
185+
}
186+
const reader = new Readability(document, { charThreshold: 0 });
187+
const parsed = reader.parse();
188+
if (!parsed?.content) {
189+
return null;
190+
}
191+
const title = parsed.title || undefined;
192+
if (request.extractMode === "text") {
193+
const text = stripInvisibleUnicode(normalizeWhitespace(parsed.textContent ?? ""));
194+
return text ? { text, title } : null;
195+
}
196+
const rendered = htmlToMarkdown(parsed.content);
197+
const text = stripInvisibleUnicode(rendered.text);
198+
return text ? { text, title: title ?? rendered.title } : null;
199+
} catch {
200+
return null;
201+
}
202+
}
203+
204+
export function createReadabilityWebContentExtractor(): WebContentExtractorPlugin {
205+
return {
206+
id: "readability",
207+
label: "Readability",
208+
autoDetectOrder: 10,
209+
extract: extractWithReadability,
210+
};
211+
}

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,10 @@
11211121
"types": "./dist/plugin-sdk/provider-usage.d.ts",
11221122
"default": "./dist/plugin-sdk/provider-usage.js"
11231123
},
1124+
"./plugin-sdk/web-content-extractor": {
1125+
"types": "./dist/plugin-sdk/web-content-extractor.d.ts",
1126+
"default": "./dist/plugin-sdk/web-content-extractor.js"
1127+
},
11241128
"./plugin-sdk/provider-web-fetch-contract": {
11251129
"types": "./dist/plugin-sdk/provider-web-fetch-contract.d.ts",
11261130
"default": "./dist/plugin-sdk/provider-web-fetch-contract.js"
@@ -1588,7 +1592,6 @@
15881592
"@mariozechner/pi-coding-agent": "0.70.2",
15891593
"@mariozechner/pi-tui": "0.70.2",
15901594
"@modelcontextprotocol/sdk": "1.29.0",
1591-
"@mozilla/readability": "^0.6.0",
15921595
"@vincentkoc/qrcode-tui": "0.2.1",
15931596
"ajv": "^8.18.0",
15941597
"chalk": "^5.6.2",
@@ -1603,7 +1606,6 @@
16031606
"jiti": "^2.6.1",
16041607
"json5": "^2.2.3",
16051608
"jszip": "^3.10.1",
1606-
"linkedom": "^0.18.12",
16071609
"markdown-it": "14.1.1",
16081610
"openai": "^6.34.0",
16091611
"osc-progress": "^0.3.0",

pnpm-lock.yaml

Lines changed: 13 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)