diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index 75226ad1..8441ce50 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -36,6 +36,7 @@ pub const InteractivityType = enum { }; pub const InteractiveElement = struct { + backendNodeId: ?u32 = null, node: *Node, tag_name: []const u8, role: ?[]const u8, @@ -55,6 +56,11 @@ pub const InteractiveElement = struct { pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void { try jw.beginObject(); + if (self.backendNodeId) |id| { + try jw.objectField("backendNodeId"); + try jw.write(id); + } + try jw.objectField("tagName"); try jw.write(self.tag_name); @@ -123,6 +129,15 @@ pub const InteractiveElement = struct { } }; +/// Populate backendNodeId on each interactive element by registering +/// their nodes in the given registry. Works with both CDP and MCP registries. +pub fn registerNodes(elements: []InteractiveElement, registry: anytype) !void { + for (elements) |*el| { + const registered = try registry.register(el.node); + el.backendNodeId = registered.id; + } +} + /// Collect all interactive elements under `root`. pub fn collectInteractiveElements( root: *Node, diff --git a/src/browser/links.zig b/src/browser/links.zig new file mode 100644 index 00000000..1abe695c --- /dev/null +++ b/src/browser/links.zig @@ -0,0 +1,54 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const Element = @import("webapi/Element.zig"); +const Node = @import("webapi/Node.zig"); +const Page = @import("Page.zig"); +const Selector = @import("webapi/selector/Selector.zig"); + +const Allocator = std.mem.Allocator; + +/// Collect all links (href attributes from anchor tags) under `root`. +/// Returns a slice of strings allocated with `arena`. +pub fn collectLinks(arena: Allocator, root: *Node, page: *Page) ![]const []const u8 { + var links: std.ArrayList([]const u8) = .empty; + + if (Selector.querySelectorAll(root, "a[href]", page)) |list| { + defer list.deinit(page._session); + + for (list._nodes) |node| { + if (node.is(Element.Html.Anchor)) |anchor| { + const href = anchor.getHref(page) catch |err| { + @import("../lightpanda.zig").log.err(.app, "resolve href failed", .{ .err = err }); + continue; + }; + + if (href.len > 0) { + try links.append(arena, href); + } + } + } + } else |err| { + @import("../lightpanda.zig").log.err(.app, "query links failed", .{ .err = err }); + return err; + } + + return links.items; +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 077ec989..b11de91f 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -135,17 +135,10 @@ fn getInteractiveElements(cmd: anytype) !void { page.document.asNode(); const elements = try interactive.collectInteractiveElements(root, cmd.arena, page); - - // Register nodes so nodeIds are valid for subsequent CDP calls. - var node_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, elements.len); - for (elements) |el| { - const registered = try bc.node_registry.register(el.node); - node_ids.appendAssumeCapacity(registered.id); - } + try interactive.registerNodes(elements, &bc.node_registry); return cmd.sendResult(.{ .elements = elements, - .nodeIds = node_ids.items, }, .{}); } @@ -308,7 +301,6 @@ test "cdp.lp: getInteractiveElements" { const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object; try testing.expect(result.get("elements") != null); - try testing.expect(result.get("nodeIds") != null); } test "cdp.lp: getStructuredData" { diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 8600f6d4..de9c0835 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig"); pub const SemanticTree = @import("SemanticTree.zig"); pub const CDPNode = @import("cdp/Node.zig"); pub const interactive = @import("browser/interactive.zig"); +pub const links = @import("browser/links.zig"); pub const forms = @import("browser/forms.zig"); pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index f6285de0..f4a82570 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -207,23 +207,12 @@ const ToolStreamingText = struct { log.err(.mcp, "markdown dump failed", .{ .err = err }); }, .links => { - if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| { - defer list.deinit(self.page._session); - + if (lp.links.collectLinks(self.page.call_arena, self.page.document.asNode(), self.page)) |links| { var first = true; - for (list._nodes) |node| { - if (node.is(Element.Html.Anchor)) |anchor| { - const href = anchor.getHref(self.page) catch |err| { - log.err(.mcp, "resolve href failed", .{ .err = err }); - continue; - }; - - if (href.len > 0) { - if (!first) try w.writeByte('\n'); - try w.writeAll(href); - first = false; - } - } + for (links) |href| { + if (!first) try w.writeByte('\n'); + try w.writeAll(href); + first = false; } } else |err| { log.err(.mcp, "query links failed", .{ .err = err }); @@ -385,6 +374,12 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std. log.err(.mcp, "elements collection failed", .{ .err = err }); return server.sendError(id, .InternalError, "Failed to collect interactive elements"); }; + + lp.interactive.registerNodes(elements, &server.node_registry) catch |err| { + log.err(.mcp, "node registration failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to register element nodes"); + }; + var aw: std.Io.Writer.Allocating = .init(arena); try std.json.Stringify.value(elements, .{}, &aw.writer);