Compare commits

..

1 Commits

Author SHA1 Message Date
Adrià Arrufat
9c8fe9b20f SemanticTree: Add nodeDetails tool
Adds a tool to retrieve detailed node metadata and updates the
semantic tree to track and display the disabled state of elements.
2026-03-30 16:38:23 +02:00
11 changed files with 281 additions and 136 deletions

View File

@@ -100,14 +100,14 @@ jobs:
./proxy/proxy & echo $! > PROXY.id ./proxy/proxy & echo $! > PROXY.id
./lightpanda serve --http-proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid ./lightpanda serve --http-proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
go run runner/main.go go run runner/main.go
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
kill `cat LPD.pid` `cat PROXY.id` kill `cat LPD.pid` `cat PROXY.id`
- name: run request interception through proxy and playwright - name: run request interception through proxy
run: | run: |
export PROXY_USERNAME=username PROXY_PASSWORD=password export PROXY_USERNAME=username PROXY_PASSWORD=password
./proxy/proxy & echo $! > PROXY.id ./proxy/proxy & echo $! > PROXY.id
./lightpanda serve & echo $! > LPD.pid ./lightpanda serve & echo $! > LPD.pid
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
kill `cat LPD.pid` `cat PROXY.id` kill `cat LPD.pid` `cat PROXY.id`
@@ -161,18 +161,14 @@ jobs:
--http-proxy 'http://127.0.0.1:3000' \ --http-proxy 'http://127.0.0.1:3000' \
& echo $! > LPD.pid & echo $! > LPD.pid
go run runner/main.go go run runner/main.go
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
kill `cat LPD.pid` `cat PROXY.id` kill `cat LPD.pid` `cat PROXY.id`
- name: run request interception through proxy and playwright - name: run request interception through proxy
run: | run: |
export PROXY_USERNAME=username PROXY_PASSWORD=password export PROXY_USERNAME=username PROXY_PASSWORD=password
./proxy/proxy & echo $! > PROXY.id ./proxy/proxy & echo $! > PROXY.id
./lightpanda serve \ ./lightpanda serve & echo $! > LPD.pid
--web-bot-auth-key-file private_key.pem \ URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
--web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
--web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
& echo $! > LPD.pid
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
kill `cat LPD.pid` `cat PROXY.id` kill `cat LPD.pid` `cat PROXY.id`

View File

@@ -74,8 +74,6 @@ const EventListeners = struct {
page_network_idle: List = .{}, page_network_idle: List = .{},
page_network_almost_idle: List = .{}, page_network_almost_idle: List = .{},
page_frame_created: List = .{}, page_frame_created: List = .{},
page_dom_content_loaded: List = .{},
page_loaded: List = .{},
http_request_fail: List = .{}, http_request_fail: List = .{},
http_request_start: List = .{}, http_request_start: List = .{},
http_request_intercept: List = .{}, http_request_intercept: List = .{},
@@ -93,8 +91,6 @@ const Events = union(enum) {
page_network_idle: *const PageNetworkIdle, page_network_idle: *const PageNetworkIdle,
page_network_almost_idle: *const PageNetworkAlmostIdle, page_network_almost_idle: *const PageNetworkAlmostIdle,
page_frame_created: *const PageFrameCreated, page_frame_created: *const PageFrameCreated,
page_dom_content_loaded: *const PageDOMContentLoaded,
page_loaded: *const PageLoaded,
http_request_fail: *const RequestFail, http_request_fail: *const RequestFail,
http_request_start: *const RequestStart, http_request_start: *const RequestStart,
http_request_intercept: *const RequestIntercept, http_request_intercept: *const RequestIntercept,
@@ -141,18 +137,6 @@ pub const PageFrameCreated = struct {
timestamp: u64, timestamp: u64,
}; };
pub const PageDOMContentLoaded = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const PageLoaded = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const RequestStart = struct { pub const RequestStart = struct {
transfer: *Transfer, transfer: *Transfer,
}; };

View File

@@ -97,6 +97,7 @@ const NodeData = struct {
options: ?[]OptionData = null, options: ?[]OptionData = null,
xpath: []const u8, xpath: []const u8,
is_interactive: bool, is_interactive: bool,
is_disabled: bool,
node_name: []const u8, node_name: []const u8,
}; };
@@ -148,6 +149,7 @@ fn walk(
const role = try axn.getRole(); const role = try axn.getRole();
var is_interactive = false; var is_interactive = false;
var is_disabled = false;
var value: ?[]const u8 = null; var value: ?[]const u8 = null;
var options: ?[]OptionData = null; var options: ?[]OptionData = null;
var node_name: []const u8 = "text"; var node_name: []const u8 = "text";
@@ -172,6 +174,8 @@ fn walk(
is_interactive = true; is_interactive = true;
} }
} }
is_disabled = el.isDisabled();
} else if (node._type == .document or node._type == .document_fragment) { } else if (node._type == .document or node._type == .document_fragment) {
node_name = "root"; node_name = "root";
} }
@@ -236,6 +240,7 @@ fn walk(
.options = options, .options = options,
.xpath = xpath, .xpath = xpath,
.is_interactive = is_interactive, .is_interactive = is_interactive,
.is_disabled = is_disabled,
.node_name = node_name, .node_name = node_name,
}; };
@@ -347,6 +352,11 @@ const JsonVisitor = struct {
try self.jw.objectField("isInteractive"); try self.jw.objectField("isInteractive");
try self.jw.write(data.is_interactive); try self.jw.write(data.is_interactive);
if (data.is_disabled) {
try self.jw.objectField("isDisabled");
try self.jw.write(true);
}
try self.jw.objectField("role"); try self.jw.objectField("role");
try self.jw.write(data.role); try self.jw.write(data.role);
@@ -459,6 +469,9 @@ const TextVisitor = struct {
const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic"); const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic");
try self.writer.print("{d}", .{data.id}); try self.writer.print("{d}", .{data.id});
if (data.is_interactive) {
try self.writer.writeAll(if (data.is_disabled) " [i:disabled]" else " [i]");
}
if (!is_text_only) { if (!is_text_only) {
try self.writer.print(" {s}", .{data.role}); try self.writer.print(" {s}", .{data.role});
} }
@@ -509,6 +522,177 @@ const TextVisitor = struct {
} }
}; };
pub const NodeDetails = struct {
backendNodeId: CDPNode.Id,
tag_name: []const u8,
role: []const u8,
name: ?[]const u8,
is_interactive: bool,
is_disabled: bool,
value: ?[]const u8 = null,
input_type: ?[]const u8 = null,
placeholder: ?[]const u8 = null,
href: ?[]const u8 = null,
id: ?[]const u8 = null,
class: ?[]const u8 = null,
checked: ?bool = null,
options: ?[]OptionData = null,
pub fn jsonStringify(self: *const NodeDetails, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("backendNodeId");
try jw.write(self.backendNodeId);
try jw.objectField("tagName");
try jw.write(self.tag_name);
try jw.objectField("role");
try jw.write(self.role);
if (self.name) |n| {
try jw.objectField("name");
try jw.write(n);
}
try jw.objectField("isInteractive");
try jw.write(self.is_interactive);
if (self.is_disabled) {
try jw.objectField("isDisabled");
try jw.write(true);
}
if (self.value) |v| {
try jw.objectField("value");
try jw.write(v);
}
if (self.input_type) |v| {
try jw.objectField("inputType");
try jw.write(v);
}
if (self.placeholder) |v| {
try jw.objectField("placeholder");
try jw.write(v);
}
if (self.href) |v| {
try jw.objectField("href");
try jw.write(v);
}
if (self.id) |v| {
try jw.objectField("id");
try jw.write(v);
}
if (self.class) |v| {
try jw.objectField("class");
try jw.write(v);
}
if (self.checked) |c| {
try jw.objectField("checked");
try jw.write(c);
}
if (self.options) |opts| {
try jw.objectField("options");
try jw.beginArray();
for (opts) |opt| {
try jw.beginObject();
try jw.objectField("value");
try jw.write(opt.value);
try jw.objectField("text");
try jw.write(opt.text);
if (opt.selected) {
try jw.objectField("selected");
try jw.write(true);
}
try jw.endObject();
}
try jw.endArray();
}
try jw.endObject();
}
};
pub fn getNodeDetails(node: *Node, registry: *CDPNode.Registry, page: *Page, arena: std.mem.Allocator) !NodeDetails {
const cdp_node = try registry.register(node);
const axn = AXNode.fromNode(node);
const role = try axn.getRole();
const name = try axn.getName(page, arena);
var is_interactive_val = false;
var is_disabled = false;
var tag_name: []const u8 = "text";
var value: ?[]const u8 = null;
var input_type: ?[]const u8 = null;
var placeholder: ?[]const u8 = null;
var href: ?[]const u8 = null;
var id_attr: ?[]const u8 = null;
var class_attr: ?[]const u8 = null;
var checked: ?bool = null;
var options: ?[]OptionData = null;
if (node.is(Element)) |el| {
tag_name = el.getTagNameLower();
is_disabled = el.isDisabled();
id_attr = el.getAttributeSafe(comptime lp.String.wrap("id"));
class_attr = el.getAttributeSafe(comptime lp.String.wrap("class"));
placeholder = el.getAttributeSafe(comptime lp.String.wrap("placeholder"));
if (el.getAttributeSafe(comptime lp.String.wrap("href"))) |h| {
const URL = lp.URL;
href = URL.resolve(arena, page.base(), h, .{ .encode = true }) catch h;
}
if (el.is(Element.Html.Input)) |input| {
value = input.getValue();
input_type = input._input_type.toString();
if (input._input_type == .checkbox or input._input_type == .radio) {
checked = input.getChecked();
}
if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| {
options = try extractDataListOptions(list_id, page, arena);
}
} else if (el.is(Element.Html.TextArea)) |textarea| {
value = textarea.getValue();
} else if (el.is(Element.Html.Select)) |select| {
value = select.getValue(page);
options = try extractSelectOptions(el.asNode(), page, arena);
}
if (el.is(Element.Html)) |html_el| {
const listener_targets = try interactive.buildListenerTargetMap(page, arena);
var pointer_events_cache: Element.PointerEventsCache = .empty;
if (interactive.classifyInteractivity(page, el, html_el, listener_targets, &pointer_events_cache) != null) {
is_interactive_val = true;
}
}
}
return .{
.backendNodeId = cdp_node.id,
.tag_name = tag_name,
.role = role,
.name = name,
.is_interactive = is_interactive_val,
.is_disabled = is_disabled,
.value = value,
.input_type = input_type,
.placeholder = placeholder,
.href = href,
.id = id_attr,
.class = class_attr,
.checked = checked,
.options = options,
};
}
const testing = @import("testing.zig"); const testing = @import("testing.zig");
test "SemanticTree backendDOMNodeId" { test "SemanticTree backendDOMNodeId" {

View File

@@ -1261,21 +1261,15 @@ pub const Transfer = struct {
fn detectAuthChallenge(transfer: *Transfer, conn: *const http.Connection) void { fn detectAuthChallenge(transfer: *Transfer, conn: *const http.Connection) void {
const status = conn.getResponseCode() catch return; const status = conn.getResponseCode() catch return;
const connect_status = conn.getConnectCode() catch return; if (status != 401 and status != 407) {
if (status != 401 and status != 407 and connect_status != 401 and connect_status != 407) {
transfer._auth_challenge = null; transfer._auth_challenge = null;
return; return;
} }
if (conn.getResponseHeader("WWW-Authenticate", 0)) |hdr| { if (conn.getResponseHeader("WWW-Authenticate", 0)) |hdr| {
transfer._auth_challenge = http.AuthChallenge.parse(status, .server, hdr.value) catch null; transfer._auth_challenge = http.AuthChallenge.parse(status, .server, hdr.value) catch null;
} else if (conn.getConnectHeader("WWW-Authenticate", 0)) |hdr| {
transfer._auth_challenge = http.AuthChallenge.parse(status, .server, hdr.value) catch null;
} else if (conn.getResponseHeader("Proxy-Authenticate", 0)) |hdr| { } else if (conn.getResponseHeader("Proxy-Authenticate", 0)) |hdr| {
transfer._auth_challenge = http.AuthChallenge.parse(status, .proxy, hdr.value) catch null; transfer._auth_challenge = http.AuthChallenge.parse(status, .proxy, hdr.value) catch null;
} else if (conn.getConnectHeader("Proxy-Authenticate", 0)) |hdr| {
transfer._auth_challenge = http.AuthChallenge.parse(status, .proxy, hdr.value) catch null;
} else { } else {
transfer._auth_challenge = .{ .status = status, .source = null, .scheme = null, .realm = null }; transfer._auth_challenge = .{ .status = status, .source = null, .scheme = null, .realm = null };
} }
@@ -1348,15 +1342,15 @@ pub const Transfer = struct {
} }
} }
transfer.req.notification.dispatch(.http_response_header_done, &.{
.transfer = transfer,
});
const proceed = transfer.req.header_callback(transfer) catch |err| { const proceed = transfer.req.header_callback(transfer) catch |err| {
log.err(.http, "header_callback", .{ .err = err, .req = transfer }); log.err(.http, "header_callback", .{ .err = err, .req = transfer });
return err; return err;
}; };
transfer.req.notification.dispatch(.http_response_header_done, &.{
.transfer = transfer,
});
return proceed and transfer.aborted == false; return proceed and transfer.aborted == false;
} }

View File

@@ -487,6 +487,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
return error.InjectBlankFailed; return error.InjectBlankFailed;
}; };
} }
self.documentIsComplete();
session.notification.dispatch(.page_navigate, &.{ session.notification.dispatch(.page_navigate, &.{
.frame_id = self._frame_id, .frame_id = self._frame_id,
@@ -518,8 +519,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// force next request id manually b/c we won't create a real req. // force next request id manually b/c we won't create a real req.
_ = session.browser.http_client.incrReqId(); _ = session.browser.http_client.incrReqId();
self.documentIsComplete();
return; return;
} }
@@ -739,12 +738,6 @@ pub fn _documentIsLoaded(self: *Page) !void {
self.document.asEventTarget(), self.document.asEventTarget(),
event, event,
); );
self._session.notification.dispatch(.page_dom_content_loaded, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
} }
pub fn scriptsCompletedLoading(self: *Page) void { pub fn scriptsCompletedLoading(self: *Page) void {
@@ -803,6 +796,19 @@ pub fn documentIsComplete(self: *Page) void {
self._documentIsComplete() catch |err| { self._documentIsComplete() catch |err| {
log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url }); log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
}; };
if (self._navigated_options) |no| {
// _navigated_options will be null in special short-circuit cases, like
// "navigating" to about:blank, in which case this notification has
// already been sent
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = no,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
} }
fn _documentIsComplete(self: *Page) !void { fn _documentIsComplete(self: *Page) !void {
@@ -821,12 +827,6 @@ fn _documentIsComplete(self: *Page) !void {
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" }); try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
} }
self._session.notification.dispatch(.page_loaded, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) { if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" }); try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
@@ -879,19 +879,6 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
}); });
} }
if (self._navigated_options) |no| {
// _navigated_options will be null in special short-circuit cases, like
// "navigating" to about:blank, in which case this notification has
// already been sent
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = no,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
return true; return true;
} }

View File

@@ -432,8 +432,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
try notification.register(.page_navigate, self, onPageNavigate); try notification.register(.page_navigate, self, onPageNavigate);
try notification.register(.page_navigated, self, onPageNavigated); try notification.register(.page_navigated, self, onPageNavigated);
try notification.register(.page_frame_created, self, onPageFrameCreated); try notification.register(.page_frame_created, self, onPageFrameCreated);
try notification.register(.page_dom_content_loaded, self, onPageDOMContentLoaded);
try notification.register(.page_loaded, self, onPageLoaded);
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
@@ -609,16 +607,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
return @import("domains/page.zig").pageFrameCreated(self, msg); return @import("domains/page.zig").pageFrameCreated(self, msg);
} }
pub fn onPageDOMContentLoaded(ctx: *anyopaque, msg: *const Notification.PageDOMContentLoaded) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageDOMContentLoaded(self, msg);
}
pub fn onPageLoaded(ctx: *anyopaque, msg: *const Notification.PageLoaded) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageLoaded(self, msg);
}
pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void { pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void {
const self: *Self = @ptrCast(@alignCast(ctx)); const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageNetworkIdle(self, msg); return @import("domains/page.zig").pageNetworkIdle(self, msg);

View File

@@ -30,6 +30,7 @@ pub fn processMessage(cmd: anytype) !void {
getMarkdown, getMarkdown,
getSemanticTree, getSemanticTree,
getInteractiveElements, getInteractiveElements,
getNodeDetails,
getStructuredData, getStructuredData,
detectForms, detectForms,
clickNode, clickNode,
@@ -42,6 +43,7 @@ pub fn processMessage(cmd: anytype) !void {
.getMarkdown => return getMarkdown(cmd), .getMarkdown => return getMarkdown(cmd),
.getSemanticTree => return getSemanticTree(cmd), .getSemanticTree => return getSemanticTree(cmd),
.getInteractiveElements => return getInteractiveElements(cmd), .getInteractiveElements => return getInteractiveElements(cmd),
.getNodeDetails => return getNodeDetails(cmd),
.getStructuredData => return getStructuredData(cmd), .getStructuredData => return getStructuredData(cmd),
.detectForms => return detectForms(cmd), .detectForms => return detectForms(cmd),
.clickNode => return clickNode(cmd), .clickNode => return clickNode(cmd),
@@ -141,6 +143,24 @@ fn getInteractiveElements(cmd: anytype) !void {
}, .{}); }, .{});
} }
fn getNodeDetails(cmd: anytype) !void {
const Params = struct {
backendNodeId: Node.Id,
};
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const node = (bc.node_registry.lookup_by_id.get(params.backendNodeId) orelse return error.InvalidNodeId).dom;
const details = SemanticTree.getNodeDetails(node, &bc.node_registry, page, cmd.arena) catch return error.InternalError;
return cmd.sendResult(.{
.nodeDetails = details,
}, .{});
}
fn getStructuredData(cmd: anytype) !void { fn getStructuredData(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.NoBrowserContext; const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded;

View File

@@ -420,6 +420,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
// things, but no session. // things, but no session.
const session_id = bc.session_id orelse return; const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
const frame_id = &id.toFrameId(event.frame_id); const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id); const loader_id = &id.toLoaderId(event.req_id);
@@ -471,9 +472,9 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
const page = bc.session.currentPage() orelse return error.PageNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded;
// When we actually recreated the context we should have the inspector send // When we actually recreated the context we should have the inspector send
// this event, see: resetContextGroup. Sending this event will tell the // this event, see: resetContextGroup Sending this event will tell the
// client that the context ids they had are invalid and the context should // client that the context ids they had are invalid and the context shouls
// be dropped. The client will expect us to send new contextCreated events, // be dropped The client will expect us to send new contextCreated events,
// such that the client has new id's for the active contexts. // such that the client has new id's for the active contexts.
// Only send executionContextsCleared for main frame navigations. For child // Only send executionContextsCleared for main frame navigations. For child
// frames (iframes), clearing all contexts would destroy the main frame's // frames (iframes), clearing all contexts would destroy the main frame's
@@ -483,18 +484,6 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id }); try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
} }
// frameNavigated event
try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation",
.frame = Frame{
.id = frame_id,
.url = event.url,
.loaderId = loader_id,
.securityOrigin = bc.security_origin,
.secureContextType = bc.secure_context_type,
},
}, .{ .session_id = session_id });
{ {
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id }); const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
@@ -565,22 +554,18 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
// chromedp client expects to receive the events is this order. // chromedp client expects to receive the events is this order.
// see https://github.com/chromedp/chromedp/issues/1558 // see https://github.com/chromedp/chromedp/issues/1558
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id }); try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
}
pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMContentLoaded) !void {
const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
var cdp = bc.cdp;
// domContentEventFired event
// TODO: partially hard coded
try cdp.sendEvent( try cdp.sendEvent(
"Page.domContentEventFired", "Page.domContentEventFired",
.{ .timestamp = timestamp }, .{ .timestamp = timestamp },
.{ .session_id = session_id }, .{ .session_id = session_id },
); );
// lifecycle DOMContentLoaded event
// TODO: partially hard coded
if (bc.page_life_cycle_events) { if (bc.page_life_cycle_events) {
const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id);
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp, .timestamp = timestamp,
.name = "DOMContentLoaded", .name = "DOMContentLoaded",
@@ -588,23 +573,16 @@ pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMConte
.loaderId = loader_id, .loaderId = loader_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }
}
pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
var cdp = bc.cdp;
const frame_id = &id.toFrameId(event.frame_id);
// loadEventFired event
try cdp.sendEvent( try cdp.sendEvent(
"Page.loadEventFired", "Page.loadEventFired",
.{ .timestamp = timestamp }, .{ .timestamp = timestamp },
.{ .session_id = session_id }, .{ .session_id = session_id },
); );
// lifecycle DOMContentLoaded event
if (bc.page_life_cycle_events) { if (bc.page_life_cycle_events) {
const loader_id = &id.toLoaderId(event.req_id);
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp, .timestamp = timestamp,
.name = "load", .name = "load",
@@ -613,6 +591,7 @@ pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }
// frameStoppedLoading
return cdp.sendEvent("Page.frameStoppedLoading", .{ return cdp.sendEvent("Page.frameStoppedLoading", .{
.frameId = frame_id, .frameId = frame_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });

View File

@@ -75,6 +75,19 @@ pub const tool_list = [_]protocol.Tool{
\\} \\}
), ),
}, },
.{
.name = "nodeDetails",
.description = "Get detailed information about a specific node by its backend node ID. Returns tag, role, name, interactivity, disabled state, value, input type, placeholder, href, checked state, and select options.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to inspect." }
\\ },
\\ "required": ["backendNodeId"]
\\}
),
},
.{ .{
.name = "interactiveElements", .name = "interactiveElements",
.description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.", .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.",
@@ -256,6 +269,7 @@ const ToolAction = enum {
navigate, navigate,
markdown, markdown,
links, links,
nodeDetails,
interactiveElements, interactiveElements,
structuredData, structuredData,
detectForms, detectForms,
@@ -272,6 +286,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "navigate", .navigate }, .{ "navigate", .navigate },
.{ "markdown", .markdown }, .{ "markdown", .markdown },
.{ "links", .links }, .{ "links", .links },
.{ "nodeDetails", .nodeDetails },
.{ "interactiveElements", .interactiveElements }, .{ "interactiveElements", .interactiveElements },
.{ "structuredData", .structuredData }, .{ "structuredData", .structuredData },
.{ "detectForms", .detectForms }, .{ "detectForms", .detectForms },
@@ -305,6 +320,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
.goto, .navigate => try handleGoto(server, arena, req.id.?, call_params.arguments), .goto, .navigate => try handleGoto(server, arena, req.id.?, call_params.arguments),
.markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments), .markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments),
.links => try handleLinks(server, arena, req.id.?, call_params.arguments), .links => try handleLinks(server, arena, req.id.?, call_params.arguments),
.nodeDetails => try handleNodeDetails(server, arena, req.id.?, call_params.arguments),
.interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments), .interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments),
.structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments),
.detectForms => try handleDetectForms(server, arena, req.id.?, call_params.arguments), .detectForms => try handleDetectForms(server, arena, req.id.?, call_params.arguments),
@@ -373,6 +389,32 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
}; };
} }
fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails");
_ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
const page = server.session.currentPage().?;
const details = lp.SemanticTree.getNodeDetails(node.dom, &server.node_registry, page, arena) catch {
return server.sendError(id, .InternalError, "Failed to get node details");
};
var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(&details, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url);

View File

@@ -389,15 +389,6 @@ pub const Connection = struct {
return url; return url;
} }
pub fn getConnectCode(self: *const Connection) !u16 {
var status: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .connect_code, &status);
if (status < 0 or status > std.math.maxInt(u16)) {
return 0;
}
return @intCast(status);
}
pub fn getResponseCode(self: *const Connection) !u16 { pub fn getResponseCode(self: *const Connection) !u16 {
var status: c_long = undefined; var status: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .response_code, &status); try libcurl.curl_easy_getinfo(self._easy, .response_code, &status);
@@ -413,24 +404,6 @@ pub const Connection = struct {
return @intCast(count); return @intCast(count);
} }
pub fn getConnectHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue {
var hdr: ?*libcurl.CurlHeader = null;
libcurl.curl_easy_header(self._easy, name, index, .connect, -1, &hdr) catch |err| {
// ErrorHeader includes OutOfMemory — rare but real errors from curl internals.
// Logged and returned as null since callers don't expect errors.
log.err(.http, "get response header", .{
.name = name,
.err = err,
});
return null;
};
const h = hdr orelse return null;
return .{
.amount = h.amount,
.value = std.mem.span(h.value),
};
}
pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue {
var hdr: ?*libcurl.CurlHeader = null; var hdr: ?*libcurl.CurlHeader = null;
libcurl.curl_easy_header(self._easy, name, index, .header, -1, &hdr) catch |err| { libcurl.curl_easy_header(self._easy, name, index, .header, -1, &hdr) catch |err| {

View File

@@ -178,7 +178,6 @@ pub const CurlInfo = enum(c.CURLINFO) {
private = c.CURLINFO_PRIVATE, private = c.CURLINFO_PRIVATE,
redirect_count = c.CURLINFO_REDIRECT_COUNT, redirect_count = c.CURLINFO_REDIRECT_COUNT,
response_code = c.CURLINFO_RESPONSE_CODE, response_code = c.CURLINFO_RESPONSE_CODE,
connect_code = c.CURLINFO_HTTP_CONNECTCODE,
}; };
pub const Error = error{ pub const Error = error{
@@ -663,7 +662,6 @@ pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Err
break :blk c.curl_easy_getinfo(easy, inf, p); break :blk c.curl_easy_getinfo(easy, inf, p);
}, },
.response_code, .response_code,
.connect_code,
.redirect_count, .redirect_count,
=> blk: { => blk: {
const p: *c_long = out; const p: *c_long = out;