├── Dockerfile ├── LICENSE ├── QtImagePartSelector.py ├── README.md ├── img └── pyvisualcompare-select.png ├── main.py └── pyvisualcompare-md5.sh /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | MAINTAINER Nicolai Spohrer 4 | 5 | RUN adduser --quiet --disabled-password qtuser && \ 6 | apt update && \ 7 | DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends \ 8 | python3-pyqt5 \ 9 | wkhtmltopdf \ 10 | xvfb && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | COPY . /app/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2021, Nicolai Spohrer 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /QtImagePartSelector.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | try: 4 | from PyQt5.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR, QPoint, QRect, QSize 5 | from PyQt5.QtGui import QImage, QPixmap, QPainterPath 6 | from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QFileDialog, QRubberBand 7 | except ImportError: 8 | raise ImportError("ImportError: Requires PyQt5") 9 | 10 | 11 | class QtImagePartSelector(QGraphicsView): 12 | """ 13 | Partly based on https://github.com/marcel-goldschen-ohm/PyQtImageViewer 14 | by Marcel Goldschen-Ohm, MIT license 15 | """ 16 | 17 | rectSet = pyqtSignal(QRect) 18 | 19 | def __init__(self): 20 | QGraphicsView.__init__(self) 21 | 22 | # Image is displayed as a QPixmap in a QGraphicsScene attached to this QGraphicsView. 23 | self.scene = QGraphicsScene() 24 | self.setScene(self.scene) 25 | 26 | # Store a local handle to the scene's current image pixmap. 27 | self._pixmapHandle = None 28 | 29 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) 30 | self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) 31 | 32 | # rubber band for area selection 33 | self.rubberBand = QRubberBand(QRubberBand.Rectangle, self) 34 | self.rubberBandScenePos = None # so it can be restored after resizing 35 | self.setMouseTracking(True) 36 | self.origin = QPoint() 37 | self.changeRubberBand = False 38 | 39 | # previous mouse position during mouse drag action 40 | self.dragPrevMousePos = None 41 | 42 | self.setCursor(Qt.CrossCursor) 43 | 44 | def hasImage(self): 45 | """ Returns whether or not the scene contains an image pixmap. 46 | """ 47 | return self._pixmapHandle is not None 48 | 49 | def clearImage(self): 50 | """ Removes the current image pixmap from the scene if it exists. 51 | """ 52 | if self.hasImage(): 53 | self.scene.removeItem(self._pixmapHandle) 54 | self._pixmapHandle = None 55 | 56 | def pixmap(self): 57 | """ Returns the scene's current image pixmap as a QPixmap, or else None if no image exists. 58 | :rtype: QPixmap | None 59 | """ 60 | if self.hasImage(): 61 | return self._pixmapHandle.pixmap() 62 | return None 63 | 64 | def image(self): 65 | """ Returns the scene's current image pixmap as a QImage, or else None if no image exists. 66 | :rtype: QImage | None 67 | """ 68 | if self.hasImage(): 69 | return self._pixmapHandle.pixmap().toImage() 70 | return None 71 | 72 | def resizeEvent(self, event): 73 | QGraphicsView.resizeEvent(self, event) 74 | self.updateRubberBandDisplay() 75 | 76 | 77 | def showEvent(self, event): 78 | self.old_center = self.mapToScene(self.rect().center()) 79 | 80 | def setImage(self, image): 81 | """ Set the scene's current image pixmap to the input QImage or QPixmap. 82 | Raises a RuntimeError if the input image has type other than QImage or QPixmap. 83 | :type image: QImage | QPixmap 84 | """ 85 | if type(image) is QPixmap: 86 | pixmap = image 87 | elif type(image) is QImage: 88 | pixmap = QPixmap.fromImage(image) 89 | else: 90 | raise RuntimeError("ImageViewer.setImage: Argument must be a QImage or QPixmap.") 91 | if self.hasImage(): 92 | self._pixmapHandle.setPixmap(pixmap) 93 | else: 94 | self._pixmapHandle = self.scene.addPixmap(pixmap) 95 | self.setSceneRect(QRectF(pixmap.rect())) # Set scene size to image size. 96 | 97 | 98 | 99 | def loadImageFromFile(self, fileName=""): 100 | """ Load an image from file. 101 | Without any arguments, loadImageFromFile() will popup a file dialog to choose the image file. 102 | With a fileName argument, loadImageFromFile(fileName) will attempt to load the specified image file directly. 103 | """ 104 | if len(fileName) == 0: 105 | fileName, dummy = QFileDialog.getOpenFileName(self, "Open image file.") 106 | if len(fileName) and os.path.isfile(fileName): 107 | image = QImage(fileName) 108 | self.setImage(image) 109 | 110 | def mousePressEvent(self, event): 111 | """ Start creation of rubber band 112 | """ 113 | if event.button() == Qt.LeftButton: 114 | self.origin = event.pos() 115 | self.rubberBand.setGeometry(QRect(self.origin, QSize())) 116 | self.rubberBandScenePos = self.mapToScene(self.rubberBand.geometry()) 117 | 118 | self.rubberBand.show() 119 | self.changeRubberBand = True 120 | elif event.button() == Qt.MidButton: 121 | self.setCursor(Qt.ClosedHandCursor) 122 | self.dragPrevMousePos = event.pos() 123 | 124 | QGraphicsView.mousePressEvent(self, event) 125 | 126 | def mouseMoveEvent(self, event): 127 | if self.changeRubberBand: 128 | # update rubber 129 | self.rubberBand.setGeometry(QRect(self.origin, event.pos()).normalized()) 130 | self.rubberBandScenePos = self.mapToScene(self.rubberBand.geometry()) 131 | if event.buttons() & Qt.MidButton: 132 | # drag image 133 | offset = self.dragPrevMousePos - event.pos() 134 | self.dragPrevMousePos = event.pos() 135 | 136 | self.verticalScrollBar().setValue(self.verticalScrollBar().value() + offset.y()) 137 | self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() + offset.x()) 138 | self.updateRubberBandDisplay() 139 | 140 | QGraphicsView.mouseMoveEvent(self, event) 141 | 142 | def mouseReleaseEvent(self, event): 143 | if event.button() == Qt.LeftButton: 144 | # Emit rubber band size 145 | self.changeRubberBand = False 146 | self.rectSet.emit(self.rubberBandScenePos.boundingRect().toAlignedRect()) 147 | elif event.button() == Qt.MiddleButton: 148 | self.setCursor(Qt.CrossCursor) 149 | 150 | QGraphicsView.mouseReleaseEvent(self, event) 151 | 152 | def updateRubberBandDisplay(self): 153 | if self.rubberBandScenePos is not None: 154 | self.rubberBand.setGeometry(self.mapFromScene(self.rubberBandScenePos).boundingRect()) 155 | 156 | def wheelEvent(self, event): 157 | # Zoom Factor 158 | zoomInFactor = 1.1 159 | zoomOutFactor = 1 / zoomInFactor 160 | 161 | # Set Anchors 162 | self.setTransformationAnchor(QGraphicsView.NoAnchor) 163 | self.setResizeAnchor(QGraphicsView.NoAnchor) 164 | 165 | # Save the scene pos 166 | oldPos = self.mapToScene(event.pos()) 167 | 168 | # Zoom 169 | if event.angleDelta().y() > 0: 170 | zoomFactor = zoomInFactor 171 | else: 172 | zoomFactor = zoomOutFactor 173 | self.scale(zoomFactor, zoomFactor) 174 | 175 | # Get the new position 176 | newPos = self.mapToScene(event.pos()) 177 | 178 | # Move scene to old position 179 | delta = newPos - oldPos 180 | self.translate(delta.x(), delta.y()) 181 | 182 | self.updateRubberBandDisplay() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyvisualcompare 2 | Graphical tool to help monitor visual changes on web pages 3 | 4 | ## Why use it? 5 | You might want to use this tool if you are interested in monitoring visual changes in web pages. 6 | For example you may be interested in immediately getting a notification if the latest version of Python changes on python.org. 7 | In pyvisualcompare you can select an area of a page that announces the latest Python version: 8 | 9 | Screenshot 10 | 11 | Another use case can be getting updates on exam result announcements. 12 | 13 | It offers an alternative to services like [Visualping](https://visualping.io/) that have significant costs or restrictions on the update rate. 14 | As this tool is supposed to run on your own server, the update rate can be as high as you want (as long as the server can handle it). 15 | 16 | ## How to use it? 17 | The process to set up monitoring is not easy, but pyvisualcompare will guide you through it. 18 | It uses an [```urlwatch```](https://github.com/thp/urlwatch) shell command in the background, so all 19 | notification options offered by ```urlwatch``` can be used. Some other tools are needed on the computer that is used to select 20 | the area of interest (frontend PC) as well as on the server that is supposed to periodically check for changes (backend server). 21 | 22 | ### Frontend (GUI) on Linux 23 | 24 | Clone the repository (or download the code as a `zip` file): 25 | 26 | ```bash 27 | git clone https://github.com/nspo/pyvisualcompare.git 28 | cd pyvisualcompare/ 29 | ``` 30 | 31 | On the frontend, the following packages must be installed (example for Ubuntu/Debian systems): 32 | 33 | ```sudo apt install python3-pyqt5 wkhtmltopdf xvfb``` 34 | 35 | When all necessary packages are installed on the frontend, pyvisualcompare can be started with ```python3 main.py``` 36 | and the URL of interest can be entered. All other steps are (hopefully) explained in the interface. 37 | 38 | ### Frontend (GUI) with Docker 39 | 40 | The following Docker command should make it possible to use the frontend on systems which have a running X11 server (most Linux distributions): 41 | 42 | `docker run -it -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=$DISPLAY -u qtuser nspohrer/pyvisualcompare python3 /app/main.py` 43 | 44 | If you are running MacOS, you will probably have to install XQuartz and adapt the parameters a bit - see [this guide](https://gist.github.com/cschiewek/246a244ba23da8b9f0e7b11a68bf3285). 45 | 46 | ### Frontend options 47 | 48 | #### Static size 49 | You can choose to take a screenshot of the target web page with a static size. 50 | This seems to be necessary for some web pages to work correctly. 51 | 52 | #### Delay 53 | If the web page is not yet fully loaded when the virtual screenshot is taken (e.g. due to Javascript), you may want to increase the default delay of 350 ms to a higher value. 54 | 55 | ### Backend on Linux 56 | The setup wizard will help to install the needed packages on the backend server. 57 | 58 | ### Backend with Docker 59 | 60 | The following code snippet shows an example how you can run the main part of the `pyvisualcompare` backend with Docker (e.g. on MacOS or if you do not want to install system packages): 61 | 62 | `docker run -it -u qtuser nspohrer/pyvisualcompare /app/pyvisualcompare-md5.sh --crop-x 8 --crop-y 5 --crop-w 232 --crop-h 58 heise.de` 63 | 64 | You will need to adapt the call to `pyvisualcompare-md5.sh` with the parameters you get from the frontend. 65 | 66 | ### Backend custom parameters 67 | 68 | The backend uses `wkhtmltoimage` for rendering web pages into images. 69 | In some use cases, you might want to slightly modify the command the `pyvisualcompare` frontend generates for you. 70 | Parameters for `wkhtmltoimage` can be added in the call to `pyvisualcompare-md5.sh` to change the default behavior. 71 | The full list of possible `wkhtmltoimage` parameters can be found [here](https://wkhtmltopdf.org/usage/wkhtmltopdf.txt). 72 | 73 | ## How exactly does it work? 74 | 75 | * A screenshot of the web page is rendered using [```wkhtmltoimage```](https://wkhtmltopdf.org/) 76 | * As most backend servers do not have a graphical interface, [```xvfb```](https://packages.debian.org/de/stable/xvfb) is used to imitate an X server. This is necessary due to ```wkhtmltoimage```. 77 | * The cropped screenshot is compared to the original screenshot with an MD5 hash - if there is any change in the image, the hash will be different 78 | * A change would also be detected if e.g. some content is added *above* the area of interest. If this is not acceptable, some other ```urlwatch``` filters might be more adequate. 79 | -------------------------------------------------------------------------------- /img/pyvisualcompare-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nspo/pyvisualcompare/875e01b27bfaa73bcd0c5eb80127dd60ef45b4f2/img/pyvisualcompare-select.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tempfile 3 | import subprocess 4 | import os 5 | 6 | try: 7 | from PyQt5.QtCore import Qt, QT_VERSION_STR, QDateTime, QCoreApplication, QRect, QThread, pyqtSignal, QProcess 8 | from PyQt5.QtGui import QImage, QIntValidator, QValidator 9 | from PyQt5.QtWidgets import QApplication, QFileDialog, QMainWindow, QDialog, QVBoxLayout, QDialogButtonBox, \ 10 | QDateTimeEdit, QTextEdit, QPlainTextEdit, QLineEdit, QLabel, QStyle, QCheckBox, QHBoxLayout, QGridLayout, \ 11 | QMessageBox, QAbstractButton, QWizard, QWizardPage, QComboBox 12 | except ImportError: 13 | raise ImportError("Requires PyQt5.") 14 | from QtImagePartSelector import QtImagePartSelector 15 | 16 | WKHTMLTOIMAGE = "wkhtmltoimage" 17 | XVFB = "xvfb-run" 18 | XVFB_BASE_PARAMETERS = ["-a", "-s", "-screen 0 640x480x16", WKHTMLTOIMAGE] 19 | 20 | try: 21 | subprocess.run(WKHTMLTOIMAGE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 22 | except FileNotFoundError: 23 | raise FileNotFoundError("wkhtmltopdf must be installed on system") 24 | 25 | try: 26 | subprocess.run(XVFB, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 27 | except FileNotFoundError: 28 | raise FileNotFoundError("xvfb must be installed on system") 29 | 30 | PATH_MD5_SCRIPT = "pyvisualcompare-md5.sh" 31 | 32 | 33 | class NotEmptyValidator(QValidator): 34 | def validate(self, text: str, pos): 35 | if bool(text.strip()): 36 | state = QValidator.Acceptable 37 | else: 38 | state = QValidator.Intermediate # so that field can be made empty temporarily 39 | return state, text, pos 40 | 41 | 42 | class UrlDialog(QDialog): 43 | def __init__(self, parent=None): 44 | super(UrlDialog, self).__init__(parent) 45 | self.setWindowTitle("Enter URL") 46 | 47 | vbox = QVBoxLayout(self) 48 | 49 | grid = QGridLayout() 50 | vbox.addLayout(grid) 51 | 52 | grid.addWidget(QLabel("URL of web page"), 0, 0) 53 | 54 | self.url_edit = QLineEdit(self) 55 | self.url_edit.setPlaceholderText("https://duckduckgo.com") 56 | self.url_edit.textChanged.connect(self.lineEditTextChanged) 57 | self.url_edit.setValidator(NotEmptyValidator()) 58 | self.url_edit.setToolTip("Must not be empty") 59 | grid.addWidget(self.url_edit, 0, 1) 60 | 61 | grid.addWidget(QLabel("Static size"), 1, 0) 62 | self.static_size_edit = QCheckBox() 63 | self.static_size_edit.stateChanged.connect(self.staticSizeChanged) 64 | grid.addWidget(self.static_size_edit, 1, 1) 65 | 66 | grid.addWidget(QLabel("Width of page"), 2, 0) 67 | self.width_edit = QLineEdit() 68 | self.width_edit.setText("1280") 69 | self.width_edit.setValidator(QIntValidator(640, 20000)) 70 | self.width_edit.setToolTip("Must be between 640 and 20000") 71 | self.width_edit.textChanged.connect(self.lineEditTextChanged) 72 | self.width_edit.setDisabled(True) 73 | grid.addWidget(self.width_edit, 2, 1) 74 | 75 | grid.addWidget(QLabel("Height of page"), 3, 0) 76 | self.height_edit = QLineEdit() 77 | self.height_edit.setText("1024") 78 | self.height_edit.setValidator(QIntValidator(480, 20000)) 79 | self.height_edit.setToolTip("Must be between 480 and 20000") 80 | self.height_edit.textChanged.connect(self.lineEditTextChanged) 81 | self.height_edit.setDisabled(True) 82 | grid.addWidget(self.height_edit, 3, 1) 83 | 84 | grid.addWidget(QLabel("Delay before screenshot [ms]"), 4, 0) 85 | self.delay_edit = QLineEdit() 86 | self.delay_edit.setText("350") 87 | self.delay_edit.setValidator(QIntValidator(200, 20000)) 88 | self.delay_edit.setToolTip("Must be between 200 and 20000") 89 | self.delay_edit.textChanged.connect(self.lineEditTextChanged) 90 | grid.addWidget(self.delay_edit, 4, 1) 91 | 92 | # OK and Cancel buttons 93 | self.buttons = QDialogButtonBox( 94 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, 95 | Qt.Horizontal, self) 96 | self.buttons.accepted.connect(self.accept) 97 | self.buttons.rejected.connect(self.reject) 98 | vbox.addWidget(self.buttons) 99 | 100 | # initially disable OK button b/c no URL has been entered yet 101 | self.buttons.buttons()[0].setDisabled(True) 102 | 103 | def staticSizeChanged(self, enable): 104 | self.width_edit.setDisabled(not enable) 105 | self.height_edit.setDisabled(not enable) 106 | 107 | if not enable: 108 | # reset default values of fields below so that invalid values do not keep the user from proceeding 109 | self.width_edit.setText("1280") 110 | self.height_edit.setText("1024") 111 | 112 | def lineEditTextChanged(self, *args, **kwargs): 113 | """ After user edits a QLineEdit, set a color according to the field's current validity """ 114 | sender = self.sender() 115 | state = self.getLineEditValidity(sender) 116 | if state == QValidator.Acceptable: 117 | sender.setStyleSheet("") # reset background to default 118 | else: 119 | color = '#f6989d' # red 120 | sender.setStyleSheet("QLineEdit {{ background-color: {} }}".format(color)) 121 | 122 | # check whether all fields are acceptable 123 | all_fields_valid = True 124 | for line_edit in [self.url_edit, self.width_edit, self.height_edit, self.delay_edit]: 125 | if not self.getLineEditValidity(line_edit) == QValidator.Acceptable: 126 | all_fields_valid = False 127 | break 128 | 129 | # disable OK button if not all fields are valid 130 | self.buttons.buttons()[0].setDisabled(not all_fields_valid) 131 | 132 | @staticmethod 133 | def getLineEditValidity(line_edit: QLineEdit) -> QValidator.State: 134 | validator = line_edit.validator() 135 | state = validator.validate(line_edit.text(), 0)[0] 136 | return state 137 | 138 | @staticmethod 139 | def getUrl(parent=None): 140 | dialog = UrlDialog(parent) 141 | result = dialog.exec_() 142 | 143 | resultdict = { 144 | "ok": result == QDialog.Accepted, 145 | "url": dialog.url_edit.text(), 146 | "static_size": dialog.static_size_edit.isChecked(), 147 | "height": dialog.height_edit.text(), 148 | "width": dialog.width_edit.text(), 149 | "delay": dialog.delay_edit.text() 150 | } 151 | 152 | return resultdict 153 | 154 | class LabelAndTextfieldPage(QWizardPage): 155 | def __init__(self, parent, labelstr, textfieldstr): 156 | super(LabelAndTextfieldPage, self).__init__(parent) 157 | 158 | layout = QVBoxLayout() 159 | label = QLabel( 160 | labelstr 161 | ) 162 | 163 | label.setWordWrap(True) 164 | layout.addWidget(label) 165 | 166 | textfield = QTextEdit() 167 | textfield.setReadOnly(True) 168 | textfield.setText(textfieldstr) 169 | layout.addWidget(textfield) 170 | 171 | self.setLayout(layout) 172 | 173 | 174 | class MagicWizard(QWizard): 175 | def __init__(self, parent, urlwatch_config): 176 | super(MagicWizard, self).__init__(parent) 177 | 178 | self.addPage(LabelAndTextfieldPage(self, 179 | "An area of interest was successfully selected. To automatically watch that " 180 | "area for changes, " 181 | "some further actions are necessary.\n" 182 | "\n" 183 | "It is recommended to run everything on a dedicated Linux server. " 184 | "This wizard will guide you through the setup process. If you are only " 185 | "interested in the urlwatch config, you can find that below.", 186 | urlwatch_config 187 | )) 188 | 189 | self.addPage(LabelAndTextfieldPage(self, 190 | "urlwatch, wkhtmltopdf and xvfb need to be installed on the server.\n" 191 | "urlwatch is necessary to watch out for changes and send notifications.\n" 192 | "wkhtmltopdf is a tool to render a web page into PDF or image files " 193 | "(similar to a browser).\n" 194 | "Lastly, xvfb is needed so that no complete X11 display server needs to " 195 | "be installed. \n\n" 196 | "On Ubuntu or Debian systems, these " 197 | "applications can be installed with the following command:", 198 | 199 | "$ sudo apt install urlwatch wkhtmltopdf xvfb" 200 | )) 201 | self.addPage(LabelAndTextfieldPage(self, 202 | "Login on your server as the user that is supposed to run urlwatch and " 203 | "send notifications.", 204 | 205 | "$ su urlwatcher")) 206 | 207 | self.addPage(LabelAndTextfieldPage(self, "Open the urlwatch config in your text editor, clear the " 208 | "example configuration if there is any and paste the " 209 | "configuration below.", 210 | 211 | "$ urlwatch - -edit\n\n" 212 | "" 213 | "{}".format(urlwatch_config))) 214 | 215 | self.addPage(LabelAndTextfieldPage(self, 216 | "Save the urlwatch config and set the correct " 217 | "file permissions for urlwatch:", 218 | 219 | "$ chmod 700 -R ~/.config/urlwatch/")) 220 | with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), PATH_MD5_SCRIPT), "r") as f: 221 | script_content = f.read() 222 | 223 | self.addPage(LabelAndTextfieldPage(self, 224 | "To simplify quite a lot of things, a simple script must be installed on " 225 | "the server. You should put it into the file " 226 | "/usr/local/bin/pyvisualcompare-md5.sh. Make sure to mark it as " 227 | "executable.", 228 | 229 | script_content)) 230 | 231 | self.addPage(LabelAndTextfieldPage(self, 232 | "Configure urlwatch to your liking, e.g. to send mail notifications. " 233 | "Details can be found in the urlwatch docs.", 234 | 235 | "https://github.com/thp/urlwatch" 236 | )) 237 | 238 | self.addPage(LabelAndTextfieldPage(self, 239 | "Nearly finished! You should probably add a cronjob to regularly call " 240 | "urlwatch. Note that a cronjob usually runs in a very restricted " 241 | "environment, so you should probably also add /usr/local/bin to your PATH " 242 | "variable and set your default shell to bash. " 243 | "An example for calling urlwatch every 10 minutes would be as follows:", 244 | 245 | "$ crontab -e\n\n" 246 | "" 247 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games\n" 248 | "SHELL=/bin/bash\n" 249 | "# m h dom mon dow command\n" 250 | "*/10 * * * * urlwatch")) 251 | 252 | self.addPage(LabelAndTextfieldPage(self, 253 | "Now we are done! On newer version of urlwatch (>= 2.13) you can test " 254 | "your urlwatch filter manually. It should always return the same " 255 | "MD5 hash - except when a change was made in the area of interest.\n\n" 256 | "If the area of interest gets moved because new content is added above, " 257 | "your filter will still notice a change. If that is not the desired " 258 | "behavior, you can have a look at the other urlwatch filters available.", 259 | 260 | "$ urlwatch --test-filter 1" 261 | )) 262 | 263 | self.setWindowTitle("Server setup wizard") 264 | self.resize(640, 480) 265 | 266 | 267 | class MyMainWindow(QMainWindow): 268 | 269 | def __init__(self): 270 | super(MyMainWindow, self).__init__() 271 | 272 | self.graphicsView = QtImagePartSelector() 273 | self.setCentralWidget(self.graphicsView) 274 | 275 | self.graphicsView.rectSet.connect(self.onRectSet) 276 | 277 | menubar = self.menuBar() 278 | 279 | file_menu = menubar.addMenu("File") 280 | load_url_action = file_menu.addAction("Load from URL") 281 | load_url_action.triggered.connect(self.getImage) 282 | 283 | close_action = file_menu.addAction("Quit") 284 | close_action.triggered.connect(self.close) 285 | 286 | self.confirm_area_action = menubar.addAction('Confirm area') 287 | self.confirm_area_action.setDisabled(True) 288 | self.confirm_area_action.triggered.connect(self.onConfirm) 289 | # self.confirm_area_action.setIcon(QApplication.style().standardIcon(QStyle.SP_DialogApplyButton)) 290 | 291 | self.statusBarWidget = QLabel() 292 | self.statusBar().addWidget(self.statusBarWidget) 293 | self.statusBarWidget.setText("Ready to load URL") 294 | 295 | self.tempdir = tempfile.mkdtemp() 296 | self.tempfilename = os.path.join(self.tempdir, "pyvisualcompare.png") 297 | 298 | self.selected_rectangle = None 299 | 300 | self.url_dict = None 301 | 302 | self.resize(500, 300) 303 | 304 | self.setWindowTitle('pyvisualcompare') 305 | 306 | def getUrlwatchConfig(self): 307 | s = "name: ExampleName\n" \ 308 | "kind: shell\n" \ 309 | "command: pyvisualcompare-md5.sh" 310 | 311 | s += " --crop-x {} --crop-y {} --crop-w {} --crop-h {} ".format( 312 | self.selected_rectangle.topLeft().x(), 313 | self.selected_rectangle.topLeft().y(), 314 | self.selected_rectangle.width(), 315 | self.selected_rectangle.height() 316 | ) 317 | 318 | s += " ".join(self.getWkhtmlParameters()) 319 | s += "\n---" 320 | 321 | return s 322 | 323 | def getWkhtmlParameters(self): 324 | # generate only parameters passed to wkhtmltoimage (except destination filename) to get full screenshot 325 | parameters = [] 326 | 327 | if self.url_dict["static_size"]: 328 | parameters.append("--height") 329 | parameters.append(self.url_dict["height"]) 330 | parameters.append("--width") 331 | parameters.append(self.url_dict["width"]) 332 | 333 | # add delay 334 | parameters.append("--javascript-delay") 335 | parameters.append(self.url_dict["delay"]) 336 | 337 | parameters.append(self.url_dict["url"]) 338 | 339 | return parameters 340 | 341 | def getXvfbParameters(self): 342 | # generate complete parameter set for xvfb call 343 | 344 | parameters = XVFB_BASE_PARAMETERS.copy() 345 | # append wkhtmltoimage parameters 346 | parameters += self.getWkhtmlParameters() 347 | parameters.append(self.tempfilename) 348 | 349 | return parameters 350 | 351 | def getImage(self): 352 | self.url_dict = UrlDialog.getUrl(self) 353 | 354 | if self.url_dict["ok"]: 355 | self.statusBarWidget.setText("Loading page...") 356 | self.graphicsView.clearImage() 357 | self.confirm_area_action.setDisabled(True) 358 | self.selected_rectangle = None 359 | 360 | self.process = QProcess() 361 | self.process.finished.connect(self.getImageCallback) 362 | 363 | self.process.start(XVFB, self.getXvfbParameters()) 364 | 365 | def getImageCallback(self, returncode): 366 | msg = QMessageBox(self) 367 | 368 | if returncode == 0: 369 | image = QImage(self.tempfilename) 370 | self.graphicsView.setImage(image) 371 | self.resize(min(image.width() + 64, 1400), min(image.height() + 64, 800)) 372 | self.statusBarWidget.setText("Drag or zoom with middle button and select area of interest. " 373 | "Afterwards confirm area in menu.") 374 | 375 | msg.setIcon(QMessageBox.Information) 376 | msg.setWindowTitle("Screenshot loaded") 377 | msg.setText("A screenshot was successfully loaded.") 378 | msg.setInformativeText("1. Drag or zoom the image with the middle button and mousewheel. \n" 379 | "2. Select area of interest by left-clicking and dragging. \n" 380 | "3. Finally, confirm the area.") 381 | 382 | else: 383 | self.statusBarWidget.setText("Error while loading page") 384 | 385 | msg.setIcon(QMessageBox.Warning) 386 | msg.setWindowTitle("Error while loading page") 387 | msg.setText("There was an error while loading the page.") 388 | msg.setInformativeText("The URL was possibly incorrect. " 389 | "Also, some pages cannot be loaded without specifying a static size - " 390 | "so you might want to try that.") 391 | msg.setDetailedText( 392 | "Standard output:\n\n{}\n\n--\nError output:\n\n{}".format( 393 | bytes(self.process.readAllStandardOutput()).decode(), 394 | bytes(self.process.readAllStandardError()).decode() 395 | )) 396 | 397 | msg.exec_() 398 | 399 | def onConfirm(self, event): 400 | wizard = MagicWizard(self, self.getUrlwatchConfig()) 401 | wizard.exec_() 402 | 403 | def onRectSet(self, r: QRect): 404 | """Rectangle has been selected""" 405 | self.selected_rectangle = r 406 | if self.graphicsView.hasImage() and r.width() > 1 and r.height() > 1: 407 | self.confirm_area_action.setDisabled(False) 408 | else: 409 | self.confirm_area_action.setDisabled(True) 410 | 411 | 412 | if __name__ == '__main__': 413 | app = QApplication(sys.argv) 414 | app.setQuitOnLastWindowClosed(True) 415 | 416 | window = MyMainWindow() 417 | window.show() 418 | # open URL dialog at start 419 | window.getImage() 420 | 421 | sys.exit(app.exec_()) 422 | -------------------------------------------------------------------------------- /pyvisualcompare-md5.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | IMGFILE=$(mktemp)".png" 3 | ERRFILE=$(mktemp)".log" 4 | 5 | xvfb-run -a -s "-screen 0 640x480x16" wkhtmltoimage "$@" "$IMGFILE" >"$ERRFILE" 2>&1 6 | 7 | RETCODE=$? 8 | if [ $RETCODE -ne 0 ]; then 9 | # command failed 10 | cat $ERRFILE 11 | exit $RETCODE 12 | fi 13 | 14 | md5sum < $IMGFILE 15 | 16 | # clean up 17 | rm $IMGFILE 2>/dev/null 18 | rm $ERRFILE 2>/dev/null 19 | 20 | # This script must be marked as executable and in urlwatch's PATH! Example: 21 | # sudo chmod +x /usr/local/bin/pyvisualcompare-md5.sh 22 | --------------------------------------------------------------------------------