Основы исполнения JavaScript и взаимодействие с сайтами внутри программ на Qt

Введение


О кроссплатформенной библиотеке Qt слышали, наверное, многие. О движке отображения веб-страниц WebKit тем более. Не так давно первое стало содержать обертку над вторым, примеры создания браузеров в 50 строчек найти не сложно. Тем не менее о том, как получать доступ к отдельным элементам веб-страницы из Qt-кода написано не много.

В данном описании я предполагаю, что люди обладают начальными познаниями в PyQt (я учил по Саммерфилду), и смутным представлением о JavaScript. Свой уровень я характеризую, именно таким, так что заранее извиняюсь за ошибки, особенно в описании ява-скрипта. Несмотря на то что в качестве языка использован Python у программистов C++/Qt вопросов тоже быть не должно.
Тестовые примеры запускались на PyQt-4.7.3, версия Python-2.6.6-r1 под ОС GNU/Linux. Из программ понадобится браузер с отладкой JS (Chrome, например) и PyQt IDE на ваше усмотрение, я использую Eric4.

Пример 1. Браузер, над которым мы будем издеваться



# -*- coding: utf-8 -*-
from PyQt4.QtCore import *
from PyQt4.QtNetwork import *
from PyQt4.QtGui import *
from PyQt4.QtWebKit import *

class BaseBrowser(QWidget):
  def __init__(self, parent = None):
    super(BaseBrowser, self).__init__(parent)
    self.__progress = 0
    QNetworkProxyFactory.setUseSystemConfiguration(True)
    self.webView = QWebView()
    self.webView.load(QUrl("http://www.yandex.ru"))
    self.connect(self.webView, SIGNAL("loadFinished(bool)"), self.adjustLocation)
    self.connect(self.webView, SIGNAL("titleChanged(QString)"), self.adjustTitle)
    self.connect(self.webView, SIGNAL("loadProgress(int)"), self.setProgress)
    self.connect(self.webView, SIGNAL("loadFinished(bool)"), self.finishLoading)
    self.locationEdit = QLineEdit()
    self.locationEdit.setSizePolicy(QSizePolicy.Expanding, self.locationEdit.sizePolicy().verticalPolicy())
    self.connect(self.locationEdit, SIGNAL("returnPressed()"), self.changeLocation)
    self.goButton = QPushButton("Go")
    self.connect(self.goButton, SIGNAL("clicked()"), self.changeLocation)
    self.layout = QGridLayout(self)
    self.layout.addWidget(self.locationEdit, 0, 0)
    self.layout.addWidget(self.goButton, 0, 1)
    self.layout.addWidget(self.webView, 1, 0, 1, 2)
    self.setLayout(self.layout)
    
  def adjustLocation(self):
    self.locationEdit.setText(self.webView.url().toString())
    
  def changeLocation(self):
    url = self.locationEdit.text()
    if url[0:7] != 'http://':
      url = 'http://' + url
    self.webView.load(QUrl(url))
    self.webView.setFocus()
    
  def adjustTitle(self):
    if self.__progress <= 0 or self.__progress >= 100:
      self.setWindowTitle(self.webView.title())
    else:
      self.setWindowTitle(QString("%1 (%2%)").arg(self.webView.title()).arg(self.__progress))
      
  def setProgress(self, p):
    self.__progress = p
    self.adjustTitle()
    
  def finishLoading(self):
    self.__progress = 100
    self.adjustTitle()

if __name__ == "__main__":
  import sys
  app = QApplication(sys.argv)
  prog = BaseBrowser()
  prog.show()
  sys.exit(app.exec_())


* This source code was highlighted with Source Code Highlighter.


Код примера 1: pastebin.com/GVQ4dw1M

Браузер представляет некую вариацию на тему браузеров из обучающих примеров по C++/Qt и PyQt, в последующих двух примеров мы будем его наследовать. Я понимаю, что так программы, даже маленькие, не пишут, и программа не должна быть одним классом, но баланс между кол-вом кода, его наглядностью и правильностью архитектуру я соблюдаю как могу.
Итак, браузер наш умеет не многое, но может загружать и отображать введенную страницу, для этого используется виджет QWebView, стандартные сигналы создаваемые этим виджетом мы привязали к слотам нашего браузера, что позволяет программе знать программе о смене заголовку текущей веб-страницы SIGNAL(«titleChanged(QString)»), прогрессе загрузки SIGNAL(«loadProgress(int)») и окончании загрузки — SIGNAL(«loadFinished(bool)»). Кроме этого создается поле QlineEdit для ввода адресса страницы и кнопка для перехода к этой веб-странице, либо по нажатию «Enter» либо по щелчку на кнопке.
Запускаем браузер, пробуем в работе, офигиваем от скорости работы «голого» WebKit. Пока ничего особенного мы не написали. Наш браузер даже по ссылкам не по всем переходит.

Пример 2. DOM-деревья и доступ к их элементам из Qt



Вообще, о структуре HTML страниц лучше бы, почитать отдельно, в двух предложениях это описать проблематично. В общем-то, если вы будете делать из офлайновую оболочку к какому либо веб-интерфейсу, ява-скрипт нужно будет все-таки выучить, по-крайней мере ту его часть, которая относится к доступу к данным. Итак, любой современный браузер позволяет получить доступ к содержимому веб-страницы представляя его в в виде дерева узлов, каждый узел которого представляет собой элемент, атрибут, текстовый, графический или любой другой объект. Узлы связаны между собой отношениями родительский-дочерний (да, эта строка из википедии). При помощи интерпретатора JavaScript к узлам этого дерева можно получить доступ. Откроем наш браузер и зайдем на все тот же yandex.ru (надеюсь их не накроет хабраэффектом). Сколько вы видите ссылок над поисковой строкой?
Щелкните по списку ссылок и откройте их в меню разработчика (в Chrome это — «проверить элемент» в контекстном списке). Так мы увидим положение текущего элемента в дереве. Список имеет незамысловатый id = «tabs» и является таблицей. Переключитесь в JavaScript консоль и попробуйте выбрать эту таблицу:
document.getElementById("tabs").
Посмотрите сколько в ней строк:
document.getElementById("tabs").rows.length
И сколько столбцов:
document.getElementById("tabs").rows(0).cells.length.
Теперь получим такой же результат в нашем браузере.

# -*- coding: utf-8 -*-
from basebrowser import *

class SimpleJavaScript(BaseBrowser):
  def __init__(self, parent = None):
    super(SimpleJavaScript, self).__init__(parent)
    self.jsButton = QPushButton("ExecuteJS")
    self.connect(self.jsButton, SIGNAL("clicked()"), self.jsScript)
    self.jsStringEdit = QLineEdit()
    self.jsStringEdit.setSizePolicy(QSizePolicy.Expanding, self.jsStringEdit.sizePolicy().verticalPolicy())
    self.jsStringEdit.setText("document.getElementById(\"tabs\").rows(0).cells.length")
    self.connect(self.jsStringEdit, SIGNAL("returnPressed()"), self.jsScript)
    self.jsReturnText = QTextEdit()
    self.layout.addWidget(self.jsStringEdit, 2, 0, 1, 1)
    self.layout.addWidget(self.jsButton, 2, 1, 1, 1)
    self.layout.addWidget(self.jsReturnText, 3, 0, 1, 2)

  def jsScript(self):    
    jsString = self.jsStringEdit.text()
    jsReturn = self.webView.page().currentFrame().evaluateJavaScript(jsString)
    self.jsReturnText.setPlainText(jsReturn.toString())

if __name__ == "__main__":
  import sys
  app = QApplication(sys.argv)
  ui = SimpleJavaScript()
  ui.show()
  sys.exit(app.exec_())


* This source code was highlighted with Source Code Highlighter.


Код примера 2: pastebin.com/p4P1ZEtS
Итак вычисление JS кода происходит в функции webView.page().currentFrame().evaluateJavaScript(jsString)
Функция evaluateJavaScript(string) принимает в качестве единственного аргумента строку QString, содержащую код на языке JavaScript. Этот код будет выполнен на текущей странице а результат выполнения будет возвращен в виде переменной QVariant. При этом, к сожалению, получить в качестве результата поддерево DOM-элементов у вас не получится, но любую текстовую или числовую информацию — пожалуйста.

Пример 3. Создание офлайн контролов



Адрес домашней страницы на этот раз выбран таким поскольку у меня карточка ATI и сижу я под Линуксом, кто знает, тот поймет, что это не от большой любви. На самом деле на странице множество контролов типа Select, для одного из которых мы создадим эквивалент.

# -*- coding: utf-8 -*-
from basebrowser import *
from PyQt4.QtGui import *
from PyQt4.QtCore import *

class JSSelectList(QAbstractListModel):
  def __init__ (self, _id, _jsFunc, parent = None):
    super(JSSelectList, self).__init__(parent)   
    self.id = _id
    self.jsFunc = _jsFunc
    
  def data(self, index, role=Qt.DisplayRole):
    if not index.isValid():      
      return QVariant()
    if role == Qt.DisplayRole:
      jsstring = QString("document.getElementById('%1').options[%2].textContent").arg(self.id).arg(index.row())
      jsreturn = self.jsFunc(jsstring)
      return jsreturn.toString().trimmed()
    
  def rowCount(self, index=QModelIndex()):
    jsstring = QString("document.getElementById('%1').length").arg(self.id)
    jsreturn = self.jsFunc(jsstring)
    ok = False
    count, ok = jsreturn.toInt()
    return count if ok else 0
    
  def headerData(self, section, orientation, role=Qt.DisplayRole):
    if role != Qt.DisplayRole:
      return QVariant()
    else:
      return self.id

class JSComboBoxDemo(BaseBrowser):
  def __init__(self, parent = None):
    super(JSComboBoxDemo, self).__init__(parent)
    self.vendorComboBox = QComboBox()
    id = QString("productLine")
    self.vendorListModel = JSSelectList(id, self.webView.page().currentFrame().evaluateJavaScript)
    self.vendorComboBox.setModel(self.vendorListModel)
    self.connect(self.vendorComboBox, SIGNAL("currentIndexChanged(int)"), self.setSelectOnWebPage);
    self.connect(self.webView, SIGNAL("loadFinished(bool)"), self.initComboBox)
    self.layout.addWidget(self.vendorComboBox, 2, 0, 1, 1)
    self.webView.load(QUrl("http://www.amd.com"))

  def setSelectOnWebPage(self, new_id):
    jsstring = QString("document.getElementById('productLine').selectedIndex=%1").arg(new_id)
    self.webView.page().currentFrame().evaluateJavaScript(jsstring)

  def initComboBox(self):
    self.vendorComboBox.setCurrentIndex(0)

if __name__ == "__main__":
  import sys
  app = QApplication(sys.argv)
  ui = JSComboBoxDemo()
  ui.show()
  sys.exit(app.exec_())


* This source code was highlighted with Source Code Highlighter.


Код примера 3: pastebin.com/YzA9hL3H

При создании таких элементов GUI как таблица, список, dropdown (не знаю как правильно перевести) Qt позволяет использовать удобный MVC подход. Вам нужно лишь описать доступ к вашей модели данных — вам нужно лишь наследовать ваше представление данных от встроенного абстрактного класса и прицепить его к стандартному контролу (у Саммерфилда, это вроде бы 14 глава). В данном случае используется QAbstractListModel, из параметров ей передается только функция исполнения JS и название select`а на странице. Все переопределения стандарты.

В самом примере тоже все достаточно понятно, кроме двух соединений типа сигнал-слот, на которые хотелось бы обратить ваше внимание.

Во-первых, бесполезно пытаться выполнить JavaScript до загрузки страницы, поэтому воспользуемся тем, что при окончании загрузки виджет QWebView формирует сигнал SIGNAL(«loadFinished(bool)»), о котором я уже говорил в первом примере.

self.connect(self.webView, SIGNAL("loadFinished(bool)"), self.initComboBox)

В противном случае, если запихнуть строку

self.vendorComboBox.setCurrentIndex(0)

в __init__ ни какой инициализации первым значением не произойдет — evaluateJavaScript ничего не вернет, так как страница еще не успеет загрузиться.
Во-вторых, нам нужна синхронизация в обе стороны:

self.connect(self.vendorComboBox, SIGNAL("currentIndexChanged(int)"), self.setSelectOnWebPage)

Аналогичным образом можно синхронизировать практически всю информацию на странице, нажимать кнопки, загружать информацию.

Буду рад, если информация окажется для кого-то полезной. Всех с Рождеством и прошедшим Новым Годом.

Использованная литература:

Ж. Бланшет, М. Саммерфилд. Qt 4: Программирование GUI на C++.
Mark Summerfield. Rapid GUI Programming with Python and Qt.
Другие источники:
Различные интернет сайты по JavaScript и PyQt, исходный код интернет-браузера Arora.
  • +38
  • 6,8k
  • 1
Поделиться публикацией

Комментарии 1

    0
    Доводилось как-то перепиливать стандартный пример браузера из числа представленных в QtDemo. Получилось неплохо, но тогда удалось обойтись малой кровью и такое взаимодействие оказалось не нужным. Хватило стандартных сигналов и слотов у QWebView, хотя мне их количество и показалось маловатым.
    Спасибо за информацию.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое