render: add "shell" option to --strip-mode

Some additional API changes:
- Add strip-mode support to pdf/png generation.
- Add LP.dump which provides greater content gathering capability to CDP,
  exposing most `fetch` dump-related parameters (e.g. format, strip, selector,
  ...)

The new "--strip-mode shell" is designed to try to remove non-content elements
such as the header and footer. The end goal is to use readibility.js test cases
as a baseline, but this isn't a port of readibility.js.

This is just the basic implementation of this, e.g removing a few key tags, e.g.
<header>, <footer> and considering some specific roles.

Even if --strip-mode shell is used, we might decide to stick with a whole dump:
it's better to strip not enough than to strip too much. This currently works by
measuring the ratio of non-link text of the stripped vs unstripped page.
This commit is contained in:
Karl Seguin committed 2026-09-11 06:35:26 +08:00
1 parent c21462cc38
commit cdceca37c3
11 files changed
+447 -27

No files matched your search

+146
View File
@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const Frame = @import("Frame.zig");
const StyleManager = @import("StyleManager.zig");
@@ -28,6 +29,8 @@ const Slot = @import("webapi/element/html/Slot.zig");
const dump_html = @import("dump.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
const log = lp.log;
pub const Strip = dump_html.Opts.Strip;
const RenderTree = @This();
@@ -216,6 +219,104 @@ pub fn isStandaloneAnchor(el: *Element, frame: *Frame) bool {
return true;
}
/// Shell stripping is undone when it would remove most of the content. Better
/// to leave too much in than to strip too muchout. Non-link text is
/// the measure (nav and footer text is mostly links); a page with none is
/// judged on all of its text.
pub fn resolveStrip(root: *Node, strip: Strip, frame: *Frame) Strip {
if (strip.shell == false) {
return strip;
}
var render_with_shell = strip;
render_with_shell.shell = false;
var m: Measure = .{};
const tree: RenderTree = .{ .frame = frame, .root = root, .strip = render_with_shell };
tree.measure(root, .{}, &m);
const total, const shell = if (m.prose > 0) .{ m.prose, m.shell_prose } else .{ m.all, m.shell_all };
const kept = total - shell;
if (kept * shell_undo_ratio < total) {
log.info(.browser, "strip shell undone", .{ .kept = kept, .total = total });
return render_with_shell;
}
return strip;
}
/// Undo when the shell holds more than this share of the text.
const shell_undo_ratio = 4;
const Measure = struct {
all: usize = 0,
prose: usize = 0,
shell_all: usize = 0,
shell_prose: usize = 0,
const Where = struct {
shell: bool = false,
link: bool = false,
};
fn count(self: *Measure, text: []const u8, where: Where) void {
var n: usize = 0;
for (text) |c| {
if (!std.ascii.isWhitespace(c)) {
n += 1;
}
}
self.all += n;
if (where.shell) {
self.shell_all += n;
}
if (!where.link) {
self.prose += n;
if (where.shell) {
self.shell_prose += n;
}
}
}
};
fn measure(self: *const RenderTree, node: *Node, where: Measure.Where, m: *Measure) void {
switch (node._type) {
.document, .document_fragment => {
var it = self.children(node, false);
while (it.next()) |child| {
self.measureChild(child, where, m);
}
},
else => if (self.classify(node, .{})) |child| {
self.measureChild(child, where, m);
},
}
}
fn measureChild(self: *const RenderTree, child: Child, where: Measure.Where, m: *Measure) void {
switch (child.what) {
.text => |text| m.count(text, where),
.element => |d| {
const el = child.node.subtype(Element);
const inner: Measure.Where = .{
.shell = where.shell or dump_html.isShellElement(el),
.link = where.link or el.getTag() == .anchor,
};
if (el.is(Slot)) |slot| {
var it = self.slotted(slot);
while (it.next()) |c| {
self.measureChild(c, inner, m);
}
return;
}
const boxed = d == .flex or d == .grid;
var it = self.content(el, boxed);
while (it.next()) |c| {
self.measureChild(c, inner, m);
}
},
}
}
const ContentInfo = struct {
has_visible: bool,
has_block: bool,
@@ -252,3 +353,48 @@ pub fn analyzeContent(root: *Node, frame: *Frame) ContentInfo {
}
return result;
}
const testing = @import("../testing.zig");
test "RenderTree: resolveStrip keeps shell when the content holds the text" {
try testing.expectEqual(true, try resolveShell(
\\<nav><a href="/">Home</a><a href="/about">About us</a><a href="/blog">Blog</a></nav><main><p>Some article text.</p></main><footer>Copyright</footer>
));
}
test "RenderTree: resolveStrip undoes shell when the shell holds the text" {
try testing.expectEqual(false, try resolveShell(
\\<nav><p>All of the text on this page lives inside a nav element.</p></nav><main>hi</main>
));
}
test "RenderTree: resolveStrip judges an all-link page on its links" {
try testing.expectEqual(false, try resolveShell(
\\<nav><a href="/1">one</a><a href="/2">two</a><a href="/3">three</a></nav><p><a href="/x">x</a></p>
));
try testing.expectEqual(true, try resolveShell(
\\<nav><a href="/1">one</a></nav><p><a href="/x">a longer list of links</a><a href="/y">and another</a></p>
));
}
test "RenderTree: resolveStrip ignores what other strip bits already drop" {
// The script text is not content; without strip.js it would tip the
// balance toward keeping the shell.
try testing.expectEqual(false, try resolveShell(
\\<nav><p>All of the text on this page lives inside a nav element.</p></nav><main>hi<script>var a_very_long_script_body_that_is_not_content = 1;</script></main>
));
}
fn resolveShell(html: []const u8) !bool {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
const strip = resolveStrip(div.asNode(), .{ .js = true, .shell = true }, frame);
// Only the shell bit is ever undone.
try testing.expectEqual(true, strip.js);
return strip.shell;
}
+78 -2
View File
@@ -33,11 +33,12 @@ pub const Opts = struct {
/// appended.
max_bytes: ?u32 = null,
pub const Strip = packed struct(u4) {
pub const Strip = packed struct(u5) {
js: bool = false,
ui: bool = false,
css: bool = false,
invisible: bool = false,
shell: bool = false,
};
pub const Shadow = union(enum) {
@@ -364,7 +365,7 @@ fn isVoidElement(el: *Node.Element) bool {
pub fn shouldStripElement(el: *Node.Element, strip: Opts.Strip, frame: *Frame) bool {
// Fast path: with no strip flags set (every innerHTML/outerHTML call)
if (@as(u4, @bitCast(strip)) == 0) {
if (@as(u5, @bitCast(strip)) == 0) {
return false;
}
@@ -412,6 +413,51 @@ pub fn shouldStripElement(el: *Node.Element, strip: Opts.Strip, frame: *Frame) b
return true;
}
if (strip.shell and isShellElement(el)) {
return true;
}
return false;
}
/// Page chrome by markup alone. <header> and <footer> only count at the page
/// level: inside an article, section, main, nav or aside they belong to that
/// content, which is also how the banner/contentinfo roles are assigned.
pub fn isShellElement(el: *Node.Element) bool {
switch (el.getTag()) {
.nav, .aside, .dialog => return true,
.header, .footer => return !hasSectioningAncestor(el),
else => {},
}
return hasRole(el, &.{ "banner", "complementary", "contentinfo", "navigation", "search", "dialog", "alertdialog", "menu", "menubar" });
}
fn hasSectioningAncestor(el: *Node.Element) bool {
var node = el.asNode().parentNode();
while (node) |n| : (node = n.parentNode()) {
if (n.is(Node.Element)) |ancestor| {
switch (ancestor.getTag()) {
.article, .aside, .main, .nav, .section => return true,
else => {},
}
if (hasRole(ancestor, &.{ "article", "complementary", "main", "navigation", "region" })) {
return true;
}
}
}
return false;
}
// ARIA `role` is a space-separated fallback list; the first token wins.
fn hasRole(el: *Node.Element, roles: []const []const u8) bool {
const attr = el.getAttributeSafe(comptime .wrap("role")) orelse return false;
var it = std.mem.tokenizeAny(u8, attr, " \t\n\r");
const role = it.next() orelse return false;
for (roles) |candidate| {
if (std.ascii.eqlIgnoreCase(role, candidate)) {
return true;
}
}
return false;
}
@@ -584,3 +630,33 @@ test "dump: strip.invisible removes author display:none elements" {
\\<html><head><style>.hidden{display:none}</style><link rel="stylesheet" href="data:text/css,"><script>var a=1;</script></head><body><h1>Title</h1><img><svg></svg><noscript>nojs</noscript><p>visible &amp; well</p></body></html>
);
}
test "dump: strip.shell removes page chrome but keeps sectioned header/footer" {
try expectShellDump(
\\<header>H</header><nav>N</nav><main><header>MH</header><p>body</p><footer>MF</footer></main><article><footer>AF</footer></article><aside>A</aside><dialog>D</dialog><footer>F</footer>
,
\\<div><main><header>MH</header><p>body</p><footer>MF</footer></main><article><footer>AF</footer></article></div>
);
}
test "dump: strip.shell honours landmark roles" {
try expectShellDump(
\\<div role="navigation">N</div><div role="BANNER search">B</div><section><div role="contentinfo">C</div></section><p>x</p><div role="region"><header>RH</header></div><div role="main"><footer>MF</footer></div>
,
\\<div><section></section><p>x</p><div role="region"><header>RH</header></div><div role="main"><footer>MF</footer></div></div>
);
}
fn expectShellDump(html: []const u8, expected: []const u8) !void {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try deep(div.asNode(), .{ .strip = .{ .shell = true } }, &aw.writer, frame);
try testing.expectString(expected, aw.written());
}
+16
View File
@@ -804,6 +804,22 @@ test "browser.markdown: strip.ui drops images and other visual elements" {
try testing.expectString("\nText more\n", aw.written());
}
test "browser.markdown: strip.shell drops page chrome" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<nav><a href=\"/\">Home</a></nav><main><p>Body</p></main><footer>Legal</footer>");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try dump(div.asNode(), .{ .strip = .{ .shell = true } }, &aw.writer, frame);
try testing.expectString("\nBody\n", aw.written());
}
test "browser.markdown: max_bytes leaves output untouched when under cap" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
+3 -1
View File
@@ -31,6 +31,7 @@ const Base64Writer = @import("../Base64Writer.zig");
const Frame = @import("Frame.zig");
const screenshot = @import("screenshot.zig");
const RenderTree = @import("RenderTree.zig");
const Node = @import("webapi/Node.zig");
@@ -55,6 +56,7 @@ pub const Opts = struct {
/// that ends up empty is an error. `parsePageRanges` reads CDP's text
/// form.
page_ranges: []const PageRange = &.{},
strip: RenderTree.Strip = .{},
};
/// 1-based, inclusive. `to = maxInt(u32)` for an open end ("5-").
@@ -121,7 +123,7 @@ pub fn prepare(arena: Allocator, node: *Node, opts: Opts, frame: *Frame) !Prepar
const prepared: Prepared = .{
.arena = arena,
.opts = opts,
.blocks = try screenshot.collect(arena, node, frame),
.blocks = try screenshot.collect(arena, node, opts.strip, frame),
.renderer = try screenshot.rendererFor(frame),
};
if (opts.page_ranges.len > 0) {
+32 -4
View File
@@ -40,6 +40,7 @@ pub const Opts = struct {
height: u32 = 0,
clip: ?Clip = null,
scale: f32 = 1.0,
strip: RenderTree.Strip = .{},
const Clip = struct {
x: f32,
@@ -136,16 +137,16 @@ pub fn preparePng(arena: Allocator, node: *Node, opts: Opts, frame: *Frame) !Pre
}
return .{
.opts = opts,
.blocks = try collect(arena, node, frame),
.blocks = try collect(arena, node, opts.strip, frame),
.renderer = try rendererFor(frame),
};
}
pub fn collect(arena: Allocator, node: *Node, frame: *Frame) ![]const LpBlock {
pub fn collect(arena: Allocator, node: *Node, strip: RenderTree.Strip, frame: *Frame) ![]const LpBlock {
var builder: Builder = .{
.frame = frame,
.arena = arena,
.tree = .{ .frame = frame, .root = node },
.tree = .{ .frame = frame, .root = node, .strip = strip },
};
try builder.render(node);
try builder.closeBlock();
@@ -1487,7 +1488,7 @@ test "browser.screenshot: flex from a stylesheet" {
// The text of each block on its own line: what renders, not how.
fn collectLines(node: *Node, frame: *Frame) ![]const u8 {
const arena = testing.arena_allocator;
const blocks = try collect(arena, node, frame);
const blocks = try collect(arena, node, .{}, frame);
var out: std.ArrayList(u8) = .empty;
for (blocks, 0..) |b, i| {
if (i > 0) try out.append(arena, '\n');
@@ -1508,3 +1509,30 @@ fn testPng(html: []const u8, width: u32) ![]const u8 {
_ = try png(testing.arena_allocator, div.asNode(), .{ .width = width }, &aw.writer, frame);
return aw.written();
}
test "browser.screenshot: collect honours strip flags" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(),
\\<nav><p>menu</p></nav><p>Body text</p><img alt="pic"><footer><p>legal</p></footer>
);
const arena = testing.arena_allocator;
const S = struct {
fn text(b: LpBlock, a: Allocator) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
for (b.spans[0..b.spans_len]) |sp| try out.appendSlice(a, sp.text[0..sp.len]);
return out.items;
}
};
const all = try collect(arena, div.asNode(), .{}, frame);
try testing.expectEqual(4, all.len);
const stripped = try collect(arena, div.asNode(), .{ .shell = true, .ui = true }, frame);
try testing.expectEqual(1, stripped.len);
try testing.expectEqual("Body text", try S.text(stripped[0], arena));
}
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head><title>Strip</title><script>var a = 1;</script></head>
<body>
<header><nav><a href="/">Site menu</a></nav></header>
<main><article><header>Byline</header><p>Article body</p></article></main>
<aside>Related</aside>
<footer>Legal</footer>
</body>
</html>
+13 -5
View File
@@ -401,7 +401,7 @@ pub const Tool = enum {
\\ "selector": { "type": "string", "description": "Optional CSS selector. When set, dump only that element's outerHTML." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, dump only that node's outerHTML. 0 is treated as omitted." },
\\ "maxBytes": { "type": "integer", "description": "Optional soft cap on output size in bytes. Content is truncated at a UTF-8 boundary and a short '[truncated]' marker is appended past the cap." },
\\ "strip": { "type": "object", "description": "Optional. Omit element groups from the output: `js` (script, noscript, script preloads), `css` (style, stylesheet links), `ui` (css plus img, picture, video, audio, svg, canvas, iframe), `invisible` (elements an author rule or inline style sets to display:none). {\"js\":true,\"css\":true} keeps a page dump small.", "properties": { "js": { "type": "boolean" }, "css": { "type": "boolean" }, "ui": { "type": "boolean" }, "invisible": { "type": "boolean" } } },
\\ "strip": { "type": "object", "description": "Optional. Omit element groups from the output: `js` (script, noscript, script preloads), `css` (style, stylesheet links), `ui` (css plus img, picture, video, audio, svg, canvas, iframe), `invisible` (elements an author rule or inline style sets to display:none), `shell` (nav, aside, dialog, page-level header/footer and the matching landmark roles; skipped when that would drop most of the text). {\"js\":true,\"css\":true} keeps a page dump small.", "properties": { "js": { "type": "boolean" }, "css": { "type": "boolean" }, "ui": { "type": "boolean" }, "invisible": { "type": "boolean" }, "shell": { "type": "boolean" } } },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before dumping." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
\\ }
@@ -419,6 +419,7 @@ pub const Tool = enum {
\\ "selector": { "type": "string", "description": "Optional CSS selector. When set, render only that element." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, render only that node. 0 is treated as omitted." },
\\ "fullPage": { "type": "boolean", "description": "Render the whole content height instead of one viewport. Defaults to false." },
\\ "strip": { "type": "object", "description": "Optional. Omit element groups from the render; same groups as the html tool's strip (`js`, `css`, `ui`, `invisible`, `shell`).", "properties": { "js": { "type": "boolean" }, "css": { "type": "boolean" }, "ui": { "type": "boolean" }, "invisible": { "type": "boolean" }, "shell": { "type": "boolean" } } },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before rendering." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
\\ }
@@ -1338,12 +1339,16 @@ fn execHtml(arena: std.mem.Allocator, session: *lp.Session, registry: *NodeRegis
const args = try parseArgsOrDefault(HtmlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout);
const opts: lp.dump.Opts = .{ .strip = args.strip, .max_bytes = args.maxBytes };
const whole = args.selector == null and args.backendNodeId == null;
const node = if (whole) page.document.asNode() else (try resolveTarget(session, registry, args.selector, args.backendNodeId)).node;
const opts: lp.dump.Opts = .{
.strip = lp.RenderTree.resolveStrip(node, args.strip, page),
.max_bytes = args.maxBytes,
};
var aw: std.Io.Writer.Allocating = .init(arena);
if (args.selector == null and args.backendNodeId == null) {
if (whole) {
lp.dump.root(page.document, opts, &aw.writer, page) catch return ToolError.InternalError;
} else {
const node = (try resolveTarget(session, registry, args.selector, args.backendNodeId)).node;
lp.dump.deep(node, opts, &aw.writer, page) catch return ToolError.InternalError;
}
return aw.written();
@@ -1355,6 +1360,7 @@ fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *Nod
selector: ?[]const u8 = null,
backendNodeId: ?NodeRegistry.Id = null,
fullPage: bool = false,
strip: lp.dump.Opts.Strip = .{},
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
};
@@ -1366,7 +1372,9 @@ fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *Nod
}
const page = try ensurePage(session, registry, args.url, args.timeout);
const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId);
var prepared = lp.screenshot.preparePng(arena, node, .fromViewport(page._page.getViewport(), args.fullPage), page) catch
var opts: lp.screenshot.Opts = .fromViewport(page._page.getViewport(), args.fullPage);
opts.strip = lp.RenderTree.resolveStrip(node, args.strip, page);
var prepared = lp.screenshot.preparePng(arena, node, opts, page) catch
return ToolError.InternalError;
if (args.path) |path| {
File diff suppressed because one or more lines are too long.
+5 -6
View File
@@ -118,15 +118,14 @@
\\ Defaults to false.
\\ --strip-mode <STRIP>
\\ Tag group to remove from dump. Can be passed multiple times.
\\ In markdown only 'ui' changes the output: scripts, styles and
\\ hidden elements are never rendered.
\\ Defaults to no-strip.
\\ Defaults to stripping nothing.
\\ Allowed values:
\\ js Script and link[as=script, rel=preload].
\\ ui Includes img, picture, video, CSS and SVG.
\\ css Includes style and link[rel=stylesheet].
\\ js Script and link[as=script, rel=preload].
\\ invisible Best-effort (e.g. display:none) hidden elements
\\ full Strip everything.
\\ shell Heuristic to attempt to remove headers, footer,
\\ and other non-content decoration.
\\ ui Includes img, picture, video, CSS and SVG.
\\ --terminate-ms <INT>
\\ Hard deadline in milliseconds. After this time elapses, JavaScript
\\ execution is forcibly terminated (e.g. for pages with endless scripts).
+16 -8
View File
@@ -68,7 +68,6 @@ pub const crash_handler = @import("crash_handler.zig");
pub const core_dump = @import("core_dump.zig");
pub var metrics = @import("Metrics.zig"){};
pub const IS_TEST = @import("builtin").is_test;
pub const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -458,13 +457,20 @@ const Binary = union(enum) {
fn prepareBinary(arena: std.mem.Allocator, frame: *Frame, opts: FetchOpts) !Binary {
const root = try dumpRoot(frame, opts.selector);
const strip = RenderTree.resolveStrip(root, opts.dump.strip, frame);
return switch (opts.dump_mode.?) {
.png => .{ .png = try screenshot.preparePng(arena, root, .fromViewport(frame._page.getViewport(), true), frame) },
.pdf => .{ .pdf = try pdf.prepare(arena, root, .{}, frame) },
.png => .{ .png = try screenshot.preparePng(arena, root, pngOpts(frame, strip), frame) },
.pdf => .{ .pdf = try pdf.prepare(arena, root, .{ .strip = strip }, frame) },
else => unreachable,
};
}
fn pngOpts(frame: *Frame, strip: dump.Opts.Strip) screenshot.Opts {
var opts: screenshot.Opts = .fromViewport(frame._page.getViewport(), true);
opts.strip = strip;
return opts;
}
fn dumpRoot(frame: *Frame, selector: ?[]const u8) !*Node {
const document = frame.window._document.asNode();
const sel = selector orelse return document;
@@ -474,21 +480,23 @@ fn dumpRoot(frame: *Frame, selector: ?[]const u8) !*Node {
fn dumpContent(app: *App, mode: Config.DumpFormat, opts: FetchOpts, frame: *Frame, writer: *std.Io.Writer) !void {
const root = try dumpRoot(frame, opts.selector);
var dump_opts = opts.dump;
dump_opts.strip = RenderTree.resolveStrip(root, dump_opts.strip, frame);
switch (mode) {
.html => if (opts.selector == null)
try dump.root(frame.window._document, opts.dump, writer, frame)
try dump.root(frame.window._document, dump_opts, writer, frame)
else
try dump.deep(root, opts.dump, writer, frame),
.markdown => try markdown.dump(root, .{ .max_bytes = opts.dump.max_bytes, .strip = opts.dump.strip }, writer, frame),
try dump.deep(root, dump_opts, writer, frame),
.markdown => try markdown.dump(root, .{ .max_bytes = dump_opts.max_bytes, .strip = dump_opts.strip }, writer, frame),
.png => {
var arena: std.heap.ArenaAllocator = .init(app.allocator);
defer arena.deinit();
_ = try screenshot.png(arena.allocator(), root, .fromViewport(frame._page.getViewport(), true), writer, frame);
_ = try screenshot.png(arena.allocator(), root, pngOpts(frame, dump_opts.strip), writer, frame);
},
.pdf => {
var arena: std.heap.ArenaAllocator = .init(app.allocator);
defer arena.deinit();
try pdf.print(arena.allocator(), root, .{}, writer, frame);
try pdf.print(arena.allocator(), root, .{ .strip = dump_opts.strip }, writer, frame);
},
.semantic_tree, .semantic_tree_text => {
var registry = NodeRegistry.init(app.allocator);
+127
View File
@@ -23,6 +23,7 @@ const CDP = @import("../CDP.zig");
const Robots = @import("../../../network/Robots.zig");
const DOMNode = @import("../../../browser/webapi/Node.zig");
const Selector = @import("../../../browser/webapi/selector/Selector.zig");
const NodeRegistry = @import("../../../NodeRegistry.zig");
const markdown = lp.markdown;
@@ -32,6 +33,7 @@ const structured_data = lp.structured_data;
pub fn processMessage(cmd: *CDP.Command) !void {
const action = std.meta.stringToEnum(enum {
dump,
getMarkdown,
getSemanticTree,
getInteractiveElements,
@@ -50,6 +52,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.dump => return dump(cmd),
.getMarkdown => return getMarkdown(cmd),
.getSemanticTree => return getSemanticTree(cmd),
.getInteractiveElements => return getInteractiveElements(cmd),
@@ -158,6 +161,66 @@ fn getSemanticTree(cmd: anytype) !void {
}, .{});
}
fn dump(cmd: anytype) !void {
const Params = struct {
format: enum { html, markdown, png, pdf },
strip: lp.dump.Opts.Strip = .{},
selector: ?[]const u8 = null,
backendNodeId: ?NodeRegistry.Id = null,
maxBytes: ?u32 = null,
};
const params = (try cmd.params(Params)) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
const root = blk: {
if (params.backendNodeId) |id| {
break :blk (bc.node_registry.lookup_by_id.get(id) orelse return error.InvalidNodeId).dom;
}
if (params.selector) |selector| {
const el = Selector.querySelector(frame.document.asNode(), selector, frame) catch return error.InvalidParams;
break :blk (el orelse return error.InvalidParams).asNode();
}
break :blk frame.document.asNode();
};
const strip = lp.RenderTree.resolveStrip(root, params.strip, frame);
switch (params.format) {
.html, .markdown => {
var aw: std.Io.Writer.Allocating = .init(cmd.arena);
defer aw.deinit();
const opts: lp.dump.Opts = .{ .strip = strip, .max_bytes = params.maxBytes };
if (params.format == .markdown) {
try markdown.dump(root, .{ .strip = strip, .max_bytes = params.maxBytes }, &aw.writer, frame);
} else if (root.is(DOMNode.Document)) |doc| {
try lp.dump.root(doc, opts, &aw.writer, frame);
} else {
try lp.dump.deep(root, opts, &aw.writer, frame);
}
return cmd.sendResult(.{ .format = params.format, .content = aw.written() }, .{});
},
.png => {
if (params.maxBytes != null) {
return error.InvalidParams;
}
var opts: lp.screenshot.Opts = .fromViewport(cmd.cdp.browser.getViewport(), true);
opts.strip = strip;
const prepared = try lp.screenshot.preparePng(cmd.arena, root, opts, frame);
return cmd.sendResult(.{ .format = params.format, .content = prepared }, .{});
},
.pdf => {
if (params.maxBytes != null) {
return error.InvalidParams;
}
const prepared = try lp.pdf.prepare(cmd.arena, root, .{ .strip = strip }, frame);
return cmd.sendResult(.{ .format = params.format, .content = prepared }, .{});
},
}
}
// Deprecated: LP.dump with format "markdown". Kept for existing callers.
fn getMarkdown(cmd: anytype) !void {
const Params = struct {
nodeId: ?NodeRegistry.Id = null,
@@ -508,6 +571,53 @@ test "cdp.lp: getMarkdown" {
try testing.expect(result.get("markdown") != null);
}
test "cdp.lp: dump formats, strip and scoping" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-S", .url = "cdp/strip.html", .target_id = "FID-000000000S".* });
// markdown, no strip: chrome and content both present.
try ctx.processMessage(.{ .id = 1, .method = "LP.dump", .params = .{ .format = "markdown" } });
var content = try dumpContent(&ctx, 1, "markdown");
try testing.expect(std.mem.indexOf(u8, content, "Site menu") != null);
try testing.expect(std.mem.indexOf(u8, content, "Article body") != null);
// markdown + shell: chrome gone, the article's own header kept.
try ctx.processMessage(.{ .id = 2, .method = "LP.dump", .params = .{ .format = "markdown", .strip = .{ .shell = true } } });
content = try dumpContent(&ctx, 2, "markdown");
try testing.expectEqual("Byline\n\nArticle body\n", content);
// html + shell, scoped by selector.
try ctx.processMessage(.{ .id = 3, .method = "LP.dump", .params = .{ .format = "html", .selector = "main", .strip = .{ .shell = true, .js = true } } });
content = try dumpContent(&ctx, 3, "html");
try testing.expectEqual("<main><article><header>Byline</header><p>Article body</p></article></main>", content);
// html, whole document, capped.
try ctx.processMessage(.{ .id = 4, .method = "LP.dump", .params = .{ .format = "html", .maxBytes = 20 } });
content = try dumpContent(&ctx, 4, "html");
try testing.expect(std.mem.startsWith(u8, content, "<!DOCTYPE html>"));
try testing.expect(content.len < 100);
// png and pdf come back base64; maxBytes is a text-only option.
try ctx.processMessage(.{ .id = 5, .method = "LP.dump", .params = .{ .format = "png", .strip = .{ .shell = true } } });
content = try dumpContent(&ctx, 5, "png");
try testing.expect(std.mem.startsWith(u8, content, "iVBOR"));
try ctx.processMessage(.{ .id = 6, .method = "LP.dump", .params = .{ .format = "pdf" } });
content = try dumpContent(&ctx, 6, "pdf");
try testing.expect(std.mem.startsWith(u8, content, "JVBER"));
try ctx.processMessage(.{ .id = 7, .method = "LP.dump", .params = .{ .format = "png", .maxBytes = 10 } });
try testing.expect((try dumpReply(&ctx, 7)).get("error") != null);
// Unknown selector and missing format are errors.
try ctx.processMessage(.{ .id = 8, .method = "LP.dump", .params = .{ .format = "markdown", .selector = "#nope" } });
try testing.expect((try dumpReply(&ctx, 8)).get("error") != null);
try ctx.processMessage(.{ .id = 9, .method = "LP.dump", .params = .{ .strip = .{ .shell = true } } });
try testing.expect((try dumpReply(&ctx, 9)).get("error") != null);
}
test "cdp.lp: getInteractiveElements" {
var ctx = try testing.context();
defer ctx.deinit();
@@ -874,3 +984,20 @@ test "cdp.lp: configureLoading toggles externalStylesheets independently" {
try testing.expectEqual(true, bc.session.load_resources.iframe);
try testing.expectEqual(true, bc.session.load_resources.worker);
}
fn dumpReply(ctx: *testing.TestContext, id: i64) !std.json.ObjectMap {
var i: usize = 0;
while (try ctx.getSentMessage(i)) |m| : (i += 1) {
const msg_id = m.object.get("id") orelse continue;
if (msg_id.integer == id) {
return m.object;
}
}
return error.MissingReply;
}
fn dumpContent(ctx: *testing.TestContext, id: i64, format: []const u8) ![]const u8 {
const result = (try dumpReply(ctx, id)).get("result").?.object;
try testing.expectEqual(format, result.get("format").?.string);
return result.get("content").?.string;
}