Add src/hk_browser_session.erl
Browse files- src/hk_browser_session.erl +415 -0
src/hk_browser_session.erl
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%%%-------------------------------------------------------------------
|
| 2 |
+
%%% @doc HK-BROWSER :: Chromium Controller (one process per session).
|
| 3 |
+
%%%
|
| 4 |
+
%%% One `hk_browser_session' gen_server owns exactly one headless
|
| 5 |
+
%%% Chromium OS process (its own `--user-data-dir', its own
|
| 6 |
+
%%% `--remote-debugging-port') and every tab opened within it. This
|
| 7 |
+
%%% is the process boundary the architecture calls for: browser
|
| 8 |
+
%%% state (which tabs exist, what URL each is on) lives *only* here,
|
| 9 |
+
%%% reached exclusively through the typed operations below -- never
|
| 10 |
+
%%% mixed into agent state or read directly from another process.
|
| 11 |
+
%%%
|
| 12 |
+
%%% Every operation:
|
| 13 |
+
%%% 1. is validated (tab exists, session is `ready') before any
|
| 14 |
+
%%% Chromium interaction is attempted;
|
| 15 |
+
%%% 2. drives Chromium through `hk_cdp_client' (never through ad
|
| 16 |
+
%%% hoc process state);
|
| 17 |
+
%%% 3. publishes a structured `hk_event' recording what happened,
|
| 18 |
+
%%% win or lose -- per the agent_browser_interface requirement
|
| 19 |
+
%%% that "every operation must produce a structured event."
|
| 20 |
+
%%%
|
| 21 |
+
%%% Click/type are implemented via `Runtime.evaluate' (DOM method
|
| 22 |
+
%%% calls and property assignment) rather than CDP's `Input' domain
|
| 23 |
+
%%% (synthetic OS-level mouse/keyboard events). This is a documented
|
| 24 |
+
%%% simplification, not a hidden one: it's adequate for
|
| 25 |
+
%%% programmatic interaction with most pages, but it will not
|
| 26 |
+
%%% exercise a page's native pointer/keyboard event handlers the way
|
| 27 |
+
%%% `Input.dispatchMouseEvent'/`Input.dispatchKeyEvent' would. A
|
| 28 |
+
%%% production build would add an `Input'-domain code path behind
|
| 29 |
+
%%% the same typed operation names.
|
| 30 |
+
%%% @end
|
| 31 |
+
%%%-------------------------------------------------------------------
|
| 32 |
+
-module(hk_browser_session).
|
| 33 |
+
-behaviour(gen_server).
|
| 34 |
+
|
| 35 |
+
-include("hyperkitty.hrl").
|
| 36 |
+
|
| 37 |
+
-export([start_link/1]).
|
| 38 |
+
-export([open_tab/2, close_tab/2, navigate/3, back/2, forward/2, reload/2,
|
| 39 |
+
read_page/2, query_element/3, click/3, type/4, scroll/3,
|
| 40 |
+
screenshot/2, extract_links/2, close_session/1, describe/1]).
|
| 41 |
+
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
|
| 42 |
+
|
| 43 |
+
-define(CHROME_READY_TIMEOUT_MS, 15000).
|
| 44 |
+
-define(NAV_TIMEOUT_MS, 30000).
|
| 45 |
+
|
| 46 |
+
-record(tab, {
|
| 47 |
+
tab_id :: binary(),
|
| 48 |
+
target_id :: binary(),
|
| 49 |
+
cdp_pid :: pid(),
|
| 50 |
+
url :: binary() | undefined,
|
| 51 |
+
title :: binary() | undefined,
|
| 52 |
+
status :: opening | loading | idle | closed,
|
| 53 |
+
created_at_ms :: integer(),
|
| 54 |
+
updated_at_ms :: integer()
|
| 55 |
+
}).
|
| 56 |
+
|
| 57 |
+
-record(state, {
|
| 58 |
+
session_id :: binary(),
|
| 59 |
+
owner_agent_id :: binary() | undefined,
|
| 60 |
+
profile :: binary(),
|
| 61 |
+
debug_port :: inet:port_number(),
|
| 62 |
+
user_data_dir :: string(),
|
| 63 |
+
os_port :: port(),
|
| 64 |
+
status = starting :: starting | ready | busy | closing | closed | crashed,
|
| 65 |
+
tabs = #{} :: #{binary() => #tab{}},
|
| 66 |
+
cdp_pid_to_tab = #{} :: #{pid() => binary()},
|
| 67 |
+
waiters = #{} :: #{{binary(), atom()} => gen_server:from()},
|
| 68 |
+
created_at_ms :: integer()
|
| 69 |
+
}).
|
| 70 |
+
|
| 71 |
+
%% Start args: #{session_id, owner_agent_id, profile}
|
| 72 |
+
start_link(Args) ->
|
| 73 |
+
gen_server:start_link(?MODULE, Args, []).
|
| 74 |
+
|
| 75 |
+
open_tab(Pid, Url) -> gen_server:call(Pid, {open_tab, Url}, ?CHROME_READY_TIMEOUT_MS).
|
| 76 |
+
close_tab(Pid, TabId) -> gen_server:call(Pid, {close_tab, TabId}).
|
| 77 |
+
navigate(Pid, TabId, Url) -> gen_server:call(Pid, {navigate, TabId, Url}, ?NAV_TIMEOUT_MS).
|
| 78 |
+
back(Pid, TabId) -> gen_server:call(Pid, {history, TabId, back}, ?NAV_TIMEOUT_MS).
|
| 79 |
+
forward(Pid, TabId) -> gen_server:call(Pid, {history, TabId, forward}, ?NAV_TIMEOUT_MS).
|
| 80 |
+
reload(Pid, TabId) -> gen_server:call(Pid, {reload, TabId}, ?NAV_TIMEOUT_MS).
|
| 81 |
+
read_page(Pid, TabId) -> gen_server:call(Pid, {read_page, TabId}).
|
| 82 |
+
query_element(Pid, TabId, Selector) -> gen_server:call(Pid, {query_element, TabId, Selector}).
|
| 83 |
+
click(Pid, TabId, Selector) -> gen_server:call(Pid, {click, TabId, Selector}).
|
| 84 |
+
type(Pid, TabId, Selector, Text) -> gen_server:call(Pid, {type, TabId, Selector, Text}).
|
| 85 |
+
scroll(Pid, TabId, DeltaY) -> gen_server:call(Pid, {scroll, TabId, DeltaY}).
|
| 86 |
+
screenshot(Pid, TabId) -> gen_server:call(Pid, {screenshot, TabId}).
|
| 87 |
+
extract_links(Pid, TabId) -> gen_server:call(Pid, {extract_links, TabId}).
|
| 88 |
+
close_session(Pid) -> gen_server:call(Pid, close_session).
|
| 89 |
+
describe(Pid) -> gen_server:call(Pid, describe).
|
| 90 |
+
|
| 91 |
+
%%% gen_server callbacks
|
| 92 |
+
|
| 93 |
+
init(#{session_id := SessionId} = Args) ->
|
| 94 |
+
process_flag(trap_exit, true),
|
| 95 |
+
Profile = maps:get(profile, Args, <<"default">>),
|
| 96 |
+
OwnerAgentId = maps:get(owner_agent_id, Args, undefined),
|
| 97 |
+
Port = find_free_port(),
|
| 98 |
+
UserDataDir = filename:join(["/tmp/hyperkitty_profiles", binary_to_list(SessionId)]),
|
| 99 |
+
ok = filelib:ensure_dir(UserDataDir ++ "/."),
|
| 100 |
+
ChromeExe = application:get_env(hyperkitty, chromium_executable,
|
| 101 |
+
"/usr/bin/chromium"),
|
| 102 |
+
Args0 = [
|
| 103 |
+
"--headless=new",
|
| 104 |
+
"--remote-debugging-port=" ++ integer_to_list(Port),
|
| 105 |
+
"--remote-debugging-address=127.0.0.1",
|
| 106 |
+
"--user-data-dir=" ++ UserDataDir,
|
| 107 |
+
"--no-sandbox",
|
| 108 |
+
"--disable-gpu",
|
| 109 |
+
"--disable-dev-shm-usage",
|
| 110 |
+
"about:blank"
|
| 111 |
+
],
|
| 112 |
+
OsPort = erlang:open_port({spawn_executable, ChromeExe},
|
| 113 |
+
[{args, Args0}, exit_status, binary, stderr_to_stdout,
|
| 114 |
+
{line, 4096}]),
|
| 115 |
+
State = #state{
|
| 116 |
+
session_id = SessionId,
|
| 117 |
+
owner_agent_id = OwnerAgentId,
|
| 118 |
+
profile = Profile,
|
| 119 |
+
debug_port = Port,
|
| 120 |
+
user_data_dir = UserDataDir,
|
| 121 |
+
os_port = OsPort,
|
| 122 |
+
created_at_ms = hk_id:timestamp_ms()
|
| 123 |
+
},
|
| 124 |
+
case hk_chrome_http:wait_for_ready(Port, ?CHROME_READY_TIMEOUT_MS) of
|
| 125 |
+
ok ->
|
| 126 |
+
ok = hk_browser_registry:register(SessionId, self()),
|
| 127 |
+
emit(State, <<"browser.session.created">>, #{profile => Profile, debug_port => Port}),
|
| 128 |
+
{ok, State#state{status = ready}};
|
| 129 |
+
{error, timeout} ->
|
| 130 |
+
catch erlang:port_close(OsPort),
|
| 131 |
+
{stop, chrome_launch_timeout}
|
| 132 |
+
end.
|
| 133 |
+
|
| 134 |
+
handle_call(describe, _From, State) ->
|
| 135 |
+
{reply, {ok, session_map(State)}, State};
|
| 136 |
+
|
| 137 |
+
handle_call({open_tab, Url}, _From, State = #state{status = ready}) ->
|
| 138 |
+
case hk_chrome_http:new_target(State#state.debug_port, Url) of
|
| 139 |
+
{ok, #{target_id := TargetId, ws_url := WsUrl}} ->
|
| 140 |
+
{ok, CdpPid} = hk_cdp_client:start_link(WsUrl, self()),
|
| 141 |
+
_ = hk_cdp_client:command(CdpPid, <<"Page.enable">>, #{}),
|
| 142 |
+
_ = hk_cdp_client:command(CdpPid, <<"Runtime.enable">>, #{}),
|
| 143 |
+
TabId = hk_id:new(<<"tab">>),
|
| 144 |
+
Now = hk_id:timestamp_ms(),
|
| 145 |
+
Tab = #tab{tab_id = TabId, target_id = TargetId, cdp_pid = CdpPid,
|
| 146 |
+
url = Url, status = loading, created_at_ms = Now, updated_at_ms = Now},
|
| 147 |
+
State2 = State#state{
|
| 148 |
+
tabs = maps:put(TabId, Tab, State#state.tabs),
|
| 149 |
+
cdp_pid_to_tab = maps:put(CdpPid, TabId, State#state.cdp_pid_to_tab)
|
| 150 |
+
},
|
| 151 |
+
emit(State2, <<"browser.tab.opened">>, #{tab_id => TabId, url => Url}),
|
| 152 |
+
{reply, {ok, TabId}, State2};
|
| 153 |
+
{error, Reason} ->
|
| 154 |
+
emit(State, <<"browser.tab.open_failed">>, #{url => Url, reason => term_bin(Reason)}),
|
| 155 |
+
{reply, {error, Reason}, State}
|
| 156 |
+
end;
|
| 157 |
+
|
| 158 |
+
handle_call({close_tab, TabId}, _From, State) ->
|
| 159 |
+
case maps:find(TabId, State#state.tabs) of
|
| 160 |
+
{ok, Tab} ->
|
| 161 |
+
hk_cdp_client:close(Tab#tab.cdp_pid),
|
| 162 |
+
_ = hk_chrome_http:close_target(State#state.debug_port, Tab#tab.target_id),
|
| 163 |
+
State2 = drop_tab(TabId, Tab, State),
|
| 164 |
+
emit(State2, <<"browser.tab.closed">>, #{tab_id => TabId}),
|
| 165 |
+
{reply, ok, State2};
|
| 166 |
+
error ->
|
| 167 |
+
{reply, {error, no_such_tab}, State}
|
| 168 |
+
end;
|
| 169 |
+
|
| 170 |
+
handle_call({navigate, TabId, Url}, From, State) ->
|
| 171 |
+
with_tab(TabId, State, fun(Tab) ->
|
| 172 |
+
emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, url => Url}),
|
| 173 |
+
_ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigate">>, #{<<"url">> => Url}),
|
| 174 |
+
Tab2 = Tab#tab{url = Url, status = loading, updated_at_ms = hk_id:timestamp_ms()},
|
| 175 |
+
State2 = put_tab(Tab2, State),
|
| 176 |
+
Key = {TabId, load_event},
|
| 177 |
+
{noreply, State2#state{waiters = maps:put(Key, From, State2#state.waiters)}}
|
| 178 |
+
end);
|
| 179 |
+
|
| 180 |
+
handle_call({history, TabId, Direction}, From, State) ->
|
| 181 |
+
with_tab(TabId, State, fun(Tab) ->
|
| 182 |
+
{ok, HistResult} = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.getNavigationHistory">>, #{}),
|
| 183 |
+
#{<<"currentIndex">> := Idx, <<"entries">> := Entries} = HistResult,
|
| 184 |
+
TargetIdx = case Direction of back -> Idx - 1; forward -> Idx + 1 end,
|
| 185 |
+
case TargetIdx >= 0 andalso TargetIdx < length(Entries) of
|
| 186 |
+
true ->
|
| 187 |
+
Entry = lists:nth(TargetIdx + 1, Entries),
|
| 188 |
+
EntryId = maps:get(<<"id">>, Entry),
|
| 189 |
+
_ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigateToHistoryEntry">>,
|
| 190 |
+
#{<<"entryId">> => EntryId}),
|
| 191 |
+
emit(State, <<"browser.navigation.started">>,
|
| 192 |
+
#{tab_id => TabId, direction => Direction}),
|
| 193 |
+
Key = {TabId, load_event},
|
| 194 |
+
{noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}};
|
| 195 |
+
false ->
|
| 196 |
+
{reply, {error, no_history_entry}, State}
|
| 197 |
+
end
|
| 198 |
+
end);
|
| 199 |
+
|
| 200 |
+
handle_call({reload, TabId}, From, State) ->
|
| 201 |
+
with_tab(TabId, State, fun(Tab) ->
|
| 202 |
+
_ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.reload">>, #{}),
|
| 203 |
+
emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, reload => true}),
|
| 204 |
+
Key = {TabId, load_event},
|
| 205 |
+
{noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}}
|
| 206 |
+
end);
|
| 207 |
+
|
| 208 |
+
handle_call({read_page, TabId}, _From, State) ->
|
| 209 |
+
with_tab_sync(TabId, State, fun(Tab) ->
|
| 210 |
+
eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
|
| 211 |
+
#{<<"expression">> => <<"document.body ? document.body.innerText : ''">>,
|
| 212 |
+
<<"returnByValue">> => true}))
|
| 213 |
+
end, fun(Result) ->
|
| 214 |
+
emit(State, <<"browser.page.read">>, #{tab_id => TabId, chars => text_len(Result)})
|
| 215 |
+
end);
|
| 216 |
+
|
| 217 |
+
handle_call({query_element, TabId, Selector}, _From, State) ->
|
| 218 |
+
Expr = query_element_js(Selector),
|
| 219 |
+
with_tab_sync(TabId, State, fun(Tab) ->
|
| 220 |
+
eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
|
| 221 |
+
#{<<"expression">> => Expr, <<"returnByValue">> => true}))
|
| 222 |
+
end, fun(_Result) ->
|
| 223 |
+
emit(State, <<"browser.element.queried">>, #{tab_id => TabId, selector => Selector})
|
| 224 |
+
end);
|
| 225 |
+
|
| 226 |
+
handle_call({click, TabId, Selector}, _From, State) ->
|
| 227 |
+
Expr = click_js(Selector),
|
| 228 |
+
with_tab_sync(TabId, State, fun(Tab) ->
|
| 229 |
+
eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
|
| 230 |
+
#{<<"expression">> => Expr, <<"returnByValue">> => true}))
|
| 231 |
+
end, fun(_Result) ->
|
| 232 |
+
emit(State, <<"browser.action.click">>, #{tab_id => TabId, selector => Selector})
|
| 233 |
+
end);
|
| 234 |
+
|
| 235 |
+
handle_call({type, TabId, Selector, Text}, _From, State) ->
|
| 236 |
+
Expr = type_js(Selector, Text),
|
| 237 |
+
with_tab_sync(TabId, State, fun(Tab) ->
|
| 238 |
+
eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
|
| 239 |
+
#{<<"expression">> => Expr, <<"returnByValue">> => true}))
|
| 240 |
+
end, fun(_Result) ->
|
| 241 |
+
emit(State, <<"browser.action.type">>, #{tab_id => TabId, selector => Selector,
|
| 242 |
+
chars => byte_size(Text)})
|
| 243 |
+
end);
|
| 244 |
+
|
| 245 |
+
handle_call({scroll, TabId, DeltaY}, _From, State) ->
|
| 246 |
+
Expr = list_to_binary(io_lib:format("window.scrollBy(0, ~w); 'ok'", [DeltaY])),
|
| 247 |
+
with_tab_sync(TabId, State, fun(Tab) ->
|
| 248 |
+
eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
|
| 249 |
+
#{<<"expression">> => Expr, <<"returnByValue">> => true}))
|
| 250 |
+
end, fun(_Result) ->
|
| 251 |
+
emit(State, <<"browser.action.scroll">>, #{tab_id => TabId, delta_y => DeltaY})
|
| 252 |
+
end);
|
| 253 |
+
|
| 254 |
+
handle_call({screenshot, TabId}, _From, State) ->
|
| 255 |
+
with_tab_sync(TabId, State, fun(Tab) ->
|
| 256 |
+
case hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.captureScreenshot">>,
|
| 257 |
+
#{<<"format">> => <<"png">>}) of
|
| 258 |
+
{ok, #{<<"data">> := B64}} -> {ok, B64};
|
| 259 |
+
{error, Reason} -> {error, Reason}
|
| 260 |
+
end
|
| 261 |
+
end, fun(_Result) ->
|
| 262 |
+
emit(State, <<"browser.screenshot.captured">>, #{tab_id => TabId})
|
| 263 |
+
end);
|
| 264 |
+
|
| 265 |
+
handle_call({extract_links, TabId}, _From, State) ->
|
| 266 |
+
Expr = <<"JSON.stringify(Array.from(document.querySelectorAll('a[href]'))"
|
| 267 |
+
".map(a => ({href: a.href, text: a.textContent.trim()})))">>,
|
| 268 |
+
with_tab_sync(TabId, State, fun(Tab) ->
|
| 269 |
+
case eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
|
| 270 |
+
#{<<"expression">> => Expr, <<"returnByValue">> => true})) of
|
| 271 |
+
{ok, Json} when is_binary(Json) ->
|
| 272 |
+
try {ok, jsx:decode(Json, [return_maps])}
|
| 273 |
+
catch _:_ -> {ok, []} end;
|
| 274 |
+
Other -> Other
|
| 275 |
+
end
|
| 276 |
+
end, fun(Result) ->
|
| 277 |
+
Count = case Result of L when is_list(L) -> length(L); _ -> 0 end,
|
| 278 |
+
emit(State, <<"browser.links.extracted">>, #{tab_id => TabId, count => Count})
|
| 279 |
+
end);
|
| 280 |
+
|
| 281 |
+
handle_call(close_session, _From, State) ->
|
| 282 |
+
State2 = State#state{status = closing},
|
| 283 |
+
maps:foreach(fun(_TabId, Tab) -> hk_cdp_client:close(Tab#tab.cdp_pid) end, State2#state.tabs),
|
| 284 |
+
catch erlang:port_close(State2#state.os_port),
|
| 285 |
+
emit(State2, <<"browser.session.closed">>, #{}),
|
| 286 |
+
{stop, normal, ok, State2#state{status = closed}}.
|
| 287 |
+
|
| 288 |
+
handle_cast(_Msg, State) ->
|
| 289 |
+
{noreply, State}.
|
| 290 |
+
|
| 291 |
+
%% CDP event fan-in: correlate `Page.loadEventFired' back to whichever
|
| 292 |
+
%% navigate/back/forward/reload call is waiting on this tab.
|
| 293 |
+
handle_info({cdp_event, CdpPid, <<"Page.loadEventFired">>, _Params}, State) ->
|
| 294 |
+
case maps:find(CdpPid, State#state.cdp_pid_to_tab) of
|
| 295 |
+
{ok, TabId} ->
|
| 296 |
+
Key = {TabId, load_event},
|
| 297 |
+
case maps:take(Key, State#state.waiters) of
|
| 298 |
+
{From, Waiters2} ->
|
| 299 |
+
emit(State, <<"browser.navigation.completed">>, #{tab_id => TabId}),
|
| 300 |
+
gen_server:reply(From, ok),
|
| 301 |
+
{noreply, State#state{waiters = Waiters2}};
|
| 302 |
+
error ->
|
| 303 |
+
{noreply, State}
|
| 304 |
+
end;
|
| 305 |
+
error ->
|
| 306 |
+
{noreply, State}
|
| 307 |
+
end;
|
| 308 |
+
handle_info({cdp_event, _CdpPid, _Method, _Params}, State) ->
|
| 309 |
+
{noreply, State}; %% other CDP events are available for future views (console, network, ...)
|
| 310 |
+
handle_info({Port, {exit_status, Status}}, State = #state{os_port = Port, status = St})
|
| 311 |
+
when St =/= closing ->
|
| 312 |
+
emit(State, <<"browser.session.crashed">>, #{exit_status => Status}),
|
| 313 |
+
{stop, {error, {chrome_process_exited, Status}}, State#state{status = crashed}};
|
| 314 |
+
handle_info({Port, {exit_status, _Status}}, State = #state{os_port = Port}) ->
|
| 315 |
+
{noreply, State};
|
| 316 |
+
handle_info({Port, {data, _}}, State = #state{os_port = Port}) ->
|
| 317 |
+
{noreply, State};
|
| 318 |
+
handle_info(_Info, State) ->
|
| 319 |
+
{noreply, State}.
|
| 320 |
+
|
| 321 |
+
terminate(_Reason, _State) ->
|
| 322 |
+
ok.
|
| 323 |
+
|
| 324 |
+
%%% internal
|
| 325 |
+
|
| 326 |
+
find_free_port() ->
|
| 327 |
+
{ok, Socket} = gen_tcp:listen(0, [{reuseaddr, true}]),
|
| 328 |
+
{ok, Port} = inet:port(Socket),
|
| 329 |
+
ok = gen_tcp:close(Socket),
|
| 330 |
+
Port.
|
| 331 |
+
|
| 332 |
+
with_tab(TabId, State, Fun) ->
|
| 333 |
+
case maps:find(TabId, State#state.tabs) of
|
| 334 |
+
{ok, Tab} -> Fun(Tab);
|
| 335 |
+
error -> {reply, {error, no_such_tab}, State}
|
| 336 |
+
end.
|
| 337 |
+
|
| 338 |
+
%% For operations that complete synchronously against the CDP client
|
| 339 |
+
%% (i.e. not waiting on a later async event like page load): run
|
| 340 |
+
%% `EvalFun(Tab) -> {ok, Result} | {error, Reason}', emit `OnOk(Result)'
|
| 341 |
+
%% only on success, and always reply from this call.
|
| 342 |
+
with_tab_sync(TabId, State, EvalFun, OnOk) ->
|
| 343 |
+
case maps:find(TabId, State#state.tabs) of
|
| 344 |
+
{ok, Tab} ->
|
| 345 |
+
case EvalFun(Tab) of
|
| 346 |
+
{ok, Result} ->
|
| 347 |
+
OnOk(Result),
|
| 348 |
+
{reply, {ok, Result}, State};
|
| 349 |
+
{error, Reason} ->
|
| 350 |
+
emit(State, <<"browser.action.failed">>,
|
| 351 |
+
#{tab_id => TabId, reason => term_bin(Reason)}),
|
| 352 |
+
{reply, {error, Reason}, State}
|
| 353 |
+
end;
|
| 354 |
+
error ->
|
| 355 |
+
{reply, {error, no_such_tab}, State}
|
| 356 |
+
end.
|
| 357 |
+
|
| 358 |
+
eval_result({ok, #{<<"exceptionDetails">> := Details}}) ->
|
| 359 |
+
{error, {js_exception, Details}};
|
| 360 |
+
eval_result({ok, #{<<"result">> := #{<<"value">> := Value}}}) ->
|
| 361 |
+
{ok, Value};
|
| 362 |
+
eval_result({ok, #{<<"result">> := _}}) ->
|
| 363 |
+
{ok, undefined};
|
| 364 |
+
eval_result({error, Reason}) ->
|
| 365 |
+
{error, Reason}.
|
| 366 |
+
|
| 367 |
+
query_element_js(Selector) ->
|
| 368 |
+
SelJson = jsx:encode(Selector),
|
| 369 |
+
<<"(() => { const el = document.querySelector(", SelJson/binary, "); "
|
| 370 |
+
"return el ? JSON.stringify({exists:true, tag: el.tagName, "
|
| 371 |
+
"text:(el.textContent||'').trim().slice(0,500)}) : "
|
| 372 |
+
"JSON.stringify({exists:false}); })()">>.
|
| 373 |
+
|
| 374 |
+
click_js(Selector) ->
|
| 375 |
+
SelJson = jsx:encode(Selector),
|
| 376 |
+
<<"(() => { const el = document.querySelector(", SelJson/binary, "); "
|
| 377 |
+
"if (!el) return 'not_found'; el.click(); return 'ok'; })()">>.
|
| 378 |
+
|
| 379 |
+
type_js(Selector, Text) ->
|
| 380 |
+
SelJson = jsx:encode(Selector),
|
| 381 |
+
TextJson = jsx:encode(Text),
|
| 382 |
+
<<"(() => { const el = document.querySelector(", SelJson/binary, "); "
|
| 383 |
+
"if (!el) return 'not_found'; el.focus(); el.value = ", TextJson/binary, "; "
|
| 384 |
+
"el.dispatchEvent(new Event('input', {bubbles:true})); "
|
| 385 |
+
"el.dispatchEvent(new Event('change', {bubbles:true})); return 'ok'; })()">>.
|
| 386 |
+
|
| 387 |
+
put_tab(Tab, State) ->
|
| 388 |
+
State#state{tabs = maps:put(Tab#tab.tab_id, Tab, State#state.tabs)}.
|
| 389 |
+
|
| 390 |
+
drop_tab(TabId, Tab, State) ->
|
| 391 |
+
State#state{
|
| 392 |
+
tabs = maps:remove(TabId, State#state.tabs),
|
| 393 |
+
cdp_pid_to_tab = maps:remove(Tab#tab.cdp_pid, State#state.cdp_pid_to_tab)
|
| 394 |
+
}.
|
| 395 |
+
|
| 396 |
+
session_map(State) ->
|
| 397 |
+
#browser_session{
|
| 398 |
+
session_id = State#state.session_id,
|
| 399 |
+
owner_agent_id = State#state.owner_agent_id,
|
| 400 |
+
profile = State#state.profile,
|
| 401 |
+
status = State#state.status,
|
| 402 |
+
tabs = maps:keys(State#state.tabs),
|
| 403 |
+
created_at_ms = State#state.created_at_ms,
|
| 404 |
+
updated_at_ms = hk_id:timestamp_ms()
|
| 405 |
+
}.
|
| 406 |
+
|
| 407 |
+
emit(State, Category, Data) ->
|
| 408 |
+
Event = hk_event:new(Category, {browser_session, State#state.session_id}, Data),
|
| 409 |
+
catch hk_event_bus:publish(Event).
|
| 410 |
+
|
| 411 |
+
term_bin(Term) ->
|
| 412 |
+
iolist_to_binary(io_lib:format("~p", [Term])).
|
| 413 |
+
|
| 414 |
+
text_len(B) when is_binary(B) -> byte_size(B);
|
| 415 |
+
text_len(_) -> 0.
|