mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-14 15:03:27 -04:00
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:
11 files changed
+447
-27
No files matched your search
@@ -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
@@ -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 & 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());
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user