From 9e08f326be0e45321cc41dbfbffd508bfef27301 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Thu, 12 Aug 2021 12:05:59 +0200 Subject: [PATCH] Chrome/Webdriver support for Javascript websites (#114) JS Support via fetching the page over WebDriver/Selenium network Refactor forms (Split into logical tabs) --- .github/workflows/image-javascript.yml | 87 ++++++++ .github/workflows/image.yml | 5 +- README.md | 19 +- backend/__init__.py | 19 +- backend/content_fetcher.py | 137 ++++++++++++ backend/fetch_site_status.py | 76 +++---- backend/forms.py | 43 +++- backend/static/images/Google-Chrome-icon.png | Bin 0 -> 14480 bytes backend/static/js/settings.js | 1 + backend/static/js/tabs.js | 51 +++++ backend/static/styles/diff.scss | 2 +- backend/static/styles/styles.css | 70 ++++-- backend/static/styles/styles.scss | 100 +++++++-- backend/store.py | 19 +- backend/templates/diff.html | 4 +- backend/templates/edit.html | 146 ++++++++----- backend/templates/import.html | 31 ++- backend/templates/login.html | 5 +- backend/templates/settings.html | 214 +++++++++++-------- backend/templates/watch-overview.html | 2 + backend/tests/conftest.py | 10 +- backend/tests/test_access_control.py | 14 +- backend/tests/test_backend.py | 2 +- backend/tests/test_css_selector.py | 6 +- backend/tests/test_headers.py | 1 + backend/tests/test_ignore_text.py | 8 +- backend/tests/test_jsonpath_selector.py | 17 +- backend/tests/test_notification.py | 12 +- backend/tests/test_watch_fields_storage.py | 7 +- backend/tests/util.py | 6 +- backend/update_worker.py | 30 ++- docker-compose.yml | 17 +- requirements.txt | 2 +- 33 files changed, 853 insertions(+), 310 deletions(-) create mode 100644 .github/workflows/image-javascript.yml create mode 100644 backend/content_fetcher.py create mode 100644 backend/static/images/Google-Chrome-icon.png create mode 100644 backend/static/js/tabs.js diff --git a/.github/workflows/image-javascript.yml b/.github/workflows/image-javascript.yml new file mode 100644 index 00000000..a6b4d8ee --- /dev/null +++ b/.github/workflows/image-javascript.yml @@ -0,0 +1,87 @@ +name: Javascript/Webdriver support - Test, build and push to Docker Hub :javascript tag + +on: + push: + branches: [ javascript-browser ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Create release metadata + run: | + # COPY'ed by Dockerfile into backend/ of the image, then read by the server in store.py + echo ${{ github.sha }} > backend/source.txt + echo ${{ github.ref }} > backend/tag.txt + + - name: Test with pytest + run: | + # Each test is totally isolated and performs its own cleanup/reset + cd backend; ./run_all_tests.sh + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + with: + image: tonistiigi/binfmt:latest + platforms: all + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + with: + install: true + version: latest + driver-opts: image=moby/buildkit:master + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./ + file: ./Dockerfile + push: true + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:javascript-dev + platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + + - name: Image digest + run: echo step SHA ${{ steps.vars.outputs.sha_short }} tag ${{steps.vars.outputs.tag}} branch ${{steps.vars.outputs.branch}} digest ${{ steps.docker_build.outputs.digest }} + +# failed: Cache service responded with 503 +# - name: Cache Docker layers +# uses: actions/cache@v2 +# with: +# path: /tmp/.buildx-cache +# key: ${{ runner.os }}-buildx-${{ github.sha }} +# restore-keys: | +# ${{ runner.os }}-buildx- + + diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 3150c60e..7abf493c 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -44,6 +44,7 @@ jobs: with: image: tonistiigi/binfmt:latest platforms: all + - name: Login to Docker Hub uses: docker/login-action@v1 with: @@ -66,10 +67,8 @@ jobs: file: ./Dockerfile push: true tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest - # ${{ secrets.DOCKER_HUB_USERNAME }}:/changedetection.io:${{ env.RELEASE_VERSION }} + ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 -# platforms: linux/amd64 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/README.md b/README.md index 601bb0c4..e9ca8dcc 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Know when ... _Need an actual Chrome runner with Javascript support? see the experimental Javascript/Chrome support changedetection.io branch!_ **Get monitoring now! super simple, one command!** + Run the python code on your own machine by cloning this repository, or with docker and/or docker-compose With one docker-compose command @@ -40,24 +41,18 @@ With one docker-compose command docker-compose up -d ``` -or +Then visit http://127.0.0.1:5000 , You should now be able to access the UI. + +_Now with per-site configurable support for using a fast built in HTTP fetcher or use a Chrome based fetcher for monitoring of JavaScript websites!_ -```bash -docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io -``` - -Now visit http://127.0.0.1:5000 , You should now be able to access the UI. - -#### Updating to latest version +#### Updating to the latest version Highly recommended :) ```bash docker pull dgtlmoon/changedetection.io -docker kill $(docker ps -a|grep changedetection.io|awk '{print $1}') -docker rm $(docker ps -a|grep changedetection.io|awk '{print $1}') -docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io +docker-compose up -d ``` ### Screenshots @@ -135,6 +130,7 @@ For more information see https://docs.python-requests.org/en/master/user/advance This proxy support also extends to the notifications https://github.com/caronc/apprise/issues/387#issuecomment-841718867 + ### Notes - ~~Does not yet support Javascript~~ @@ -143,6 +139,7 @@ This proxy support also extends to the notifications https://github.com/caronc/a See the experimental Javascript/Chrome browser support! + ### RaspberriPi support? RaspberriPi and linux/arm/v6 linux/arm/v7 arm64 devices are supported! diff --git a/backend/__init__.py b/backend/__init__.py index bee1bc13..91608993 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -378,6 +378,7 @@ def changedetection_app(config=None, datastore_o=None): if uuid == 'first': uuid = list(datastore.data['watching'].keys()).pop() + if request.method == 'GET': if not uuid in datastore.data['watching']: flash("No watch with the UUID %s found." % (uuid), "error") @@ -385,17 +386,25 @@ def changedetection_app(config=None, datastore_o=None): populate_form_from_watch(form, datastore.data['watching'][uuid]) + if datastore.data['watching'][uuid]['fetch_backend'] is None: + form.fetch_backend.data = datastore.data['settings']['application']['fetch_backend'] + if request.method == 'POST' and form.validate(): # Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default if form.minutes_between_check.data == datastore.data['settings']['requests']['minutes_between_check']: form.minutes_between_check.data = None + if form.fetch_backend.data == datastore.data['settings']['application']['fetch_backend']: + form.fetch_backend.data = None + + update_obj = {'url': form.url.data.strip(), 'minutes_between_check': form.minutes_between_check.data, 'tag': form.tag.data.strip(), 'title': form.title.data.strip(), - 'headers': form.headers.data + 'headers': form.headers.data, + 'fetch_backend': form.fetch_backend.data } # Notification URLs @@ -428,8 +437,8 @@ def changedetection_app(config=None, datastore_o=None): if form.trigger_check.data: n_object = {'watch_url': form.url.data.strip(), - 'notification_urls': form.notification_urls.data, - 'uuid': uuid} + 'notification_urls': form.notification_urls.data + } notification_q.put(n_object) flash('Notifications queued.') @@ -464,12 +473,15 @@ def changedetection_app(config=None, datastore_o=None): def settings_page(): from backend import forms + from backend import content_fetcher + form = forms.globalSettingsForm(request.form) if request.method == 'GET': form.minutes_between_check.data = int(datastore.data['settings']['requests']['minutes_between_check']) form.notification_urls.data = datastore.data['settings']['application']['notification_urls'] form.extract_title_as_title.data = datastore.data['settings']['application']['extract_title_as_title'] + form.fetch_backend.data = datastore.data['settings']['application']['fetch_backend'] form.notification_title.data = datastore.data['settings']['application']['notification_title'] form.notification_body.data = datastore.data['settings']['application']['notification_body'] @@ -486,6 +498,7 @@ def changedetection_app(config=None, datastore_o=None): datastore.data['settings']['application']['notification_urls'] = form.notification_urls.data datastore.data['settings']['requests']['minutes_between_check'] = form.minutes_between_check.data datastore.data['settings']['application']['extract_title_as_title'] = form.extract_title_as_title.data + datastore.data['settings']['application']['fetch_backend'] = form.fetch_backend.data datastore.data['settings']['application']['notification_title'] = form.notification_title.data datastore.data['settings']['application']['notification_body'] = form.notification_body.data diff --git a/backend/content_fetcher.py b/backend/content_fetcher.py new file mode 100644 index 00000000..40ff7327 --- /dev/null +++ b/backend/content_fetcher.py @@ -0,0 +1,137 @@ +import os +import time +from abc import ABC, abstractmethod +from selenium import webdriver +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from selenium.common.exceptions import WebDriverException +import urllib3.exceptions + + +class EmptyReply(Exception): + pass + +class Fetcher(): + error = None + status_code = None + content = None # Should be bytes? + + fetcher_description ="No description" + + @abstractmethod + def get_error(self): + return self.error + + @abstractmethod + def run(self, url, timeout, request_headers): + # Should set self.error, self.status_code and self.content + pass + + @abstractmethod + def get_last_status_code(self): + return self.status_code + + @abstractmethod + # Return true/false if this checker is ready to run, in the case it needs todo some special config check etc + def is_ready(self): + return True + +# Maybe for the future, each fetcher provides its own diff output, could be used for text, image +# the current one would return javascript output (as we use JS to generate the diff) +# +# Returns tuple(mime_type, stream) +# @abstractmethod +# def return_diff(self, stream_a, stream_b): +# return + +def available_fetchers(): + import inspect + from backend import content_fetcher + p=[] + for name, obj in inspect.getmembers(content_fetcher): + if inspect.isclass(obj): + # @todo html_ is maybe better as fetcher_ or something + # In this case, make sure to edit the default one in store.py and fetch_site_status.py + if "html_" in name: + t=tuple([name,obj.fetcher_description]) + p.append(t) + + return p + +class html_webdriver(Fetcher): + fetcher_description = "WebDriver Chrome/Javascript" + command_executor = '' + + def __init__(self): + self.command_executor = os.getenv("WEBDRIVER_URL",'http://browser-chrome:4444/wd/hub') + + def run(self, url, timeout, request_headers): + + # check env for WEBDRIVER_URL + driver = webdriver.Remote( + command_executor=self.command_executor, + desired_capabilities=DesiredCapabilities.CHROME) + + try: + driver.get(url) + except WebDriverException as e: + # Be sure we close the session window + driver.quit() + raise + + # @todo - how to check this? is it possible? + self.status_code = 200 + + # @todo - dom wait loaded? + time.sleep(5) + self.content = driver.page_source + + driver.quit() + + + def is_ready(self): + from selenium import webdriver + from selenium.webdriver.common.desired_capabilities import DesiredCapabilities + from selenium.common.exceptions import WebDriverException + + driver = webdriver.Remote( + command_executor='http://browser-chrome:4444/wd/hub', + desired_capabilities=DesiredCapabilities.CHROME) + + # driver.quit() seems to cause better exceptions + driver.quit() + + + return True + +# "html_requests" is listed as the default fetcher in store.py! +class html_requests(Fetcher): + fetcher_description = "Basic fast Plaintext/HTTP Client" + + def run(self, url, timeout, request_headers): + import requests + try: + r = requests.get(url, + headers=request_headers, + timeout=timeout, + verify=False) + + html = r.text + + # Usually from networkIO/requests level + except ( + requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout, + requests.exceptions.MissingSchema) as e: + self.error = str(e) + return None + + except Exception as e: + self.error = "Other exception" + str(e) + return None + + # @todo test this + if not r or not html or not len(html): + raise EmptyReply(url) + + self.status_code = r.status_code + self.content = html + diff --git a/backend/fetch_site_status.py b/backend/fetch_site_status.py index 870a6515..242a46b1 100644 --- a/backend/fetch_site_status.py +++ b/backend/fetch_site_status.py @@ -1,11 +1,13 @@ import time -import requests +from backend import content_fetcher import hashlib from inscriptis import get_text import urllib3 from . import html_tools urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +from selenium import webdriver +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities # Some common stuff here that can be moved to a base class @@ -52,8 +54,8 @@ class perform_site_check(): def run(self, uuid): timestamp = int(time.time()) # used for storage etc too - stripped_text_from_html = False changed_detected = False + stripped_text_from_html = "" update_obj = {'previous_md5': self.datastore.data['watching'][uuid]['previous_md5'], 'history': {}, @@ -72,71 +74,63 @@ class perform_site_check(): if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') - try: - timeout = self.datastore.data['settings']['requests']['timeout'] - except KeyError: - # @todo yeah this should go back to the default value in store.py, but this whole object should abstract off it - timeout = 15 + # @todo check the failures are really handled how we expect - try: + else: + timeout = self.datastore.data['settings']['requests']['timeout'] url = self.datastore.get_val(uuid, 'url') - r = requests.get(url, - headers=request_headers, - timeout=timeout, - verify=False) + # Pluggable content fetcher + prefer_backend = self.datastore.data['watching'][uuid]['fetch_backend'] + if hasattr(content_fetcher, prefer_backend): + klass = getattr(content_fetcher, prefer_backend) + else: + # If the klass doesnt exist, just use a default + klass = getattr(content_fetcher, "html_requests") - html = r.text + + fetcher = klass() + fetcher.run(url, timeout, request_headers) + # Fetching complete, now filters + # @todo move to class / maybe inside of fetcher abstract base? is_html = True css_filter_rule = self.datastore.data['watching'][uuid]['css_filter'] if css_filter_rule and len(css_filter_rule.strip()): if 'json:' in css_filter_rule: - stripped_text_from_html = html_tools.extract_json_as_string(html, css_filter_rule) + stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, jsonpath_filter=css_filter_rule) is_html = False else: # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text - html = html_tools.css_filter(css_filter=css_filter_rule, html_content=r.content) + stripped_text_from_html = html_tools.css_filter(css_filter=css_filter_rule, html_content=fetcher.content) if is_html: - stripped_text_from_html = get_text(html) + # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text + html_content = fetcher.content + css_filter_rule = self.datastore.data['watching'][uuid]['css_filter'] + if css_filter_rule and len(css_filter_rule.strip()): + html_content = html_tools.css_filter(css_filter=css_filter_rule, html_content=fetcher.content) - # Usually from networkIO/requests level - except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) as e: - update_obj["last_error"] = str(e) - print(str(e)) + # get_text() via inscriptis + stripped_text_from_html = get_text(html_content) - except requests.exceptions.MissingSchema: - print("Skipping {} due to missing schema/bad url".format(uuid)) - - # Usually from html2text level - except Exception as e: - # except UnicodeDecodeError as e: - update_obj["last_error"] = str(e) - print(str(e)) - # figure out how to deal with this cleaner.. - # 'utf-8' codec can't decode byte 0xe9 in position 480: invalid continuation byte - - - else: # We rely on the actual text in the html output.. many sites have random script vars etc, # in the future we'll implement other mechanisms. - update_obj["last_check_status"] = r.status_code + update_obj["last_check_status"] = fetcher.get_last_status_code() update_obj["last_error"] = False - if not len(r.text): - update_obj["last_error"] = "Empty reply" # If there's text to skip # @todo we could abstract out the get_text() to handle this cleaner if len(self.datastore.data['watching'][uuid]['ignore_text']): - content = self.strip_ignore_text(stripped_text_from_html, + stripped_text_from_html = self.strip_ignore_text(stripped_text_from_html, self.datastore.data['watching'][uuid]['ignore_text']) else: - content = stripped_text_from_html.encode('utf8') + stripped_text_from_html = stripped_text_from_html.encode('utf8') - fetched_md5 = hashlib.md5(content).hexdigest() + + fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest() # could be None or False depending on JSON type if self.datastore.data['watching'][uuid]['previous_md5'] != fetched_md5: @@ -149,9 +143,9 @@ class perform_site_check(): update_obj["previous_md5"] = fetched_md5 # Extract title as title - if self.datastore.data['settings']['application']['extract_title_as_title']: + if is_html and self.datastore.data['settings']['application']['extract_title_as_title']: if not self.datastore.data['watching'][uuid]['title'] or not len(self.datastore.data['watching'][uuid]['title']): - update_obj['title'] = html_tools.extract_element(find='title', html_content=html) + update_obj['title'] = html_tools.extract_element(find='title', html_content=fetcher.content) return changed_detected, update_obj, stripped_text_from_html diff --git a/backend/forms.py b/backend/forms.py index 3abb2e55..28d74224 100644 --- a/backend/forms.py +++ b/backend/forms.py @@ -1,9 +1,9 @@ -from wtforms import Form, BooleanField, StringField, PasswordField, validators, IntegerField, fields, TextAreaField, \ +from wtforms import Form, SelectField, RadioField, BooleanField, StringField, PasswordField, validators, IntegerField, fields, TextAreaField, \ Field from wtforms import widgets from wtforms.validators import ValidationError from wtforms.fields import html5 - +from backend import content_fetcher class StringListField(StringField): widget = widgets.TextArea() @@ -82,6 +82,40 @@ class StringDictKeyValue(StringField): else: self.data = {} +class ValidateContentFetcherIsReady(object): + """ + Validates that anything that looks like a regex passes as a regex + """ + def __init__(self, message=None): + self.message = message + + def __call__(self, form, field): + from backend import content_fetcher + import urllib3.exceptions + + # Better would be a radiohandler that keeps a reference to each class + if field.data is not None: + klass = getattr(content_fetcher, field.data) + some_object = klass() + try: + ready = some_object.is_ready() + + except urllib3.exceptions.MaxRetryError as e: + driver_url = some_object.command_executor + message = field.gettext('Content fetcher \'%s\' did not respond.' % (field.data)) + message += '
'+field.gettext('Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.') + message += '
' + field.gettext('Did you follow the instructions in the wiki?') + message += '

' + field.gettext('WebDriver Host: %s' % (driver_url)) + message += '
Go here for more information' + + raise ValidationError(message) + + except Exception as e: + message = field.gettext('Content fetcher \'%s\' did not respond properly, unable to use it.\n %s') + raise ValidationError(message % (field.data, e)) + + + class ValidateListRegex(object): """ Validates that anything that looks like a regex passes as a regex @@ -138,6 +172,8 @@ class watchForm(quickWatchForm): css_filter = StringField('CSS/JSON Filter', [ValidateCSSJSONInput()]) title = StringField('Title') + fetch_backend = RadioField(u'Fetch Method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) + ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) notification_urls = StringListField('Notification URL List') headers = StringDictKeyValue('Request Headers') @@ -152,6 +188,9 @@ class globalSettingsForm(Form): [validators.NumberRange(min=1)]) notification_urls = StringListField('Notification URL List') + + fetch_backend = RadioField(u'Fetch Method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) + extract_title_as_title = BooleanField('Extract from document and use as watch title') trigger_check = BooleanField('Send test notification on save') diff --git a/backend/static/images/Google-Chrome-icon.png b/backend/static/images/Google-Chrome-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a4dd10f5aa6e4753ac6fb723fa3fa006624e5c4d GIT binary patch literal 14480 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 literal 0 HcmV?d00001 diff --git a/backend/static/js/settings.js b/backend/static/js/settings.js index c6eba772..27b07dcc 100644 --- a/backend/static/js/settings.js +++ b/backend/static/js/settings.js @@ -14,3 +14,4 @@ window.addEventListener("load", (event) => { toggleVisible("notification-customisation"); }; }); + diff --git a/backend/static/js/tabs.js b/backend/static/js/tabs.js new file mode 100644 index 00000000..36fecf70 --- /dev/null +++ b/backend/static/js/tabs.js @@ -0,0 +1,51 @@ +// Rewrite this is a plugin.. is all this JS really 'worth it?' + + +window.addEventListener('hashchange', function() { + var tabs = document.getElementsByClassName('active'); + while (tabs[0]) { + tabs[0].classList.remove('active') + } + set_active_tab(); +}, false); + +var has_errors=document.querySelectorAll(".messages .error"); +if (!has_errors.length) { + if (document.location.hash == "" ) { + document.location.hash = "#general"; + document.getElementById("default-tab").className = "active"; + } else { + set_active_tab(); + } +} else { + focus_error_tab(); +} + + +function set_active_tab() { + var tab=document.querySelectorAll("a[href='"+location.hash+"']"); + if (tab.length) { + tab[0].parentElement.className="active"; + } + // hash could move the page down + window.scrollTo(0, 0); +} + +function focus_error_tab() { + // time to use jquery or vuejs really, + // activate the tab with the error + var tabs = document.querySelectorAll('.tabs li a'),i; + for (i = 0; i < tabs.length; ++i) { + var tab_name=tabs[i].hash.replace('#',''); + var pane_errors=document.querySelectorAll('#'+tab_name+' .error') + if (pane_errors.length) { + document.location.hash = '#'+tab_name; + return true; + } + } + return false; +} + + + + diff --git a/backend/static/styles/diff.scss b/backend/static/styles/diff.scss index 7f08a5fa..98e5f0b5 100644 --- a/backend/static/styles/diff.scss +++ b/backend/static/styles/diff.scss @@ -65,4 +65,4 @@ ins { body { height: 99%; /* Hide scroll bar in Firefox */ } -} \ No newline at end of file +} diff --git a/backend/static/styles/styles.css b/backend/static/styles/styles.css index 60285d35..f59ccd39 100644 --- a/backend/static/styles/styles.css +++ b/backend/static/styles/styles.css @@ -129,13 +129,6 @@ body:after, body:before { max-width: 400px; display: block; } -.edit-form { - background: #fff; - padding: 2em; - margin: 1em; - border-radius: 5px; - min-width: 70%; } - .button-secondary { color: white; border-radius: 4px; @@ -221,15 +214,14 @@ body:after, body:before { border-top-right-radius: 5px; border-bottom-right-radius: 5px; box-shadow: 5px 0 5px -2px #888; } - -#diff-jump a { - color: #1b98f8; - cursor: grabbing; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; - -o-user-select: none; } + #diff-jump a { + color: #1b98f8; + cursor: grabbing; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + -o-user-select: none; } footer { padding: 10px; @@ -299,6 +291,11 @@ footer { font-weight: bold; } .pure-form textarea { width: 100%; } + .pure-form ul#fetch_backend { + margin: 0px; + list-style: none; } + .pure-form ul#fetch_backend > li > * { + display: inline-block; } @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { .box { @@ -363,3 +360,44 @@ and also iPads specifically. /* m-d is medium-desktop */ .m-d { min-width: 80%; } } + +.tabs ul { + margin: 0px; + padding: 0px; + display: block; } + .tabs ul li { + margin-right: 3px; + display: inline-block; + color: #fff; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background-color: rgba(255, 255, 255, 0.2); } + .tabs ul li.active, .tabs ul li :target { + background-color: #fff; } + .tabs ul li.active a, .tabs ul li :target a { + color: #222; + font-weight: bold; } + .tabs ul li a { + display: block; + padding: 0.8em; + color: #fff; } + +.pure-form-stacked > div:first-child { + display: block; } + +.edit-form { + min-width: 70%; } + .edit-form .tab-pane-inner { + padding: 0px; } + .edit-form .tab-pane-inner:not(:target) { + display: none; } + .edit-form .tab-pane-inner:target { + display: block; } + .edit-form .box-wrap { + position: relative; } + .edit-form .inner { + background: #fff; + padding: 20px; } + .edit-form #actions { + display: block; + background: #fff; } diff --git a/backend/static/styles/styles.scss b/backend/static/styles/styles.scss index 559f1311..4a4b3b83 100644 --- a/backend/static/styles/styles.scss +++ b/backend/static/styles/styles.scss @@ -7,7 +7,6 @@ body { color: #333; background: #262626; } - .pure-table-even { background: #fff; } @@ -170,13 +169,6 @@ body:after, body:before { display: block; } -.edit-form { - background: #fff; - padding: 2em; - margin: 1em; - border-radius: 5px; - min-width: 70%; -} .button-secondary { color: white; @@ -294,16 +286,15 @@ body:after, body:before { border-top-right-radius: 5px; border-bottom-right-radius: 5px; box-shadow: 5px 0 5px -2px #888; -} - -#diff-jump a { - color: #1b98f8; - cursor: grabbing; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; - -o-user-select: none; + a { + color: #1b98f8; + cursor: grabbing; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + -o-user-select: none; + } } footer { @@ -404,6 +395,15 @@ footer { textarea { width: 100%; } + ul#fetch_backend { + margin: 0px; + list-style: none; + > li { + > * { + display: inline-block; + } + } + } } @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { @@ -417,7 +417,6 @@ footer { #nav-menu { overflow-x: scroll; } - } /* @@ -425,6 +424,7 @@ Max width before this PARTICULAR table gets nasty This query will take effect for any screen smaller than 760px and also iPads specifically. */ + @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { input[type='text'] { @@ -507,3 +507,65 @@ and also iPads specifically. } } + + +.tabs { + ul { + margin: 0px; + padding: 0px; + display:block; + li { + margin-right: 3px; + display: inline-block; + color: #fff; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background-color: rgba(255, 255, 255, 0.2); + + &.active,:target { + background-color: #fff; + a { + color: #222; + font-weight: bold; + } + } + a { + display: block; + padding: 0.8em; + color: #fff; + } + } + } +} + +$form-edge-padding: 20px; +.pure-form-stacked { + >div:first-child { + display: block; + } +} +.edit-form { + min-width: 70%; + .tab-pane-inner { + &:not(:target) { + display: none; + } + &:target { + display: block; + } + // doesnt need padding because theres another row of buttons/activity + padding: 0px; + } + .box-wrap { + position: relative; + } + .inner { + background: #fff;; + padding: $form-edge-padding; + } + #actions { + display: block; + background: #fff; + } +} + diff --git a/backend/store.py b/backend/store.py index a345bfd4..2d3f7abf 100644 --- a/backend/store.py +++ b/backend/store.py @@ -39,6 +39,7 @@ class ChangeDetectionStore: 'application': { 'password': False, 'extract_title_as_title': False, + 'fetch_backend': 'html_requests', 'notification_urls': [], # Apprise URL list # Custom notification content 'notification_title': 'ChangeDetection.io Notification - {watch_url}', @@ -67,6 +68,7 @@ class ChangeDetectionStore: 'ignore_text': [], # List of text to ignore when calculating the comparison checksum 'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise) 'css_filter': "", + 'fetch_backend': None, } if path.isfile('backend/source.txt'): @@ -193,6 +195,10 @@ class ChangeDetectionStore: if not self.__data['watching'][uuid]['title']: self.__data['watching'][uuid]['title'] = None + # Default var for fetch_backend + if not self.__data['watching'][uuid]['fetch_backend']: + self.__data['watching'][uuid]['fetch_backend'] = self.__data['settings']['application']['fetch_backend'] + self.__data['has_unviewed'] = has_unviewed return self.__data @@ -315,18 +321,15 @@ class ChangeDetectionStore: # Save some text file to the appropriate path and bump the history # result_obj from fetch_site_status.run() - def save_history_text(self, uuid, result_obj, contents): + def save_history_text(self, watch_uuid, contents): + import uuid - output_path = "{}/{}".format(self.datastore_path, uuid) - fname = "{}/{}-{}.stripped.txt".format(output_path, result_obj['previous_md5'], str(time.time())) - with open(fname, 'w') as f: + output_path = "{}/{}".format(self.datastore_path, watch_uuid) + fname = "{}/{}.stripped.txt".format(output_path, uuid.uuid4()) + with open(fname, 'wb') as f: f.write(contents) f.close() - # Update history with the stripped text for future reference, this will also mean we save the first - # Should always be keyed by string(timestamp) - self.update_watch(uuid, {"history": {str(result_obj["last_checked"]): fname}}) - return fname def sync_to_json(self): diff --git a/backend/templates/diff.html b/backend/templates/diff.html index c3969ad6..bc88a290 100644 --- a/backend/templates/diff.html +++ b/backend/templates/diff.html @@ -52,7 +52,9 @@ </div> -<script src="/static/js/diff.js"></script> + +<script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff.js')}}"></script> + <script defer=""> diff --git a/backend/templates/edit.html b/backend/templates/edit.html index 77ee233f..54efce2e 100644 --- a/backend/templates/edit.html +++ b/backend/templates/edit.html @@ -1,80 +1,112 @@ {% extends 'base.html' %} {% block content %} {% from '_helpers.jinja' import render_field %} +<script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> + <div class="edit-form monospaced-textarea"> - <form class="pure-form pure-form-stacked" action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next') ) }}" method="POST"> - <fieldset> - <div class="pure-control-group"> - {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} + + <div class="tabs"> + <ul> + <li class="tab" id="default-tab"><a href="#general">General</a></li> + <li class="tab"><a href="#notifications">Notifications</a></li> + <li class="tab"><a href="#filters">Filters</a></li> + </ul> + </div> + + <div class="box-wrap inner"> + <form class="pure-form pure-form-stacked" + action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next') ) }}" method="POST"> + + <div class="tab-pane-inner" id="general"> + <fieldset> + <div class="pure-control-group"> + {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} + </div> + <div class="pure-control-group"> + {{ render_field(form.title, class="m-d") }} + </div> + <div class="pure-control-group"> + {{ render_field(form.tag) }} + </div> + <div class="pure-control-group"> + {{ render_field(form.minutes_between_check) }} + {% if using_default_minutes %} + <span class="pure-form-message-inline">Currently using the <a + href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>, change to another value if you want to be specific.</span> + {% else %} + <span class="pure-form-message-inline">Set to blank to use the <a + href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>.</span> + {% endif %} + </div> + <fieldset class="pure-group"> + {{ render_field(form.headers, rows=5, placeholder="Example + Cookie: foobar + User-Agent: wonderbra 1.0") }} + <span class="pure-form-message-inline"> + Note: ONLY used by Basic fast Plaintext/HTTP Client + </span> + </fieldset> + </fieldset> </div> - <div class="pure-control-group"> - {{ render_field(form.title, class="m-d") }} + <div class="tab-pane-inner" id="notifications"> + <fieldset> + <div class="pure-control-group"> + {{ render_field(form.notification_urls, rows=5, placeholder="Examples: + Gitter - gitter://token/room + Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail + AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo + SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com + ") }} + <span class="pure-form-message-inline">Use <a target=_new + href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service!</span> + <span class="pure-form-message-inline">Note: This overrides any global settings notification URLs</span> + </div> + + <div class="pure-controls"> + {{ render_field(form.trigger_check, rows=5) }} + </div> + </fieldset> </div> - <div class="pure-control-group"> - {{ render_field(form.tag) }} - </div> - <div class="pure-control-group"> - {{ render_field(form.minutes_between_check) }} - {% if using_default_minutes %} - <span class="pure-form-message-inline">Currently using the <a href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>, change to another value if you want to be specific.</span> - {% else %} - <span class="pure-form-message-inline">Set to blank to use the <a href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>.</span> - {% endif %} - </div> - <div class="pure-control-group"> - {{ render_field(form.css_filter, placeholder=".class-name or #some-id, or other CSS selector rule.", class="m-d") }} - <span class="pure-form-message-inline"> + <div class="tab-pane-inner" id="filters"> + <fieldset> + <div class="pure-control-group"> + {{ render_field(form.css_filter, placeholder=".class-name or #some-id, or other CSS selector rule.", + class="m-d") }} + <span class="pure-form-message-inline"> <ul> <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> - <li>JSON - Limit text to this JSON rule, using <a href="https://pypi.org/project/jsonpath-ng/">JSONPath</a>, prefix with <b>"json:"</b>, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a></li> + <li>JSON - Limit text to this JSON rule, using <a href="https://pypi.org/project/jsonpath-ng/">JSONPath</a>, prefix with <b>"json:"</b>, <a + href="https://jsonpath.com/" target="new">test your JSONPath here</a></li> </ul> - Please be sure that you thoroughly understand how to write CSS or JSONPath selector rules before filing an issue on GitHub! <a href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/> + Please be sure that you thoroughly understand how to write CSS or JSONPath selector rules before filing an issue on GitHub! <a + href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/> </span> - </div> - <!-- @todo: move to tabs ---> - <fieldset class="pure-group"> - {{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line -/some.regex\d{2}/ for case-INsensitive regex -") }} - <span class="pure-form-message-inline"> + </div> + + </fieldset> + <fieldset class="pure-group"> + {{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line +/some.regex\d{2}/ for case-INsensitive regex + ") }} + <span class="pure-form-message-inline"> Each line processed separately, any line matching will be ignored.<br/> Regular Expression support, wrap the line in forward slash <b>/regex/</b>. </span> </fieldset> - <fieldset class="pure-group"> - {{ render_field(form.headers, rows=5, placeholder="Example -Cookie: foobar -User-Agent: wonderbra 1.0") }} - </fieldset> - <div class="pure-control-group"> - {{ render_field(form.notification_urls, rows=5, placeholder="Examples: -Gitter - gitter://token/room -Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail -AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo -SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com -") }} - <span class="pure-form-message-inline">Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service!</span> - <span class="pure-form-message-inline">Note: This overrides any global settings notification URLs</span> </div> + <div id="actions"> + <div class="pure-control-group"> - <div class="pure-controls"> - {{ render_field(form.trigger_check, rows=5) }} + <button type="submit" class="pure-button pure-button-primary">Save</button> + <a href="{{url_for('api_delete', uuid=uuid)}}" + class="pure-button button-small button-error ">Delete</a> + </div> </div> - <div class="pure-control-group"> - <button type="submit" class="pure-button pure-button-primary">Save</button> - </div> - <br/> - <div class="pure-control-group"> - <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Cancel</a> - <a href="{{url_for('api_delete', uuid=uuid)}}" - class="pure-button button-small button-error ">Delete</a> - </div> - </fieldset> - </form> - + </form> + </div> </div> {% endblock %} diff --git a/backend/templates/import.html b/backend/templates/import.html index cd7012dc..77bd9b40 100644 --- a/backend/templates/import.html +++ b/backend/templates/import.html @@ -2,24 +2,21 @@ {% block content %} <div class="edit-form"> + <div class="inner"> + <form class="pure-form pure-form-aligned" action="{{url_for('import_page')}}" method="POST"> + <fieldset class="pure-group"> + <legend>One URL per line, URLs that do not pass validation will stay in the textarea.</legend> - - <form class="pure-form pure-form-aligned" action="{{url_for('import_page')}}" method="POST"> - - <fieldset class="pure-group"> - <legend>One URL per line, URLs that do not pass validation will stay in the textarea.</legend> - - <textarea name="urls" class="pure-input-1-2" placeholder="https://" - style="width: 100%; - font-family:monospace; - white-space: pre; - overflow-wrap: normal; - overflow-x: scroll;" rows="25">{{ remaining }}</textarea> - </fieldset> - <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button> - - </form> - + <textarea name="urls" class="pure-input-1-2" placeholder="https://" + style="width: 100%; + font-family:monospace; + white-space: pre; + overflow-wrap: normal; + overflow-x: scroll;" rows="25">{{ remaining }}</textarea> + </fieldset> + <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button> + </form> + </div> </div> {% endblock %} diff --git a/backend/templates/login.html b/backend/templates/login.html index 8cc798b1..6bcbdbd3 100644 --- a/backend/templates/login.html +++ b/backend/templates/login.html @@ -2,6 +2,8 @@ {% block content %} <div class="edit-form"> + + <div class="inner"> <form class="pure-form pure-form-stacked" action="{{url_for('login')}}" method="POST"> <fieldset> <div class="pure-control-group"> @@ -15,6 +17,7 @@ </div> </fieldset> </form> -</div> + </div> + </div> {% endblock %} diff --git a/backend/templates/settings.html b/backend/templates/settings.html index 1751df17..941760a4 100644 --- a/backend/templates/settings.html +++ b/backend/templates/settings.html @@ -2,107 +2,137 @@ {% block content %} {% from '_helpers.jinja' import render_field %} -<script type="text/javascript" src="static/js/settings.js"></script> + +<script type="text/javascript" src="{{url_for('static_content', group='js', filename='settings.js')}}" defer></script> +<script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> + <div class="edit-form"> - <form class="pure-form pure-form-stacked settings" action="{{url_for('settings_page')}}" method="POST"> - <fieldset> - <div class="pure-control-group"> - {{ render_field(form.minutes_between_check) }} - <span class="pure-form-message-inline">Default time for all watches, when the watch does not have a specific time setting.</span> + <div class="tabs"> + <ul> + <li class="tab" id="default-tab"><a href="#general">General</a></li> + <li class="tab"><a href="#notifications">Notifications</a></li> + <li class="tab"><a href="#fetching">Fetching</a></li> + </ul> + </div> + <div class="box-wrap inner"> + <form class="pure-form pure-form-stacked settings" action="{{url_for('settings_page')}}" method="POST"> + <div class="tab-pane-inner" id="general"> + <fieldset> + <div class="pure-control-group"> + {{ render_field(form.minutes_between_check) }} + <span class="pure-form-message-inline">Default time for all watches, when the watch does not have a specific time setting.</span> + </div> + <div class="pure-control-group"> + {% if current_user.is_authenticated %} + <a href="{{url_for('settings_page', removepassword='yes')}}" + class="pure-button pure-button-primary">Remove password</a> + {% else %} + {{ render_field(form.password) }} + <span class="pure-form-message-inline">Password protection for your changedetection.io application.</span> + {% endif %} + </div> + <div class="pure-control-group"> + {{ render_field(form.extract_title_as_title) }} + <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> + </div> + </fieldset> </div> - <div class="pure-control-group"> - {% if current_user.is_authenticated %} - <a href="{{url_for('settings_page', removepassword='yes')}}" class="pure-button pure-button-primary">Remove password</a> - {% else %} - {{ render_field(form.password) }} - <span class="pure-form-message-inline">Password protection for your changedetection.io application.</span> - {% endif %} - </div> - <div class="pure-control-group"> - {{ render_field(form.extract_title_as_title) }} - <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> - </div> - - <div class="field-group"> - - <div class="pure-control-group"> - {{ render_field(form.notification_urls, rows=5, placeholder="Examples: -Gitter - gitter://token/room -Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail -AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo -SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com") }} - <div class="pure-form-message-inline">Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! - <a id="toggle-customise-notifications">Customise notification body: <i - class="arrow down"></i></a> + <div class="tab-pane-inner" id="notifications"> + <fieldset> + <div class="field-group"> + <div class="pure-control-group"> + {{ render_field(form.notification_urls, rows=5, placeholder="Examples: + Gitter - gitter://token/room + Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail + AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo + SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com") + }} + <div class="pure-form-message-inline">Use <a target=_new + href="https://github.com/caronc/apprise">AppRise + URLs</a> for notification to just about any service! + <a id="toggle-customise-notifications">Customise notification body: <i + class="arrow down"></i></a> + </div> </div> - </div> - <div id="notification-customisation" style="display:none;"> + <div id="notification-customisation" style="display:none;"> - <div class="pure-control-group"> - {{ render_field(form.notification_title, class="m-d") }} - <span class="pure-form-message-inline">Title for all notifications</span> + <div class="pure-control-group"> + {{ render_field(form.notification_title, class="m-d") }} + <span class="pure-form-message-inline">Title for all notifications</span> + </div> + <div class="pure-control-group"> + {{ render_field(form.notification_body , rows=5) }} + <span class="pure-form-message-inline">Body for all notifications</span> + </div> + <div class="pure-controls"> + <span class="pure-form-message-inline"> + These tokens can be used in the notification body and title to + customise the notification text. + </span> + <table class="pure-table" id="token-table"> + <thead> + <tr> + <th>Token</th> + <th>Description</th> + </tr> + </thead> + <tbody> + <tr> + <td><code>{base_url}</code></td> + <td>The URL of the changedetection.io instance you are running.</td> + </tr> + <tr> + <td><code>{watch_url}</code></td> + <td>The URL being watched.</td> + </tr> + <tr> + <td><code>{preview_url}</code></td> + <td>The URL of the preview page generated by changedetection.io.</td> + </tr> + <tr> + <td><code>{diff_url}</code></td> + <td>The URL of the diff page generated by changedetection.io.</td> + </tr> + <tr> + <td><code>{current_snapshot}</code></td> + <td>The current snapshot value, useful when combined with JSON or CSS filters + </td> + </tr> + </tbody> + </table> + <span class="pure-form-message-inline"> + URLs generated by changedetection.io (such as <code>{diff_url}</code>) require the <code>BASE_URL</code> environment variable set.<br/> + Your <code>BASE_URL</code> var is currently "{{base_url}}" + </span> + </div> + </div> + <div class="pure-control-group"> + {{ render_field(form.trigger_check) }} + </div> </div> - <div class="pure-control-group"> - {{ render_field(form.notification_body , rows=5) }} - <span class="pure-form-message-inline">Body for all notifications</span> - </div> - <div class="pure-controls"> - <span class="pure-form-message-inline"> - These tokens can be used in the notification body and title to - customise the notification text. - </span> - <table class="pure-table" id="token-table"> - <thead> - <tr> - <th>Token</th> - <th>Description</th> - </tr> - </thead> - <tbody> - <tr> - <td><code>{base_url}</code></td> - <td>The URL of the changedetection.io instance you are running.</td> - </tr> - <tr> - <td><code>{watch_url}</code></td> - <td>The URL being watched.</td> - </tr> - <tr> - <td><code>{preview_url}</code></td> - <td>The URL of the preview page generated by changedetection.io.</td> - </tr> - <tr> - <td><code>{diff_url}</code></td> - <td>The URL of the diff page generated by changedetection.io.</td> - </tr> - <tr> - <td><code>{current_snapshot}</code></td> - <td>The current snapshot value, useful when combined with JSON or CSS filters</td> - </tr> - </tbody> - </table> - <span class="pure-form-message-inline"> - URLs generated by changedetection.io (such as <code>{diff_url}</code>) require the <code>BASE_URL</code> environment variable set.<br/> - Your <code>BASE_URL</code> var is currently "{{base_url}}" - </span> - </div> - </div> + + </fieldset> + </div> + <div class="tab-pane-inner" id="fetching"> <div class="pure-control-group"> - {{ render_field(form.trigger_check) }} + {{ render_field(form.fetch_backend) }} + <span class="pure-form-message-inline"> + <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> + <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server. </p> + </span> </div> </div> - <div class="pure-control-group"> - <button type="submit" class="pure-button pure-button-primary">Save</button> + <div id="actions"> + <div class="pure-control-group"> + <button type="submit" class="pure-button pure-button-primary">Save</button> + <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> + <a href="{{url_for('scrub_page')}}" class="pure-button button-small button-cancel">Delete + History + Snapshot Data</a> + </div> </div> - <br/> - <div class="pure-control-group"> - <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> - <a href="{{url_for('scrub_page')}}" class="pure-button button-small button-cancel">Delete History Snapshot Data</a> - </div> - </fieldset> - </form> - - + </form> + </div> </div> {% endblock %} diff --git a/backend/templates/watch-overview.html b/backend/templates/watch-overview.html index 9a024533..c992f6a1 100644 --- a/backend/templates/watch-overview.html +++ b/backend/templates/watch-overview.html @@ -49,6 +49,8 @@ <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} <a class="external" target="_blank" rel="noopener" href="{{ watch.url }}"></a> + {%if watch.fetch_backend == "html_webdriver" %}<img style="height: 1em; display:inline-block;" src="/static/images/Google-Chrome-icon.png" />{% endif %} + {% if watch.last_error is defined and watch.last_error != False %} <div class="fetch-error">{{ watch.last_error }}</div> {% endif %} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3c620e6b..ef5cdc42 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,7 +5,6 @@ from backend import changedetection_app from backend import store import os - # https://github.com/pallets/flask/blob/1.1.2/examples/tutorial/tests/test_auth.py # Much better boilerplate than the docs # https://www.python-boilerplate.com/py3+flask+pytest/ @@ -39,10 +38,11 @@ def app(request): # Enable a BASE_URL for notifications to work (so we can look for diff/ etc URLs) os.environ["BASE_URL"] = "http://mysite.com/" - cleanup(datastore_path) + app_config = {'datastore_path': datastore_path} + cleanup(app_config['datastore_path']) datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], include_default_watches=False) app = changedetection_app(app_config, datastore) app.config['STOP_THREADS'] = True @@ -50,8 +50,8 @@ def app(request): def teardown(): datastore.stop_thread = True app.config.exit.set() - cleanup(datastore_path) - + cleanup(app_config['datastore_path']) + + request.addfinalizer(teardown) yield app - diff --git a/backend/tests/test_access_control.py b/backend/tests/test_access_control.py index 14e0880a..026929ba 100644 --- a/backend/tests/test_access_control.py +++ b/backend/tests/test_access_control.py @@ -12,7 +12,9 @@ def test_check_access_control(app, client): # Enable password check. res = c.post( url_for("settings_page"), - data={"password": "foobar", "minutes_between_check": 180}, + data={"password": "foobar", + "minutes_between_check": 180, + 'fetch_backend': "html_requests"}, follow_redirects=True ) @@ -66,8 +68,11 @@ def test_check_access_control_no_blank_password(app, client): # Enable password check. res = c.post( url_for("settings_page"), - data={"password": "", "minutes_between_check": 180}, - follow_redirects=True + data={"password": "", + "minutes_between_check": 180, + 'fetch_backend': "html_requests"}, + + follow_redirects=True ) assert b"Password protection enabled." not in res.data @@ -86,7 +91,8 @@ def test_check_access_no_remote_access_to_remove_password(app, client): # Enable password check. res = c.post( url_for("settings_page"), - data={"password": "password", "minutes_between_check": 180}, + data={"password": "password", "minutes_between_check": 180, + 'fetch_backend': "html_requests"}, follow_redirects=True ) diff --git a/backend/tests/test_backend.py b/backend/tests/test_backend.py index 8f944605..3b013f61 100644 --- a/backend/tests/test_backend.py +++ b/backend/tests/test_backend.py @@ -88,7 +88,7 @@ def test_check_basic_change_detection_functionality(client, live_server): # Enable auto pickup of <title> in settings res = client.post( url_for("settings_page"), - data={"extract_title_as_title": "1", "minutes_between_check": 180}, + data={"extract_title_as_title": "1", "minutes_between_check": 180, 'fetch_backend': "html_requests"}, follow_redirects=True ) diff --git a/backend/tests/test_css_selector.py b/backend/tests/test_css_selector.py index 6425600c..21183b06 100644 --- a/backend/tests/test_css_selector.py +++ b/backend/tests/test_css_selector.py @@ -22,7 +22,7 @@ def set_original_response(): </html> """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) return None @@ -39,7 +39,7 @@ def set_modified_response(): </html> """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) return None @@ -98,7 +98,7 @@ def test_check_markup_css_filter_restriction(client, live_server): # Add our URL to the import page res = client.post( url_for("edit_page", uuid="first"), - data={"css_filter": css_filter, "url": test_url, "tag": "", "headers": ""}, + data={"css_filter": css_filter, "url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_requests"}, follow_redirects=True ) assert b"Updated watch." in res.data diff --git a/backend/tests/test_headers.py b/backend/tests/test_headers.py index 420fd39f..38943978 100644 --- a/backend/tests/test_headers.py +++ b/backend/tests/test_headers.py @@ -35,6 +35,7 @@ def test_headers_in_request(client, live_server): data={ "url": test_url, "tag": "", + "fetch_backend": "html_requests", "headers": "xxx:ooo\ncool:yeah\r\ncookie:"+cookie_header}, follow_redirects=True ) diff --git a/backend/tests/test_ignore_text.py b/backend/tests/test_ignore_text.py index df6e349c..cdcb9bbb 100644 --- a/backend/tests/test_ignore_text.py +++ b/backend/tests/test_ignore_text.py @@ -41,7 +41,7 @@ def set_original_ignore_response(): """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) @@ -57,7 +57,7 @@ def set_modified_original_ignore_response(): """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) @@ -75,7 +75,7 @@ def set_modified_ignore_response(): """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) @@ -107,7 +107,7 @@ def test_check_ignore_text_functionality(client, live_server): # Add our URL to the import page res = client.post( url_for("edit_page", uuid="first"), - data={"ignore_text": ignore_text, "url": test_url}, + data={"ignore_text": ignore_text, "url": test_url, 'fetch_backend': "html_requests"}, follow_redirects=True ) assert b"Updated watch." in res.data diff --git a/backend/tests/test_jsonpath_selector.py b/backend/tests/test_jsonpath_selector.py index 40f36556..91c46764 100644 --- a/backend/tests/test_jsonpath_selector.py +++ b/backend/tests/test_jsonpath_selector.py @@ -43,9 +43,6 @@ and it can also be repeated html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "$.id") -def test_setup(live_server): - live_server_setup(live_server) - def set_original_response(): test_return_data = """ { @@ -66,7 +63,7 @@ def set_original_response(): } } """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) return None @@ -91,7 +88,7 @@ def set_modified_response(): } """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) return None @@ -99,6 +96,7 @@ def set_modified_response(): def test_check_json_filter(client, live_server): + live_server_setup(live_server) json_filter = 'json:boss.name' @@ -126,7 +124,12 @@ def test_check_json_filter(client, live_server): # Add our URL to the import page res = client.post( url_for("edit_page", uuid="first"), - data={"css_filter": json_filter, "url": test_url, "tag": "", "headers": ""}, + data={"css_filter": json_filter, + "url": test_url, + "tag": "", + "headers": "", + "fetch_backend": "html_requests" + }, follow_redirects=True ) assert b"Updated watch." in res.data @@ -148,7 +151,7 @@ def test_check_json_filter(client, live_server): # Trigger a check client.get(url_for("api_watch_checknow"), follow_redirects=True) # Give the thread time to pick it up - time.sleep(3) + time.sleep(4) # It should have 'unviewed' still res = client.get(url_for("index")) diff --git a/backend/tests/test_notification.py b/backend/tests/test_notification.py index e07d77ef..ec97be2d 100644 --- a/backend/tests/test_notification.py +++ b/backend/tests/test_notification.py @@ -37,6 +37,7 @@ def test_check_notification(client, live_server): "url": test_url, "tag": "", "headers": "", + "fetch_backend": "html_requests", "trigger_check": "y"}, follow_redirects=True ) @@ -90,9 +91,8 @@ def test_check_notification(client, live_server): #assert bytes("https://foobar.com".encode('utf-8')) in notification_submission - ## Now configure something clever, we go into custom config (non-default) mode - - with open("test-datastore/output.txt", "w") as f: + ## Now configure something clever, we go into custom config (non-default) mode, this is returned by the endpoint + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(";jasdhflkjadshf kjhsdfkjl ahslkjf haslkjd hfaklsj hf\njl;asdhfkasj stuff we will detect\n") res = client.post( @@ -100,7 +100,9 @@ def test_check_notification(client, live_server): data={"notification_title": "New ChangeDetection.io Notification - {watch_url}", "notification_body": "{base_url}\n{watch_url}\n{preview_url}\n{diff_url}\n{current_snapshot}\n:-)", "notification_urls": "json://foobar.com", #Re #143 should not see that it sent without [test checkbox] - "minutes_between_check": 180}, + "minutes_between_check": 180, + "fetch_backend": "html_requests", + }, follow_redirects=True ) assert b"Settings updated." in res.data @@ -122,6 +124,8 @@ def test_check_notification(client, live_server): with open("test-datastore/notification.txt", "r") as f: notification_submission = f.read() + # @todo regex that diff/uuid-31123-123-etc + assert "diff/" in notification_submission assert "preview/" in notification_submission assert ":-)" in notification_submission diff --git a/backend/tests/test_watch_fields_storage.py b/backend/tests/test_watch_fields_storage.py index f9ee7634..12f6474c 100644 --- a/backend/tests/test_watch_fields_storage.py +++ b/backend/tests/test_watch_fields_storage.py @@ -28,7 +28,7 @@ def test_check_watch_field_storage(client, live_server): "url": test_url, "tag": "woohoo", "headers": "curl:foo", - + 'fetch_backend': "html_requests" }, follow_redirects=True ) @@ -57,6 +57,7 @@ def test_check_recheck_global_setting(client, live_server): url_for("settings_page"), data={ "minutes_between_check": 1566, + 'fetch_backend': "html_requests" }, follow_redirects=True ) @@ -88,6 +89,7 @@ def test_check_recheck_global_setting(client, live_server): url_for("settings_page"), data={ "minutes_between_check": 222, + 'fetch_backend': "html_requests" }, follow_redirects=True ) @@ -107,6 +109,7 @@ def test_check_recheck_global_setting(client, live_server): url_for("edit_page", uuid="first"), data={"url": test_url, "minutes_between_check": 55, + 'fetch_backend': "html_requests" }, follow_redirects=True ) @@ -122,6 +125,7 @@ def test_check_recheck_global_setting(client, live_server): url_for("settings_page"), data={ "minutes_between_check": 666, + 'fetch_backend': "html_requests" }, follow_redirects=True ) @@ -131,6 +135,7 @@ def test_check_recheck_global_setting(client, live_server): url_for("edit_page", uuid="first"), data={"url": test_url, "minutes_between_check": "", + 'fetch_backend': "html_requests" }, follow_redirects=True ) diff --git a/backend/tests/util.py b/backend/tests/util.py index 5d6f7c28..38e1c2b6 100644 --- a/backend/tests/util.py +++ b/backend/tests/util.py @@ -13,7 +13,7 @@ def set_original_response(): </html> """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) return None @@ -29,7 +29,7 @@ def set_modified_response(): </html> """ - with open("test-datastore/output.txt", "w") as f: + with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) return None @@ -41,7 +41,7 @@ def live_server_setup(live_server): @live_server.app.route('/test-endpoint') def test_endpoint(): # Tried using a global var here but didn't seem to work, so reading from a file instead. - with open("test-datastore/output.txt", "r") as f: + with open("test-datastore/endpoint-content.txt", "r") as f: return f.read() # Just return the headers in the request diff --git a/backend/update_worker.py b/backend/update_worker.py index 9eb54ebd..e27bbfc9 100644 --- a/backend/update_worker.py +++ b/backend/update_worker.py @@ -1,5 +1,6 @@ import threading import queue +import time # Requests for checking on the site use a pool of thread Workers managed by a Queue. class update_worker(threading.Thread): @@ -26,24 +27,45 @@ class update_worker(threading.Thread): else: self.current_uuid = uuid + from backend import content_fetcher if uuid in list(self.datastore.data['watching'].keys()): + + changed_detected = False + contents = "" + update_obj= {} + try: - changed_detected, result, contents = update_handler.run(uuid) + now = time.time() + changed_detected, update_obj, contents = update_handler.run(uuid) + + # Always record that we atleast tried + self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - now, 3)}) except PermissionError as e: self.app.logger.error("File permission error updating", uuid, str(e)) + except content_fetcher.EmptyReply as e: + self.datastore.update_watch(uuid=uuid, update_obj={'last_error':str(e)}) + + #@todo how to handle when it's thrown from webdriver connecting? except Exception as e: self.app.logger.error("Exception reached", uuid, str(e)) + self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)}) + else: - if result: + if update_obj: try: - self.datastore.update_watch(uuid=uuid, update_obj=result) + self.datastore.update_watch(uuid=uuid, update_obj=update_obj) if changed_detected: # A change was detected newest_version_file_contents = "" - self.datastore.save_history_text(uuid=uuid, contents=contents, result_obj=result) + fname = self.datastore.save_history_text(watch_uuid=uuid, contents=contents) + + # Update history with the stripped text for future reference, this will also mean we save the first + # Should always be keyed by string(timestamp) + self.datastore.update_watch(uuid, {"history": {str(update_obj["last_checked"]): fname}}) + watch = self.datastore.data['watching'][uuid] print (">> Change detected in UUID {} - {}".format(uuid, watch['url'])) diff --git a/docker-compose.yml b/docker-compose.yml index 8bb11bce..5acbb8d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,12 +6,14 @@ services: hostname: changedetection.io volumes: - changedetection-data:/datastore + # environment: # Default listening port, can also be changed with the -p option # - PORT=5000 # - PUID=1000 # - PGID=1000 + # - WEBDRIVER_URL="http://browser-chrome:4444/wd/hub" # Proxy support example. # - HTTP_PROXY="socks5h://10.10.1.10:1080" # - HTTPS_PROXY="socks5h://10.10.1.10:1080" @@ -27,8 +29,21 @@ services: # Comment out ports: when using behind a reverse proxy , enable networks: etc. ports: - 5000:5000 + restart: unless-stopped - restart: always + # Used for fetching pages via WebDriver+Chrome where you need Javascript support. + # Does not work on rPi, https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver + +# browser-chrome: +# hostname: browser-chrome +# image: selenium/standalone-chrome-debug:3.141.59 +# environment: +# - VNC_NO_PASSWORD=1 +# volumes: +# # Workaround to avoid the browser crashing inside a docker container +# # See https://github.com/SeleniumHQ/docker-selenium#quick-start +# - /dev/shm:/dev/shm +# restart: unless-stopped volumes: changedetection-data: diff --git a/requirements.txt b/requirements.txt index 1d4c4b11..18bb875a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,10 +14,10 @@ urllib3 wtforms ~= 2.3.3 jsonpath-ng ~= 1.5.3 - # Notification library apprise ~= 0.9 # Used for CSS filtering, replace with soupsieve and lxml for xpath bs4 +selenium ~= 3.141 \ No newline at end of file