| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| -module(hk_browser_session). |
| -behaviour(gen_server). |
|
|
| -include("hyperkitty.hrl"). |
|
|
| -export([start_link/1]). |
| -export([open_tab/2, close_tab/2, navigate/3, back/2, forward/2, reload/2, |
| read_page/2, query_element/3, click/3, type/4, scroll/3, |
| screenshot/2, extract_links/2, close_session/1, describe/1]). |
| -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). |
|
|
| -define(CHROME_READY_TIMEOUT_MS, 15000). |
| -define(NAV_TIMEOUT_MS, 30000). |
|
|
| -record(tab, { |
| tab_id :: binary(), |
| target_id :: binary(), |
| cdp_pid :: pid(), |
| url :: binary() | undefined, |
| title :: binary() | undefined, |
| status :: opening | loading | idle | closed, |
| created_at_ms :: integer(), |
| updated_at_ms :: integer() |
| }). |
|
|
| -record(state, { |
| session_id :: binary(), |
| owner_agent_id :: binary() | undefined, |
| profile :: binary(), |
| debug_port :: inet:port_number(), |
| user_data_dir :: string(), |
| os_port :: port(), |
| status = starting :: starting | ready | busy | closing | closed | crashed, |
| tabs = #{} :: #{binary() => #tab{}}, |
| cdp_pid_to_tab = #{} :: #{pid() => binary()}, |
| waiters = #{} :: #{{binary(), atom()} => gen_server:from()}, |
| created_at_ms :: integer() |
| }). |
|
|
| |
| start_link(Args) -> |
| gen_server:start_link(?MODULE, Args, []). |
|
|
| open_tab(Pid, Url) -> gen_server:call(Pid, {open_tab, Url}, ?CHROME_READY_TIMEOUT_MS). |
| close_tab(Pid, TabId) -> gen_server:call(Pid, {close_tab, TabId}). |
| navigate(Pid, TabId, Url) -> gen_server:call(Pid, {navigate, TabId, Url}, ?NAV_TIMEOUT_MS). |
| back(Pid, TabId) -> gen_server:call(Pid, {history, TabId, back}, ?NAV_TIMEOUT_MS). |
| forward(Pid, TabId) -> gen_server:call(Pid, {history, TabId, forward}, ?NAV_TIMEOUT_MS). |
| reload(Pid, TabId) -> gen_server:call(Pid, {reload, TabId}, ?NAV_TIMEOUT_MS). |
| read_page(Pid, TabId) -> gen_server:call(Pid, {read_page, TabId}). |
| query_element(Pid, TabId, Selector) -> gen_server:call(Pid, {query_element, TabId, Selector}). |
| click(Pid, TabId, Selector) -> gen_server:call(Pid, {click, TabId, Selector}). |
| type(Pid, TabId, Selector, Text) -> gen_server:call(Pid, {type, TabId, Selector, Text}). |
| scroll(Pid, TabId, DeltaY) -> gen_server:call(Pid, {scroll, TabId, DeltaY}). |
| screenshot(Pid, TabId) -> gen_server:call(Pid, {screenshot, TabId}). |
| extract_links(Pid, TabId) -> gen_server:call(Pid, {extract_links, TabId}). |
| close_session(Pid) -> gen_server:call(Pid, close_session). |
| describe(Pid) -> gen_server:call(Pid, describe). |
|
|
| |
|
|
| init(#{session_id := SessionId} = Args) -> |
| process_flag(trap_exit, true), |
| Profile = maps:get(profile, Args, <<"default">>), |
| OwnerAgentId = maps:get(owner_agent_id, Args, undefined), |
| Port = find_free_port(), |
| UserDataDir = filename:join(["/tmp/hyperkitty_profiles", binary_to_list(SessionId)]), |
| ok = filelib:ensure_dir(UserDataDir ++ "/."), |
| ChromeExe = application:get_env(hyperkitty, chromium_executable, |
| "/usr/bin/chromium"), |
| Args0 = [ |
| "--headless=new", |
| "--remote-debugging-port=" ++ integer_to_list(Port), |
| "--remote-debugging-address=127.0.0.1", |
| "--user-data-dir=" ++ UserDataDir, |
| "--no-sandbox", |
| "--disable-gpu", |
| "--disable-dev-shm-usage", |
| "about:blank" |
| ], |
| OsPort = erlang:open_port({spawn_executable, ChromeExe}, |
| [{args, Args0}, exit_status, binary, stderr_to_stdout, |
| {line, 4096}]), |
| State = #state{ |
| session_id = SessionId, |
| owner_agent_id = OwnerAgentId, |
| profile = Profile, |
| debug_port = Port, |
| user_data_dir = UserDataDir, |
| os_port = OsPort, |
| created_at_ms = hk_id:timestamp_ms() |
| }, |
| case hk_chrome_http:wait_for_ready(Port, ?CHROME_READY_TIMEOUT_MS) of |
| ok -> |
| ok = hk_browser_registry:register(SessionId, self()), |
| emit(State, <<"browser.session.created">>, #{profile => Profile, debug_port => Port}), |
| {ok, State#state{status = ready}}; |
| {error, timeout} -> |
| catch erlang:port_close(OsPort), |
| {stop, chrome_launch_timeout} |
| end. |
|
|
| handle_call(describe, _From, State) -> |
| {reply, {ok, session_map(State)}, State}; |
|
|
| handle_call({open_tab, Url}, _From, State = #state{status = ready}) -> |
| case hk_chrome_http:new_target(State#state.debug_port, Url) of |
| {ok, #{target_id := TargetId, ws_url := WsUrl}} -> |
| {ok, CdpPid} = hk_cdp_client:start_link(WsUrl, self()), |
| _ = hk_cdp_client:command(CdpPid, <<"Page.enable">>, #{}), |
| _ = hk_cdp_client:command(CdpPid, <<"Runtime.enable">>, #{}), |
| TabId = hk_id:new(<<"tab">>), |
| Now = hk_id:timestamp_ms(), |
| Tab = #tab{tab_id = TabId, target_id = TargetId, cdp_pid = CdpPid, |
| url = Url, status = loading, created_at_ms = Now, updated_at_ms = Now}, |
| State2 = State#state{ |
| tabs = maps:put(TabId, Tab, State#state.tabs), |
| cdp_pid_to_tab = maps:put(CdpPid, TabId, State#state.cdp_pid_to_tab) |
| }, |
| emit(State2, <<"browser.tab.opened">>, #{tab_id => TabId, url => Url}), |
| {reply, {ok, TabId}, State2}; |
| {error, Reason} -> |
| emit(State, <<"browser.tab.open_failed">>, #{url => Url, reason => term_bin(Reason)}), |
| {reply, {error, Reason}, State} |
| end; |
|
|
| handle_call({close_tab, TabId}, _From, State) -> |
| case maps:find(TabId, State#state.tabs) of |
| {ok, Tab} -> |
| hk_cdp_client:close(Tab#tab.cdp_pid), |
| _ = hk_chrome_http:close_target(State#state.debug_port, Tab#tab.target_id), |
| State2 = drop_tab(TabId, Tab, State), |
| emit(State2, <<"browser.tab.closed">>, #{tab_id => TabId}), |
| {reply, ok, State2}; |
| error -> |
| {reply, {error, no_such_tab}, State} |
| end; |
|
|
| handle_call({navigate, TabId, Url}, From, State) -> |
| with_tab(TabId, State, fun(Tab) -> |
| emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, url => Url}), |
| _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigate">>, #{<<"url">> => Url}), |
| Tab2 = Tab#tab{url = Url, status = loading, updated_at_ms = hk_id:timestamp_ms()}, |
| State2 = put_tab(Tab2, State), |
| Key = {TabId, load_event}, |
| {noreply, State2#state{waiters = maps:put(Key, From, State2#state.waiters)}} |
| end); |
|
|
| handle_call({history, TabId, Direction}, From, State) -> |
| with_tab(TabId, State, fun(Tab) -> |
| {ok, HistResult} = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.getNavigationHistory">>, #{}), |
| #{<<"currentIndex">> := Idx, <<"entries">> := Entries} = HistResult, |
| TargetIdx = case Direction of back -> Idx - 1; forward -> Idx + 1 end, |
| case TargetIdx >= 0 andalso TargetIdx < length(Entries) of |
| true -> |
| Entry = lists:nth(TargetIdx + 1, Entries), |
| EntryId = maps:get(<<"id">>, Entry), |
| _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigateToHistoryEntry">>, |
| #{<<"entryId">> => EntryId}), |
| emit(State, <<"browser.navigation.started">>, |
| #{tab_id => TabId, direction => Direction}), |
| Key = {TabId, load_event}, |
| {noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}}; |
| false -> |
| {reply, {error, no_history_entry}, State} |
| end |
| end); |
|
|
| handle_call({reload, TabId}, From, State) -> |
| with_tab(TabId, State, fun(Tab) -> |
| _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.reload">>, #{}), |
| emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, reload => true}), |
| Key = {TabId, load_event}, |
| {noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}} |
| end); |
|
|
| handle_call({read_page, TabId}, _From, State) -> |
| with_tab_sync(TabId, State, fun(Tab) -> |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, |
| #{<<"expression">> => <<"document.body ? document.body.innerText : ''">>, |
| <<"returnByValue">> => true})) |
| end, fun(Result) -> |
| emit(State, <<"browser.page.read">>, #{tab_id => TabId, chars => text_len(Result)}) |
| end); |
|
|
| handle_call({query_element, TabId, Selector}, _From, State) -> |
| Expr = query_element_js(Selector), |
| with_tab_sync(TabId, State, fun(Tab) -> |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) |
| end, fun(_Result) -> |
| emit(State, <<"browser.element.queried">>, #{tab_id => TabId, selector => Selector}) |
| end); |
|
|
| handle_call({click, TabId, Selector}, _From, State) -> |
| Expr = click_js(Selector), |
| with_tab_sync(TabId, State, fun(Tab) -> |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) |
| end, fun(_Result) -> |
| emit(State, <<"browser.action.click">>, #{tab_id => TabId, selector => Selector}) |
| end); |
|
|
| handle_call({type, TabId, Selector, Text}, _From, State) -> |
| Expr = type_js(Selector, Text), |
| with_tab_sync(TabId, State, fun(Tab) -> |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) |
| end, fun(_Result) -> |
| emit(State, <<"browser.action.type">>, #{tab_id => TabId, selector => Selector, |
| chars => byte_size(Text)}) |
| end); |
|
|
| handle_call({scroll, TabId, DeltaY}, _From, State) -> |
| Expr = list_to_binary(io_lib:format("window.scrollBy(0, ~w); 'ok'", [DeltaY])), |
| with_tab_sync(TabId, State, fun(Tab) -> |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) |
| end, fun(_Result) -> |
| emit(State, <<"browser.action.scroll">>, #{tab_id => TabId, delta_y => DeltaY}) |
| end); |
|
|
| handle_call({screenshot, TabId}, _From, State) -> |
| with_tab_sync(TabId, State, fun(Tab) -> |
| case hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.captureScreenshot">>, |
| #{<<"format">> => <<"png">>}) of |
| {ok, #{<<"data">> := B64}} -> {ok, B64}; |
| {error, Reason} -> {error, Reason} |
| end |
| end, fun(_Result) -> |
| emit(State, <<"browser.screenshot.captured">>, #{tab_id => TabId}) |
| end); |
|
|
| handle_call({extract_links, TabId}, _From, State) -> |
| Expr = <<"JSON.stringify(Array.from(document.querySelectorAll('a[href]'))" |
| ".map(a => ({href: a.href, text: a.textContent.trim()})))">>, |
| with_tab_sync(TabId, State, fun(Tab) -> |
| case eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) of |
| {ok, Json} when is_binary(Json) -> |
| try {ok, jsx:decode(Json, [return_maps])} |
| catch _:_ -> {ok, []} end; |
| Other -> Other |
| end |
| end, fun(Result) -> |
| Count = case Result of L when is_list(L) -> length(L); _ -> 0 end, |
| emit(State, <<"browser.links.extracted">>, #{tab_id => TabId, count => Count}) |
| end); |
|
|
| handle_call(close_session, _From, State) -> |
| State2 = State#state{status = closing}, |
| maps:foreach(fun(_TabId, Tab) -> hk_cdp_client:close(Tab#tab.cdp_pid) end, State2#state.tabs), |
| catch erlang:port_close(State2#state.os_port), |
| emit(State2, <<"browser.session.closed">>, #{}), |
| {stop, normal, ok, State2#state{status = closed}}. |
|
|
| handle_cast(_Msg, State) -> |
| {noreply, State}. |
|
|
| |
| |
| handle_info({cdp_event, CdpPid, <<"Page.loadEventFired">>, _Params}, State) -> |
| case maps:find(CdpPid, State#state.cdp_pid_to_tab) of |
| {ok, TabId} -> |
| Key = {TabId, load_event}, |
| case maps:take(Key, State#state.waiters) of |
| {From, Waiters2} -> |
| emit(State, <<"browser.navigation.completed">>, #{tab_id => TabId}), |
| gen_server:reply(From, ok), |
| {noreply, State#state{waiters = Waiters2}}; |
| error -> |
| {noreply, State} |
| end; |
| error -> |
| {noreply, State} |
| end; |
| handle_info({cdp_event, _CdpPid, _Method, _Params}, State) -> |
| {noreply, State}; |
| handle_info({Port, {exit_status, Status}}, State = #state{os_port = Port, status = St}) |
| when St =/= closing -> |
| emit(State, <<"browser.session.crashed">>, #{exit_status => Status}), |
| {stop, {error, {chrome_process_exited, Status}}, State#state{status = crashed}}; |
| handle_info({Port, {exit_status, _Status}}, State = #state{os_port = Port}) -> |
| {noreply, State}; |
| handle_info({Port, {data, _}}, State = #state{os_port = Port}) -> |
| {noreply, State}; |
| handle_info(_Info, State) -> |
| {noreply, State}. |
|
|
| terminate(_Reason, _State) -> |
| ok. |
|
|
| |
|
|
| find_free_port() -> |
| {ok, Socket} = gen_tcp:listen(0, [{reuseaddr, true}]), |
| {ok, Port} = inet:port(Socket), |
| ok = gen_tcp:close(Socket), |
| Port. |
|
|
| with_tab(TabId, State, Fun) -> |
| case maps:find(TabId, State#state.tabs) of |
| {ok, Tab} -> Fun(Tab); |
| error -> {reply, {error, no_such_tab}, State} |
| end. |
|
|
| |
| |
| |
| |
| with_tab_sync(TabId, State, EvalFun, OnOk) -> |
| case maps:find(TabId, State#state.tabs) of |
| {ok, Tab} -> |
| case EvalFun(Tab) of |
| {ok, Result} -> |
| OnOk(Result), |
| {reply, {ok, Result}, State}; |
| {error, Reason} -> |
| emit(State, <<"browser.action.failed">>, |
| #{tab_id => TabId, reason => term_bin(Reason)}), |
| {reply, {error, Reason}, State} |
| end; |
| error -> |
| {reply, {error, no_such_tab}, State} |
| end. |
|
|
| eval_result({ok, #{<<"exceptionDetails">> := Details}}) -> |
| {error, {js_exception, Details}}; |
| eval_result({ok, #{<<"result">> := #{<<"value">> := Value}}}) -> |
| {ok, Value}; |
| eval_result({ok, #{<<"result">> := _}}) -> |
| {ok, undefined}; |
| eval_result({error, Reason}) -> |
| {error, Reason}. |
|
|
| query_element_js(Selector) -> |
| SelJson = jsx:encode(Selector), |
| <<"(() => { const el = document.querySelector(", SelJson/binary, "); " |
| "return el ? JSON.stringify({exists:true, tag: el.tagName, " |
| "text:(el.textContent||'').trim().slice(0,500)}) : " |
| "JSON.stringify({exists:false}); })()">>. |
|
|
| click_js(Selector) -> |
| SelJson = jsx:encode(Selector), |
| <<"(() => { const el = document.querySelector(", SelJson/binary, "); " |
| "if (!el) return 'not_found'; el.click(); return 'ok'; })()">>. |
|
|
| type_js(Selector, Text) -> |
| SelJson = jsx:encode(Selector), |
| TextJson = jsx:encode(Text), |
| <<"(() => { const el = document.querySelector(", SelJson/binary, "); " |
| "if (!el) return 'not_found'; el.focus(); el.value = ", TextJson/binary, "; " |
| "el.dispatchEvent(new Event('input', {bubbles:true})); " |
| "el.dispatchEvent(new Event('change', {bubbles:true})); return 'ok'; })()">>. |
|
|
| put_tab(Tab, State) -> |
| State#state{tabs = maps:put(Tab#tab.tab_id, Tab, State#state.tabs)}. |
|
|
| drop_tab(TabId, Tab, State) -> |
| State#state{ |
| tabs = maps:remove(TabId, State#state.tabs), |
| cdp_pid_to_tab = maps:remove(Tab#tab.cdp_pid, State#state.cdp_pid_to_tab) |
| }. |
|
|
| session_map(State) -> |
| #browser_session{ |
| session_id = State#state.session_id, |
| owner_agent_id = State#state.owner_agent_id, |
| profile = State#state.profile, |
| status = State#state.status, |
| tabs = maps:keys(State#state.tabs), |
| created_at_ms = State#state.created_at_ms, |
| updated_at_ms = hk_id:timestamp_ms() |
| }. |
|
|
| emit(State, Category, Data) -> |
| Event = hk_event:new(Category, {browser_session, State#state.session_id}, Data), |
| catch hk_event_bus:publish(Event). |
|
|
| term_bin(Term) -> |
| iolist_to_binary(io_lib:format("~p", [Term])). |
|
|
| text_len(B) when is_binary(B) -> byte_size(B); |
| text_len(_) -> 0. |
|
|