Files
browser/src/browser/tools.zig
2026-04-08 15:10:23 +02:00

611 lines
24 KiB
Zig

const std = @import("std");
const lp = @import("lightpanda");
const DOMNode = @import("webapi/Node.zig");
const CDPNode = @import("../cdp/Node.zig");
pub const ToolError = error{
PageNotLoaded,
InvalidParams,
NodeNotFound,
NavigationFailed,
InternalError,
};
/// Result from evaluate that may represent a JS error (not a tool failure).
pub const EvalResult = struct {
text: []const u8,
is_error: bool = false,
};
pub const GotoParams = struct {
url: [:0]const u8,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
pub const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const NodeAndPage = struct { node: *DOMNode, page: *lp.Page };
// --- Tool dispatch ---
const Action = enum {
goto,
navigate,
markdown,
links,
nodeDetails,
interactiveElements,
structuredData,
detectForms,
evaluate,
eval,
semantic_tree,
click,
fill,
scroll,
waitForSelector,
hover,
press,
selectOption,
setChecked,
findElement,
getEnv,
consoleLogs,
getUrl,
getCookies,
};
const action_map = std.StaticStringMap(Action).initComptime(.{
.{ "goto", .goto },
.{ "navigate", .navigate },
.{ "markdown", .markdown },
.{ "links", .links },
.{ "nodeDetails", .nodeDetails },
.{ "interactiveElements", .interactiveElements },
.{ "structuredData", .structuredData },
.{ "detectForms", .detectForms },
.{ "evaluate", .evaluate },
.{ "eval", .eval },
.{ "semantic_tree", .semantic_tree },
.{ "click", .click },
.{ "fill", .fill },
.{ "scroll", .scroll },
.{ "waitForSelector", .waitForSelector },
.{ "hover", .hover },
.{ "press", .press },
.{ "selectOption", .selectOption },
.{ "setChecked", .setChecked },
.{ "findElement", .findElement },
.{ "getEnv", .getEnv },
.{ "consoleLogs", .consoleLogs },
.{ "getUrl", .getUrl },
.{ "getCookies", .getCookies },
});
/// Execute a tool by name. Returns the result text.
/// For `evaluate`/`eval`, use `callEval` to distinguish JS errors from tool errors.
pub fn call(
session: *lp.Session,
registry: *CDPNode.Registry,
arena: std.mem.Allocator,
tool_name: []const u8,
arguments: ?std.json.Value,
) ToolError![]const u8 {
const action = action_map.get(tool_name) orelse return ToolError.InvalidParams;
return switch (action) {
.goto, .navigate => execGoto(session, registry, arena, arguments),
.markdown => execMarkdown(session, registry, arena, arguments),
.links => execLinks(session, registry, arena, arguments),
.nodeDetails => execNodeDetails(session, registry, arena, arguments),
.interactiveElements => execInteractiveElements(session, registry, arena, arguments),
.structuredData => execStructuredData(session, registry, arena, arguments),
.detectForms => execDetectForms(session, registry, arena, arguments),
.evaluate, .eval => blk: {
const result = execEvaluate(session, registry, arena, arguments);
break :blk result.text;
},
.semantic_tree => execSemanticTree(session, registry, arena, arguments),
.click => execClick(session, registry, arena, arguments),
.fill => execFill(session, registry, arena, arguments),
.scroll => execScroll(session, registry, arena, arguments),
.waitForSelector => execWaitForSelector(session, registry, arena, arguments),
.hover => execHover(session, registry, arena, arguments),
.press => execPress(session, registry, arena, arguments),
.selectOption => execSelectOption(session, registry, arena, arguments),
.setChecked => execSetChecked(session, registry, arena, arguments),
.findElement => execFindElement(session, registry, arena, arguments),
.getEnv => execGetEnv(arena, arguments),
.consoleLogs => execConsoleLogs(session, arena),
.getUrl => execGetUrl(session),
.getCookies => execGetCookies(session, arena),
};
}
/// Like `call`, but for evaluate/eval returns the full EvalResult with is_error flag.
pub fn callEval(
session: *lp.Session,
registry: *CDPNode.Registry,
arena: std.mem.Allocator,
arguments: ?std.json.Value,
) EvalResult {
return execEvaluate(session, registry, arena, arguments);
}
/// Check if a tool name is recognized.
pub fn isKnownTool(tool_name: []const u8) bool {
return action_map.get(tool_name) != null;
}
// --- Tool implementations ---
fn execGoto(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrErr(GotoParams, arena, arguments) orelse return ToolError.InvalidParams;
try performGoto(session, registry, args.url, args.timeout, args.waitUntil);
return "Navigated successfully.";
}
fn execMarkdown(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
var aw: std.Io.Writer.Allocating = .init(arena);
lp.markdown.dump(page.document.asNode(), .{}, &aw.writer, page) catch
return ToolError.InternalError;
return aw.written();
}
fn execLinks(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const links_list = lp.links.collectLinks(arena, page.document.asNode(), page) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
for (links_list, 0..) |href, i| {
if (i > 0) aw.writer.writeByte('\n') catch {};
aw.writer.writeAll(href) catch {};
}
return aw.written();
}
fn execSemanticTree(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const TreeParams = struct {
url: ?[:0]const u8 = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(TreeParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
var root_node = page.document.asNode();
if (args.backendNodeId) |node_id| {
if (registry.lookup_by_id.get(node_id)) |n| {
root_node = n.dom;
}
}
const st = lp.SemanticTree{
.dom_node = root_node,
.registry = registry,
.page = page,
.arena = arena,
.prune = true,
.max_depth = args.maxDepth orelse std.math.maxInt(u32) - 1,
};
var aw: std.Io.Writer.Allocating = .init(arena);
st.textStringify(&aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execNodeDetails(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
_ = session.currentPage() orelse return ToolError.PageNotLoaded;
const node = registry.lookup_by_id.get(args.backendNodeId) orelse
return ToolError.NodeNotFound;
const page = session.currentPage().?;
const details = lp.SemanticTree.getNodeDetails(arena, node.dom, registry, page) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(&details, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execInteractiveElements(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch
return ToolError.InternalError;
lp.interactive.registerNodes(elements, registry) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(elements, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execStructuredData(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(data, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execDetectForms(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch
return ToolError.InternalError;
lp.forms.registerNodes(forms_data, registry) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(forms_data, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execEvaluate(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) EvalResult {
const Params = struct {
script: [:0]const u8,
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return .{ .text = "Error: missing 'script' argument", .is_error = true };
const page = ensurePage(session, registry, args.url, args.timeout, args.waitUntil) catch return .{ .text = "Error: page not loaded", .is_error = true };
var ls: lp.js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const js_result = ls.local.compileAndRun(args.script, null) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
var aw: std.Io.Writer.Allocating = .init(arena);
caught.format(&aw.writer) catch {};
return .{ .text = aw.written(), .is_error = true };
};
return .{ .text = js_result.toStringSliceWithAlloc(arena) catch "undefined" };
}
fn execClick(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.click(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execFill(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
text: []const u8,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.fill(resolved.node, args.text, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId,
args.text,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execScroll(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: ?CDPNode.Id = null,
x: ?i32 = null,
y: ?i32 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = registry.lookup_by_id.get(node_id) orelse return ToolError.NodeNotFound;
target_node = node.dom;
}
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Scrolled to x: {d}, y: {d}. Page url: {s}, title: {s}", .{
args.x orelse 0,
args.y orelse 0,
page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execWaitForSelector(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
selector: [:0]const u8,
timeout: ?u32 = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
_ = session.currentPage() orelse return ToolError.PageNotLoaded;
const timeout_ms = args.timeout orelse 5000;
const node = lp.actions.waitForSelector(args.selector, timeout_ms, session) catch |err| {
if (err == error.InvalidSelector) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const registered = registry.register(node) catch return ToolError.InternalError;
return std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch return ToolError.InternalError;
}
fn execHover(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.hover(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execPress(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
key: []const u8,
backendNodeId: ?CDPNode.Id = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = registry.lookup_by_id.get(node_id) orelse return ToolError.NodeNotFound;
target_node = node.dom;
}
lp.actions.press(target_node, args.key, page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{
args.key,
page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execSelectOption(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
value: []const u8,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Selected option '{s}' (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.value,
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execSetChecked(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
checked: bool,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.setChecked(resolved.node, args.checked, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const state_str = if (args.checked) "checked" else "unchecked";
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Set element (backendNodeId: {d}) to {s}. Page url: {s}, title: {s}", .{
args.backendNodeId,
state_str,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execFindElement(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
role: ?[]const u8 = null,
name: ?[]const u8 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
if (args.role == null and args.name == null) return ToolError.InvalidParams;
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch
return ToolError.InternalError;
var matches: std.ArrayListUnmanaged(lp.interactive.InteractiveElement) = .empty;
for (elements) |el| {
if (args.role) |role| {
const el_role = el.role orelse continue;
if (!std.ascii.eqlIgnoreCase(el_role, role)) continue;
}
if (args.name) |name| {
const el_name = el.name orelse continue;
if (!containsIgnoreCase(el_name, name)) continue;
}
matches.append(arena, el) catch return ToolError.InternalError;
}
const matched = matches.toOwnedSlice(arena) catch return ToolError.InternalError;
lp.interactive.registerNodes(matched, registry) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(matched, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execGetEnv(arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { name: []const u8 };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const name_z = arena.dupeZ(u8, args.name) catch return ToolError.InternalError;
const value = std.posix.getenv(name_z) orelse
return std.fmt.allocPrint(arena, "Environment variable '{s}' is not set", .{args.name}) catch ToolError.InternalError;
return value;
}
fn execConsoleLogs(
session: *lp.Session,
arena: std.mem.Allocator,
) ToolError![]const u8 {
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
const messages = page.console_messages.items;
if (messages.len == 0) return "No console messages.";
var aw: std.Io.Writer.Allocating = .init(arena);
const writer = &aw.writer;
for (messages) |msg| {
writer.print("[{s}] {s}\n", .{ @tagName(msg.level), msg.text }) catch return ToolError.InternalError;
}
page.console_messages.clearRetainingCapacity();
return aw.written();
}
fn execGetUrl(session: *lp.Session) ToolError![]const u8 {
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
return page.url;
}
fn execGetCookies(session: *lp.Session, arena: std.mem.Allocator) ToolError![]const u8 {
const cookies = session.cookie_jar.cookies.items;
if (cookies.len == 0) return "No cookies.";
var aw: std.Io.Writer.Allocating = .init(arena);
const writer = &aw.writer;
for (cookies) |*cookie| {
writer.print("{s}={s}", .{ cookie.name, cookie.value }) catch return ToolError.InternalError;
writer.print("; domain={s}; path={s}", .{ cookie.domain, cookie.path }) catch return ToolError.InternalError;
if (cookie.secure) writer.writeAll("; Secure") catch return ToolError.InternalError;
if (cookie.http_only) writer.writeAll("; HttpOnly") catch return ToolError.InternalError;
writer.writeAll("\n") catch return ToolError.InternalError;
}
return aw.written();
}
// --- Shared helpers ---
fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!*lp.Page {
if (url) |u| {
try performGoto(session, registry, u, timeout, waitUntil);
}
return session.currentPage() orelse ToolError.PageNotLoaded;
}
fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!void {
if (session.page != null) {
registry.reset();
session.removePage();
}
const page = session.createPage() catch return ToolError.NavigationFailed;
_ = page.navigate(url, .{
.reason = .address_bar,
.kind = .{ .push = null },
}) catch return ToolError.NavigationFailed;
var runner = session.runner(.{}) catch return ToolError.NavigationFailed;
runner.wait(.{
.ms = timeout orelse 10000,
.until = waitUntil orelse .done,
}) catch return ToolError.NavigationFailed;
}
fn resolveNodeAndPage(session: *lp.Session, registry: *CDPNode.Registry, node_id: CDPNode.Id) ToolError!NodeAndPage {
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
const node = registry.lookup_by_id.get(node_id) orelse return ToolError.NodeNotFound;
return .{ .node = node.dom, .page = page };
}
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch .{};
}
fn parseArgsOrErr(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ?T {
const args_raw = arguments orelse return null;
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch null;
}
pub fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
if (needle.len == 0) return true;
const end = haystack.len - needle.len + 1;
for (0..end) |i| {
if (std.ascii.eqlIgnoreCase(haystack[i..][0..needle.len], needle)) return true;
}
return false;
}