вторник, 5 апреля 2016 г.

Мини FAQ по WebBrowser. Часть 1-я.



  1. Я загружаю страницу и не могу получить доступ к ее содержимому.
  2. Как найти элемент на странице, если неизвестен его id.
  3. Классы HtmlWindow, HtmlDocument, HtmlElement и HtmlElementCollection - всего лишь оболочки COM-объектов и не дают всех тех возможностей, которые есть у них. Можно ли добраться до всего функционала DOM?
  4. Как программно выделить текст в браузере.
  5. Если скопировать код из браузера и вставить его в Word, WordPad или просто в RichTextBox, то при вставке сохраняется форматирование текста(цвета, шрифты и т.д.). Можно ли достичь такого же эффекта программно?

В данной заметке хочу собрать ответы на наиболее часто встречающиеся вопросы, возникающие при работе с элементом управления WebBrowser. Как элемент управления он мало чем отличается от других контролов и главной проблемой, с которой сталкиваются те, кто с ним начинает работать, является управление содержимым веб-страниц. А этот вопрос очень плохо задокументирован и обо всех возможностях приходится узнавать из других источников. В связи с этим хотелось бы рассмотреть некоторые вопросы по этой теме. Описание буду вести в формате "вопрос-ответ", поскольку описываться будут отдельные, зачастую не связанные между собой, аспекты и какой-то последовательности в рассмотрении отдельных вопросов тоже задавать не планирую.

Я загружаю страницу и не могу получить доступ к ее содержимому.

Обычно эта проблема возникает в тех случаях, когда попытка обратиться к содержимому документа осуществляется непосредственно после вызова метода Navigate (или другого способа загрузки). В этот момент страница еще не успевает загрузиться и таким образом ее(либо той части, к которой происходит обращение) на момент обращения просто не существует как объекта. Обращаться к содержимому надо либо по команде пользователя, который видит, что страница загружена, либо, если требуется сделать это автоматически после загрузки, надо делать это в обработчике события DocumentCompleted.
С этим событием не все так просто как хотелось бы. Иногда можно выполнить нужную обработку документа в обработчике этого события и все вроде работает как надо, но потом, на другом документе могут наблюдаться неожиданные эффекты. Например, если в коде обработчика в документ добавляется какой-нибудь элемент, то можно наблюдать, что элемент добавился несколько раз, хотя в коде он был добавлен только один раз.
За примерами далеко ходить не надо. Возьмем главную страницу CyberForum.
vb.net
1
2
3
4
5
6
7
8
9
10
11
12
13
    Private Sub DocumentCompletedForm_Load(sender As Object, e As EventArgs) Handles Me.Load
        ' Запрещаем появление диалога, сообщающего, что на странице произошла ошибка сценария
        Me.WebBrowser1.ScriptErrorsSuppressed = True
        Me.WebBrowser1.Navigate("http://www.cyberforum.ru")
    End Sub
 
    Private Sub WebBrowser1_DocumentCompleted(sender As Object, e As WebBrowserDocumentCompletedEventArgs) Handles WebBrowser1.DocumentCompleted
        Dim doc = Me.WebBrowser1.Document
        Dim div = doc.CreateElement("div")
        div.Style = "color:red;font-size:30px;margin:10px"
        div.InnerText = "Привет"
        Me.WebBrowser1.Document.Body.InsertAdjacentElement(HtmlElementInsertionOrientation.AfterBegin, div)
    End Sub
На форме WebBrowser по имени WebBrowser1 и ничего больше.
Этот код при загрузке документа должен в начало страницы вставить div со словом "Привет!". Он делает это, только вставляет его 9 раз, хотя должен бы только один. Понятно, что происходить это может в том случае, если обработчик DocumentCompleted срабатывает именно такое количество раз. Но тут возникает вопрос, как такое может быть. Ответ мы получим, если изменим код следующим образом.
vb.net
1
2
3
4
5
6
7
    Private Sub WebBrowser1_DocumentCompleted(sender As Object, e As WebBrowserDocumentCompletedEventArgs) Handles WebBrowser1.DocumentCompleted
        Dim doc = Me.WebBrowser1.Document
        Dim div = doc.CreateElement("div")
        div.Style = "color:red;font-size:30px;margin:10px"
        div.InnerText = e.Url.Host
        Me.WebBrowser1.Document.Body.InsertAdjacentElement(HtmlElementInsertionOrientation.AfterBegin, div)
    End Sub
То есть вместо "привета" мы будем выводит имя хоста, с которого загружен документ. И вот, что мы имеем
Цитата:
www.cyberforum.ru
profile.begun.ru
www.acint.net
profile.begun.ru
www.acint.net
googleads.g.doubleclick.net
googleads.g.doubleclick.net
То есть помимо основной страницы, это событие срабатывает еще и при загрузке фреймов. Мало того срабатывает оно дважды и если еще проверить состояние загрузки WebBrowser.RedyState, то для фреймов сначала срабатывает в состоянии Interactive, а потом уже Complete. И только в самом конце, когда загружено все содержимое, событие срабатывает и для основной страницы (вставка происходит в самое начало документа, поэтому самая верхняя запись была добавлена самой последней).
Конечно, можно проверять адрес страницы и сравнивать его со значением свойства Url самого браузера.
vb.net
1
2
3
4
5
6
7
8
9
    Private Sub WebBrowser1_DocumentCompleted(sender As Object, e As WebBrowserDocumentCompletedEventArgs) Handles WebBrowser1.DocumentCompleted
        If WebBrowser1.Url = e.Url Then
            Dim doc = Me.WebBrowser1.Document
            Dim div = doc.CreateElement("div")
            div.Style = "color:red;font-size:30px;margin:10px"
            div.InnerText = e.Url.Host
            Me.WebBrowser1.Document.Body.InsertAdjacentElement(HtmlElementInsertionOrientation.AfterBegin, div)
        End If
    End Sub
По всей видимости это самый правильный способ и он даже работает как надо, но к сожалению, срабатывает он не всегда. На том примере, который я показал(что выводится при работе кода) можно заметить, что слово "привет" выводилось 9 раз, а вот адресов всего 7. И список не всегда один и тот же и зачастую отсутствует адрес как раз таки основного документа. С чем это связано - трудно сказать. Можно было бы предположить, что для основного документа событие сработает только тогда, когда все фреймы загрузятся и событие сработает для них всех. Но последний пример показывает, что это не так: один явно не вызывал событие, но для главной страницы оно все равно сработало. Так что не знаю насколько правилен этот способ, но надежным его точно не назовешь.
Я обычно проблему эту решаю достаточно "топорным" способом, который работает вполне себе сносно. Если задуматься, то как можно решить задачу, когда некий код должен выполниться только один раз, хотя находится он в методе, вызываемом многократно? Ответ достаточно прост, создается булева(не обязательно) переменная, а код помещается в условие, где проверяется значение этой переменной. Первоначальное значение переменно таково, что условие выполняется, но после того как все сделано, значение переменной изменяется и больше эта часть кода выполняться не будет. Примерно то же самое можно сделать и здесь. Правда создавать переменную на уровне класса не стоит, поскольку в браузер могут загружаться разные документы и на все переменных не напасешься. Но есть простой выход - метить сам документ, который уже был обработан. Например так.
vb.net
1
2
3
4
5
6
7
8
9
10
11
    Private Sub WebBrowser1_DocumentCompleted(sender As Object, e As WebBrowserDocumentCompletedEventArgs) Handles WebBrowser1.DocumentCompleted
        If WebBrowser1.Document.Body.GetAttribute("data-comleted-process-flag") <> "1" Then
            Dim doc = Me.WebBrowser1.Document
            Dim div = doc.CreateElement("div")
            div.Style = "color:red;font-size:30px;margin:10px"
            div.InnerText = e.Url.Host
            Me.WebBrowser1.Document.Body.InsertAdjacentElement(HtmlElementInsertionOrientation.AfterBegin, div)
            WebBrowser1.Document.Body.SetAttribute("data-comleted-process-flag", "1")
        End If
 
    End Sub
Здесь мы устанавливаем элементу BODY атрибут data-comleted-process-flag. В начале проверяем равно ли его значение единице( а это может произойти только если мы сами установили этот атрибут), потом выполняем действия и устанавливаем этот атрибут. Можно сделать имя атрибута совсем сложным, чтобы избежать случайных совпадений. Можно выполнять дополнительную проверку, на предмент существования элемента, с которым надо работать и если нет, то выходить из обработчика. Но это уже детали. Описанный способ неплохо работает, хотя гарантировать, что правильно сработает во всех случаях я, конечно же, не могу.

Как найти элемент на странице, если неизвестен его id.
С известным id все просто
vb.net
1
WebBrowser1.Document.GetElementById(elementId)
поэтому вопрос поставлен именно так.
На самом деле без id тоже все просто. Главное - выявить критерии, по которым ищется элемент. У HtmlDocument равно как и у HtmlElement есть свойство All, у первого оно возвращает коллекцию, состоящую из всех элементов документа, а у второго - из всех потомков данного элемента. Обойдя эту коллекцию, попутно проверяя соответствие элемента условию, можно легко найти все необходимое. Например мне нужно найти ссылку на мой профиль на главной странице Cyberforum. Ссылка выглядит так
HTML5
1
<a href="http://www.cyberforum.ru/members/557613.html">diadiavova</a>
Ссылку будем искать по имени элемента, значению атрибута href и содержимому(чтоб уж наверняка, хотя в данном случае будет достаточно значения атрибута, но все же). Вот простейший агрегатный запрос, выполняющий поиск
vb.net
1
2
3
4
5
        Dim a = Aggregate el As HtmlElement In WebBrowser1.Document.All
                Where el.TagName = "A" _
                AndAlso el.GetAttribute("href").ToLower = "http://www.cyberforum.ru/members/557613.html" _
                AndAlso el.InnerText.Trim = "diadiavova"
                Into FirstOrDefault()
Здесь имя элемента указано в верхнем регистре, поскольку браузер всегда выдает его так, независимо от того, как это было в тексте документа. Для адреса я перестраховался и перевел его в нижний регистр(на всякий случай) если реальный адрес задан переменной, то его тоже придется перевести в нижний регистр для регистронезависимой проверки (или можно исопльзовать String.Compare с соответствующим флажком). Из содержимого убрал лишние пробелы(тоже для надежности). Код простой и понятный, так что в дополнительных комментариях не нуждается.
Можно так же создать несколько методов-расширений для классов документа, элемента, окна и коллекции элементов, для того, чтобы было удобнее с ними работать. Например так
vb.net
1
2
3
4
    <Extension>
    Public Function GetElementsByClassName(document As HtmlDocument, className As String) As IEnumerable(Of HtmlElement)
        Return From el As HtmlElement In document.All Where el.GetAttribute("className") = className
    End Function
Классы HtmlWindow, HtmlDocument, HtmlElement и HtmlElementCollection - всего лишь оболочки COM-объектов и не дают всех тех возможностей, которые есть у них. Можно ли добраться до всего функционала DOM?
Существует несколько способов получить доступ ко всем возможностям DOM, независимо от того, реализованы они в интерфейсах упомянутых объектов или нет. Перечислю известные мне способы.
  1. Следует внимательнее присмотреться к методам GetAttribute и SetAttribute элемента. На самом деле они не так просты как кажется. С помощью этих методов можно управлять свойствами элементов. Можно для примера поэксперементировать со свойством outerHtml. Взять любой элемент и вызвать GetAttribute("outerHTML"). Метод выдаст полный код элемента и понятное дело, что полный код элемента ну уж никак не может быть значением его же атрибута. Кроме того в примере из предыдущего вопроса я использовал "атрибут" className. Но класс задается атрибутом class, правда через этот атрибут(при использоании getAttribute) его значение получить нельзя, а можно только через свойство className, так что в данном случае мы запросили именно свойство.
  2. HtmlElement.InvokeMember позволяет вызвать метод элемента, даже если он не определен в управляемой оболочке. Это расширяет возможности, но я бы сказал, что не очень сильно. Например можно программно кликнуть элемент, вызвав обработчики клика
    vb.net
    1
    
    element.InvokeMember("click")
    Для методов с параметрами есть перегрузка этого метода.
  3. HtmlDocument.InvokeScript - один из наиболее полезных методов. Позволяет вызвать любую функцию, доступную глобально в контексте данного документа. Наверное он не был бы таким полезным, если бы в JavaScript не существовало функции eval. Но можно в качестве первого аргумента этого метода передать eval, а вторым сделать массив с единственным элементом - JavaScript-кодом, который надо выполнить на странице. Кроме того, он при необходимости вернет результат выполнения кода.
  4. Используя DOM можно вставить свой скрипт в документ и выполнить его
    vb.net
    1
    2
    3
    4
    5
    6
    
        Sub AddAlert()
            Dim doc = Me.WebBrowser1.Document
            Dim script = doc.CreateElement("script")
            script.SetAttribute("text", "alert('Hello');")
            doc.Body.InsertAdjacentElement(HtmlElementInsertionOrientation.BeforeEnd, script)
        End Sub
    Этот запустит alert с текстом Hello. Но туда можно вставлять любые скрипты и в том числе функции из этих скрптов вызывать с помощью InvokeScript
  5. WebBrowser.ObjectForScripting - свойство, которому можно передать любой ComVisible объект и обращаться к нему из скриптов, в том числе добавленных способом, описанными в пердыдущем пункте. А так же с помощью InvokeScript через функцию eval.
  6. Через свойства HtmlWindow.DomWindow, HtmlDocument.DomDocument и HtmlElement.DomElement можно получить доступ к объектам, которые "прячутся под оболочкой. Используя позднее связывание можно писать код, очень похожий на тот, что пишется на веб-страницах. Здесь будет доступно почти все, что доступно из скрипта веб-страницы. Конечно, некоторые вещи в скриптах реализованы на уровне движка JavaScript, и получить к ним доступ можно только с помощью пунктов, описанных выше и позволяющих выполнять скрипты на странице, зато здесь есть некоторые возможности, которые на странице недоступны из-за политики безопасности веб-станиц. Недостатком этого подхода является все, что относится к недостаткам использования позднего связывания.
  7. То же, что и в предыдущем пункте, но уже с помощью библиотеки mshtml. Именно в этой библиотеке описаны все типы, составляющие DOM. Подключая эту библиотеку мы избавляемся от сложностей позднего связывания (и получаем все "прелести" COM-взаимодействия). В общем и целом, если надо писать много кода, взаимодействующего с DOM, то этот способ лучше. А если чуть-чуть - то можно обойтись предыдущим пунктом или вовсе управиться, используя классы, предоставляемые пространстовом System.Windows.Forms.
Более подробно буду описывать пункты уже на примерах, по мере того, как в них будет появляться надобность.

Как программно выделить текст в браузере.

Если это делать в коде JavaScript, то выглядеть это будет так
Javascript
1
2
3
        var rng = document.body.createTextRange();
        rng.moveToElementText(document.body);
        rng.select();
Воспользуемся этим и создадим на его основе метод-расширение для класса HtmlDocument. Создадим модуль
vb.net
1
2
3
4
Imports System.Runtime.CompilerServices
Module HTMLWrappersExtensions
 
End Module
И определим в нем методы
vb.net
1
2
3
4
5
6
7
8
9
10
11
12
13
14
    <Extension>
    Public Function ExecuteScript(document As HtmlDocument, script As String)
        Return document.InvokeScript("eval", New Object() {script})
    End Function
 
    <Extension>
    Public Sub SelectAllText(document As HtmlDocument)
        Dim script = <![CDATA[
        var rng = document.body.createTextRange();
        rng.moveToElementText(document.body);
        rng.select();
        ]]>.Value
        document.ExecuteScript(script)
    End Sub
Первый метод немного упрощает вызов InvokeScript с функцией eval, а второй его вызывает. Сам скрипт мы пишем в XML-литерале CDATA, чтобы можно было писать текст на нескольких строках прямо в коде, и передаем его методу ExecuteScript. Теперь вызвав у документа метод SelectAllText мы выделим весь текст документа.
Теперь напишем аналогичный код для элемента, но уже будем использовать DOM-объекты непосредственно в коде.
vb.net
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    <Extension()>
    Public Sub SelectElementText(element As HtmlElement)
        Dim doc = element.Document.DomDocument
        Dim rng = doc.body.createTextRange()
        rng.moveToElementText(element.DomElement)
        rng.select()
    End Sub
 
    <Extension>
    Public Sub SelectText(element As HtmlElement, textToFind As String)
        Dim doc = element.Document.DomDocument
        Dim rng = doc.body.createTextRange()
        rng.findText(textToFind)
        rng.select()
    End Sub
Первый метод выделяет весь текст элемента, второй сначала находит в нем указанный текст, а потом выделяет именно его. Несложно заметить, что код очень похож на тот, что был написан на JavaScript, но с поправкой на синтаксис.

Если скопировать код из браузера и вставить его в Word, WordPad или просто в RichTextBox, то при вставке сохраняется форматирование текста(цвета, шрифты и т.д.). Можно ли достичь такого же эффекта программно
Не могу сказать, есть ли для этого какие-то встроенные функции, но такого эффекта можно достичь тем же способом, что и при участии пользователя: выделить код, скопировать в буфер обмена и извлечь оттуда RTF. Реализуем это в виде расширения объекта HtmlDocument и воспользуемся ранее определенным методом для выделения текст документа SelectAllText.
vb.net
1
2
3
4
5
6
    <Extension>
    Public Function RetrieveRtf(document As HtmlDocument) As String
        document.SelectAllText()
        document.ExecCommand("Copy", False, True)
        Return Clipboard.GetData(DataFormats.Rtf)
    End Function
Аналгичным образом можно выполнить и обратное преобразование. Но на этот раз создадим метод, который будет создавать экземпляр WebBrowser "втемную", не показывая его пользователю.
vb.net
1
2
3
4
5
6
7
8
9
10
11
12
    Public Sub RtfToHTML(rtf As String, callback As Action(Of String))
        Dim browser As New WebBrowser With {.Width = 500, .Height = 500}
        AddHandler browser.DocumentCompleted,
           Sub(sender, e)
               Clipboard.SetData(DataFormats.Rtf, rtf)
               browser.Document.Body.SetAttribute("contentEditable", "true")
               browser.Document.ExecCommand("Paste", False, True)
               callback(browser.Document.Body.Parent.OuterHtml)
               browser.Dispose()
           End Sub
        browser.DocumentText = <html><head><title></title></head><body></body></html>.ToString
    End Sub
В данном случае, поскольку вся работа выполняется в обработчике события DocumentComleted, мы не можем сделать так, чтобы метод вернул результат, поэтому возвращаем его через метод обратного вызова (callback). Вызывать его мы будем так
vb.net
1
2
3
4
        RtfToHTML(rtfText,
                  Sub(result)
                      IO.File.WriteAllText(IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "qwerty.htm"), result)
                  End Sub)
В данном случае код сохраняется на рабочем столе в файле qwerty.htm.
Не надо ждать от такого преобразования слишком много. Вид веб-документа может сильно отличаться от того, что было первоначально. Например можно попробовать скопировать код из редактора кода Visual Studio, он копируется в буфер обмена в том числе и в формате RTF, но HTML, производимый в этом случае теряет отступы и могут иначе выглядеть буквы, высота межстрочного интервала и т. д. Но так, в принципе все похоже, расцветка кода совпадает. Вот код, который можно запустить после того, как скопирован код из редактора кода студии.
vb.net
1
2
3
4
        RtfToHTML(Clipboard.GetData(DataFormats.Rtf),
                  Sub(result)
                      IO.File.WriteAllText(IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "qwerty.htm"), result)
                  End Sub)
Проблему отступов можно решить, немного отредактировав RTF перед преобразованием
vb.net
1
2
3
4
        RtfToHTML(Clipboard.GetData(DataFormats.Rtf).Replace(" ", "\'A0"),
                  Sub(result)
                      IO.File.WriteAllText(IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "qwerty.htm"), result)
                  End Sub)
Но вида "как в студии" все равно это не дает, хотя это уже близко.

Комментариев нет :

Отправить комментарий