24 мая 2021

Встраиваем Microsoft Edge. Просмотр содержимого загруженной в WebView2 веб-страницы

    Добавленный в RAD Studio 10.4 Sydney новый контрол TEdgeBrowser позволяет легко встроить браузер Microsoft Edge на основе Chromium в приложение написанное на Delphi или C++Builder. Но его функциональные возможности ограничены возможностями Microsoft.Web.WebView2. Например, у всех интернет-браузеров есть функция для просмотра исходного кода загруженной в него веб-страницы, а у WebView2 такого метода нет. Но этот недостаток можно легко исправить.
    Идея очень простая. Контрол WebView2 позволяет в отображаемом в нем документе выполнять код написанный на JavaScript. А JavaScript, используя DOM, может получить доступ к загруженному в интернет-браузере документу и его элементам.
    Начнем с DOM. Веб-страница, которая загружается в интернет-браузере, анализируется и преобразуется браузером в объектную модель документа (Document Object Model). DOM предоставляет собой объектно-ориентированный программный интерфейс (API) для доступа к элементам документов в форматах HTML, XHTML и XML. В нем каждая веб-страница, которая загружается в браузер, имеет свой собственный объект "document". Он служит интерфейсом для доступа ко всем свойствам документа. Модель документа представляет собой дерево, в узлах которого хранятся объекты типа "Element", предоставляющие интерфейс для доступа к свойствам отдельных элементов документа. Свойство элемента "outerHTML" хранит содержимое элемента. Значит нам достаточно взять корневой элемент загруженного документа (например, для HTML документов это <html>) и получить его содержимое. То есть путь к исходному коду веб-страницы будет выглядеть так: document.documentElement.outerHTML.
    В RAD Studio есть модуль WebView2.pas, в который импортировано описание интерфейсов и констант из WebView2.tlb. В модуле WebView2 нас интересуют две вещи:
  • ICoreWebView2.ExecuteScript – функция, которая выполняет строку с кодом JavaScript в загруженном в WebView2 документе
    ICoreWebView2 = interface(IUnknown)
      ['{189B8AAF-0426-4748-B9AD-243F537EB46B}']
      ...
      function ExecuteScript(javaScript: PWideChar; 
                             const handler: ICoreWebView2ExecuteScriptCompletedHandler): HResult; stdcall;
    end;
  • WebView.ICoreWebView2ExecuteScriptCompletedHandler – ссылка на функцию, которая получает результат вызова метода ICoreWebView2.ExecuteScript
    ICoreWebView2ExecuteScriptCompletedHandler = interface(IUnknown)
      ['{3B717C93-3ED5-4450-9B13-7F56AA367AC7}']
      function Invoke(errorCode: HResult; resultObjectAsJson: PWideChar): HResult; stdcall;
    end;
    Создадим тестовый VCL-проект, на главную форму которого поместим EdgeBrowser: TEdgeBrowser (интерфейс к WebView2), reSource: TRichEdit (для отображения исходного кода загруженной в EdgeBrowser веб-страницы) и btnViewPageSource: TButton (кнопка для вызова метода получения исходного кода). Добавим метод ViewPageSource для получения исходного кода загруженной веб-страницы и метод btnViewPageSourceClick для его вызова:
procedure TForm1.btnViewPageSourceClick(Sender: TObject);
begin
  if EdgeBrowser.WebViewCreated
    then ViewPageSource(EdgeBrowser.DefaultInterface)
    else reSource.Clear
end;

procedure TForm1.ViewPageSource(WebView: ICoreWebView2);
begin
  WebView.ExecuteScript('encodeURI(document.documentElement.outerHTML)',
    ICoreWebView2ExecuteScriptCompletedHandler(
      function (errorCode: HResult; resultObjectAsJson: PWideChar): HResult stdcall
      begin
        if resultObjectAsJson = 'null'
          then reSource.Clear
          else reSource.Text := TNetEncoding.URL.Decode(resultObjectAsJson).DeQuotedString('"');
        Result := S_OK;
      end));
end;
Небольшие комментарии к коду:
  • EdgeBrowser.DefaultInterface – ссылка на инициализированный в EdgeBrowser контрол WebView2.
  • encodeURI – функция JavaScript, которая заменяет некоторые символы на управляющие последовательности, представляющие UTF-8 кодировку символа.
  • resultObjectAsJson = 'null' – вызов скрипта может вернуть строку 'null' (например, если страница находится в состоянии загрузки).
  • TNetEncoding.URL.Decode – декодируем результат в читаемый вид.
Запускаем программу, нажимаем кнопку "View source" и получаем исходный код загруженной веб-страницы. Можете сравнить его с тем, что отображает сам Edge:
TEdgeBrowser / WebView - View page source
    У класса TEdgeBrowser есть своя реализация вызова процедуры ICoreWebView2.ExecuteScript. Для получения ее результата объекту TEdgeBrowser необходимо добавить обработчик OnExecuteScript типа TExecuteScriptEvent.
type
  TCustomEdgeBrowser = class(TWinControl)
    procedure ExecuteScript(const JavaScript: string);
...
  TExecuteScriptEvent = procedure (Sender: TCustomEdgeBrowser; AResult: HResult; 
                                   const AResultObjectAsJson: string) of object;
При их использовании код программы будет выглядеть немного иначе:
procedure TForm1.btnViewPageSourceClick(Sender: TObject);
begin
  if EdgeBrowser.WebViewCreated
    then EdgeBrowser.ExecuteScript('encodeURI(document.documentElement.outerHTML)')
    else reSource.Clear
end;

procedure TForm1.EdgeBrowserExecuteScript(Sender: TCustomEdgeBrowser;
  AResult: HRESULT; const AResultObjectAsJson: string);
begin
  if AResultObjectAsJson = 'null'
    then reSource.Clear
    else reSource.Text := TNetEncoding.URL.Decode(AResultObjectAsJson).DeQuotedString('"');
end;
    Мне больше нравится первый вариант. В нем вызов ExecuteScript и функция, которая обрабатывает его результат однозначно связаны. Вариант, предложенный разработчиками RAD Studio, требует общего обработчика OnExecuteScript в котором, если программа будет выполнять различные команды JavaScript, необходимо будет делать дополнительные проверки.

5 комментариев:

  1. Я не понимаю почему, но этот код способен только заполнить TMemo, а попытка подставить вместо этого тобычный StringList заканчивается провалом - ничего не записывается.

    ОтветитьУдалить
    Ответы
    1. Я попробовал добавить TStringList как посредника. Все работает:
      if AResultObjectAsJson = 'null'
      then reSource.Clear
      else begin
      var sl: TStringList := TStringList.Create;
      try
      sl.Text := TNetEncoding.URL.Decode(AResultObjectAsJson).DeQuotedString('"');
      reSource.Text := sl.Text;
      finally
      sl.Free
      end;
      end;

      Удалить
  2. Я просто не понимаю, что происходит. Если я пишу
    HTMLSource.Text := TNetEncoding.URL.Decode(resultObjectAsJson).DeQuotedString('"');
    то HTMLSource.Text в дебаггере равен '', HTMLSource.Count равен нулю, в этом стринглисте ничего нет. Причем, если HTMLSource является локальной переменной, то компилятор "оптимизирует" ее до состояния E2003 Undeclared identifier.
    Если я пишу вот так:
    Memo1.Text := TNetEncoding.URL.Decode(resultObjectAsJson).DeQuotedString('"');
    HTMLSource.Text:=Memo1.Text;
    то в Memo присутствует исходный текст страницы, его видно на форме, но опять же Memo1.Text = '', и HTMLSource остается пустым. Даже через assign ничего не копируется. Это какая-то дичь...

    ОтветитьУдалить
    Ответы
    1. Это потому что под отладкой вы смотрите Memo1.Text до того, как туда записались значения.

      Удалить
  3. Этот код будет работать случайным образом, обычно работать не будет. Дело в том что ExecuteScript выполняется какое-то время и значение в reSource.Text появится не сразу. В случае если попытаться сразу после ExecuteScript прочитать reSource.Text - там обычно пусто будет. Поэтому нужен таймаут на определение.

    Вот так работает:

    var
    reSource: string;

    function EdgeBrowserGetPageSource(aEdgeBrowser: TEdgeBrowser; ATimeOut{sec}: Integer; ABreak: PBoolean): String;
    var
    WebView: ICoreWebView2;
    tFinish: TDateTime;
    begin
    Result := '';
    if AEdgeBrowser.WebViewCreated then
    begin
    reSource := '';
    WebView := AEdgeBrowser.DefaultInterface;

    WebView.ExecuteScript('encodeURI(document.documentElement.outerHTML)',
    ICoreWebView2ExecuteScriptCompletedHandler(
    function (errorCode: HResult; resultObjectAsJson: PWideChar): HResult stdcall
    begin
    if resultObjectAsJson <> 'null' then
    reSource := TNetEncoding.URL.Decode(resultObjectAsJson).DeQuotedString('"');
    Result := S_OK;
    end));

    tFinish := Now() + ATimeOut/24/60/60;
    repeat
    if length(reSource) = 0 then
    sleep(100);
    Application.ProcessMessages;
    until (length(reSource) > 0) or (Now() >= tFinish) or (assigned(ABreak) and ABreak^);

    Result := reSource;
    end;
    end;

    ОтветитьУдалить