Compare commits

..

11 Commits

Author SHA1 Message Date
dgtlmoon 23c73cafa0 remove check 2023-01-29 21:07:42 +01:00
dgtlmoon b957dc9153 rss token now always required 2023-01-29 20:35:59 +01:00
dgtlmoon 7b54a5e533 remove dupe code 2023-01-29 20:14:03 +01:00
dgtlmoon ebd9c1cbc3 Simplify 2023-01-29 20:12:00 +01:00
dgtlmoon 8e9fe9c288 tidying up rss token test 2023-01-29 20:07:16 +01:00
dgtlmoon 9f1577331c Fix for RSS perms 2023-01-29 19:41:26 +01:00
dgtlmoon 952ba38b46 Refactor user session handling 2023-01-29 19:35:02 +01:00
dgtlmoon 8f5cb68319 Adding test 2023-01-29 15:37:42 +01:00
dgtlmoon 02682b288f Merge branch 'master' into share-diff 2023-01-29 15:05:57 +01:00
dgtlmoon 782ed32b05 WIP 2023-01-28 23:27:44 +01:00
dgtlmoon cb0d124a46 WIP 2023-01-28 16:54:00 +01:00
79 changed files with 690 additions and 1852 deletions
+2 -4
View File
@@ -98,8 +98,7 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
# Looks like this was disabled provenance: false
# provenance: false
# A new tagged release is required, which builds :tag and :latest # A new tagged release is required, which builds :tag and :latest
- name: Build and push :tag - name: Build and push :tag
@@ -118,8 +117,7 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
# Looks like this was disabled provenance: false
# provenance: false
- name: Image digest - 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 }} 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 }}
+38
View File
@@ -0,0 +1,38 @@
name: PyPi Test and Push tagged release
# Triggers the workflow on push or pull request events
on:
workflow_run:
workflows: ["ChangeDetection.io Test"]
tags: '*.*'
types: [completed]
jobs:
test-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: Test that pip builds without error
run: |
pip3 --version
python3 -m pip install wheel
python3 setup.py bdist_wheel
python3 -m pip install dist/changedetection.io-*-none-any.whl --force
changedetection.io -d /tmp -p 10000 &
sleep 3
curl http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null
killall -9 changedetection.io
# https://github.com/docker/build-push-action/blob/master/docs/advanced/test-before-push.md ?
# https://github.com/docker/buildx/issues/59 ? Needs to be one platform?
# https://github.com/docker/buildx/issues/495#issuecomment-918925854
#if: ${{ github.event_name == 'release'}}
+2 -5
View File
@@ -50,13 +50,10 @@ jobs:
run: | run: |
# Selenium fetch # Selenium fetch
docker run --rm -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py' docker run -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py'
# Playwright/Browserless fetch # Playwright/Browserless fetch
docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py && pytest tests/visualselector/test_fetch_data.py' docker run -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py && pytest tests/visualselector/test_fetch_data.py'
# restock detection via playwright - added name=changedet here so that playwright/browserless can connect to it
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
- name: Test proxy interaction - name: Test proxy interaction
run: | run: |
-36
View File
@@ -1,36 +0,0 @@
name: ChangeDetection.io PIP package test
# Triggers the workflow on push or pull request events
# This line doesnt work, even tho it is the documented one
on: [push, pull_request]
# Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing
# @todo: some kind of path filter for requirements.txt and Dockerfile
jobs:
test-pip-build-basics:
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: Test that the basic pip built package runs without error
run: |
set -e
mkdir dist
pip3 install wheel
python3 setup.py bdist_wheel
pip3 install -r requirements.txt
rm ./changedetection.py
rm -rf changedetectio
pip3 install dist/changedetection.io*.whl
changedetection.io -d /tmp -p 10000 &
sleep 3
curl http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null
killall -9 changedetection.io
-1
View File
@@ -1,7 +1,6 @@
recursive-include changedetectionio/api * recursive-include changedetectionio/api *
recursive-include changedetectionio/blueprint * recursive-include changedetectionio/blueprint *
recursive-include changedetectionio/model * recursive-include changedetectionio/model *
recursive-include changedetectionio/processors *
recursive-include changedetectionio/res * recursive-include changedetectionio/res *
recursive-include changedetectionio/static * recursive-include changedetectionio/static *
recursive-include changedetectionio/templates * recursive-include changedetectionio/templates *
+1 -6
View File
@@ -1,4 +1,4 @@
## Web Site Change Detection, Restock monitoring and notifications. ## Web Site Change Detection, Monitoring and Notification.
**_Detect website content changes and perform meaningful actions - trigger notifications via Discord, Email, Slack, Telegram, API calls and many more._** **_Detect website content changes and perform meaningful actions - trigger notifications via Discord, Email, Slack, Telegram, API calls and many more._**
@@ -49,7 +49,6 @@ Requires Playwright to be enabled.
- Governmental department updates (changes are often only on their websites) - Governmental department updates (changes are often only on their websites)
- New software releases, security advisories when you're not on their mailing list. - New software releases, security advisories when you're not on their mailing list.
- Festivals with changes - Festivals with changes
- Discogs restock alerts and monitoring
- Realestate listing changes - Realestate listing changes
- Know when your favourite whiskey is on sale, or other special deals are announced before anyone else - Know when your favourite whiskey is on sale, or other special deals are announced before anyone else
- COVID related news from government websites - COVID related news from government websites
@@ -64,8 +63,6 @@ Requires Playwright to be enabled.
- You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product)
- Get notified when certain keywords appear in Twitter search results - Get notified when certain keywords appear in Twitter search results
- Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords. - Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords.
- Get alerts when new job positions are open on Bamboo HR and other job platforms
- Website defacement monitoring
_Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_
@@ -103,8 +100,6 @@ $ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/d
`:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch. `:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch.
Alternative docker repository over at ghcr - [ghcr.io/dgtlmoon/changedetection.io](https://ghcr.io/dgtlmoon/changedetection.io)
### Windows ### Windows
See the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows See the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows
+96 -120
View File
@@ -33,12 +33,10 @@ from flask import (
url_for, url_for,
) )
from flask_paginate import Pagination, get_page_parameter
from changedetectionio import html_tools from changedetectionio import html_tools
from changedetectionio.api import api_v1 from changedetectionio.api import api_v1
__version__ = '0.41.1' __version__ = '0.40.1.1'
datastore = None datastore = None
@@ -55,6 +53,7 @@ app = Flask(__name__,
static_url_path="", static_url_path="",
static_folder="static", static_folder="static",
template_folder="templates") template_folder="templates")
from flask_compress import Compress
# Super handy for compressing large BrowserSteps responses and others # Super handy for compressing large BrowserSteps responses and others
FlaskCompress(app) FlaskCompress(app)
@@ -66,9 +65,6 @@ app.config.exit = Event()
app.config['NEW_VERSION_AVAILABLE'] = False app.config['NEW_VERSION_AVAILABLE'] = False
if os.getenv('FLASK_SERVER_NAME'):
app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME')
#app.config["EXPLAIN_TEMPLATE_LOADING"] = True #app.config["EXPLAIN_TEMPLATE_LOADING"] = True
# Disables caching of the templates # Disables caching of the templates
@@ -342,6 +338,8 @@ def changedetection_app(config=None, datastore_o=None):
if len(dates) < 2: if len(dates) < 2:
continue continue
prev_fname = watch.history[dates[-2]]
if not watch.viewed: if not watch.viewed:
# Re #239 - GUID needs to be individual for each event # Re #239 - GUID needs to be individual for each event
# @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228) # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228)
@@ -362,12 +360,9 @@ def changedetection_app(config=None, datastore_o=None):
watch_title = watch.get('title') if watch.get('title') else watch.get('url') watch_title = watch.get('title') if watch.get('title') else watch.get('url')
fe.title(title=watch_title) fe.title(title=watch_title)
latest_fname = watch.history[dates[-1]]
html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]), html_diff = diff.render_diff(prev_fname, latest_fname, include_equal=False, line_feed_sep="</br>")
newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
include_equal=False,
line_feed_sep="<br>")
fe.content(content="<html><body><h4>{}</h4>{}</body></html>".format(watch_title, html_diff), fe.content(content="<html><body><h4>{}</h4>{}</body></html>".format(watch_title, html_diff),
type='CDATA') type='CDATA')
@@ -421,10 +416,6 @@ def changedetection_app(config=None, datastore_o=None):
existing_tags = datastore.get_all_tags() existing_tags = datastore.get_all_tags()
form = forms.quickWatchForm(request.form) form = forms.quickWatchForm(request.form)
page = request.args.get(get_page_parameter(), type=int, default=1)
total_count = len(sorted_watches) if sorted_watches else len(datastore.data['watching'])
pagination = Pagination(page=page, total=total_count, per_page=int(os.getenv('pagination_per_page', 50)), css_framework = "semantic")
output = render_template( output = render_template(
"watch-overview.html", "watch-overview.html",
# Don't link to hosting when we're on the hosting environment # Don't link to hosting when we're on the hosting environment
@@ -435,28 +426,16 @@ def changedetection_app(config=None, datastore_o=None):
has_proxies=datastore.proxy_list, has_proxies=datastore.proxy_list,
has_unviewed=datastore.has_unviewed, has_unviewed=datastore.has_unviewed,
hosted_sticky=os.getenv("SALTED_PASS", False) == False, hosted_sticky=os.getenv("SALTED_PASS", False) == False,
pagination=pagination,
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue], queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'), system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
tags=existing_tags, tags=existing_tags,
watches=sorted_watches watches=sorted_watches
) )
if session.get('share-link'): if session.get('share-link'):
del(session['share-link']) del(session['share-link'])
return output
resp = make_response(output)
# The template can run on cookie or url query info
if request.args.get('sort'):
resp.set_cookie('sort', request.args.get('sort'))
if request.args.get('order'):
resp.set_cookie('order', request.args.get('order'))
return resp
# AJAX endpoint for sending a test # AJAX endpoint for sending a test
@@ -481,19 +460,11 @@ def changedetection_app(config=None, datastore_o=None):
try: try:
n_object = {'watch_url': request.form['window_url'], n_object = {'watch_url': request.form['window_url'],
'notification_urls': request.form['notification_urls'].splitlines() 'notification_urls': request.form['notification_urls'].splitlines(),
'notification_title': request.form['notification_title'].strip(),
'notification_body': request.form['notification_body'].strip(),
'notification_format': request.form['notification_format'].strip()
} }
# Only use if present, if not set in n_object it should use the default system value
if 'notification_format' in request.form and request.form['notification_format'].strip():
n_object['notification_format'] = request.form.get('notification_format', '').strip()
if 'notification_title' in request.form and request.form['notification_title'].strip():
n_object['notification_title'] = request.form.get('notification_title', '').strip()
if 'notification_body' in request.form and request.form['notification_body'].strip():
n_object['notification_body'] = request.form.get('notification_body', '').strip()
notification_q.put(n_object) notification_q.put(n_object)
except Exception as e: except Exception as e:
return make_response({'error': str(e)}, 400) return make_response({'error': str(e)}, 400)
@@ -535,15 +506,49 @@ def changedetection_app(config=None, datastore_o=None):
output = render_template("clear_all_history.html") output = render_template("clear_all_history.html")
return output return output
# If they edited an existing watch, we need to know to reset the current/previous md5 to include
# the excluded text.
def get_current_checksum_include_ignore_text(uuid):
import hashlib
from changedetectionio import fetch_site_status
# Get the most recent one
newest_history_key = datastore.data['watching'][uuid].get('newest_history_key')
# 0 means that theres only one, so that there should be no 'unviewed' history available
if newest_history_key == 0:
newest_history_key = list(datastore.data['watching'][uuid].history.keys())[0]
if newest_history_key:
with open(datastore.data['watching'][uuid].history[newest_history_key],
encoding='utf-8') as file:
raw_content = file.read()
handler = fetch_site_status.perform_site_check(datastore=datastore)
stripped_content = html_tools.strip_ignore_text(raw_content,
datastore.data['watching'][uuid]['ignore_text'])
if datastore.data['settings']['application'].get('ignore_whitespace', False):
checksum = hashlib.md5(stripped_content.translate(None, b'\r\n\t ')).hexdigest()
else:
checksum = hashlib.md5(stripped_content).hexdigest()
return checksum
return datastore.data['watching'][uuid]['previous_md5']
@app.route("/edit/<string:uuid>", methods=['GET', 'POST']) @app.route("/edit/<string:uuid>", methods=['GET', 'POST'])
@login_optionally_required @login_optionally_required
# https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists
# https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ? # https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ?
def edit_page(uuid): def edit_page(uuid):
from . import forms from changedetectionio import forms
from .blueprint.browser_steps.browser_steps import browser_step_ui_config from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config
from . import processors
using_default_check_time = True using_default_check_time = True
# More for testing, possible to return the first/only # More for testing, possible to return the first/only
@@ -558,15 +563,6 @@ def changedetection_app(config=None, datastore_o=None):
flash("No watch with the UUID %s found." % (uuid), "error") flash("No watch with the UUID %s found." % (uuid), "error")
return redirect(url_for('index')) return redirect(url_for('index'))
switch_processor = request.args.get('switch_processor')
if switch_processor:
for p in processors.available_processors():
if p[0] == switch_processor:
datastore.data['watching'][uuid]['processor'] = switch_processor
flash(f"Switched to mode - {p[1]}.")
datastore.clear_watch_history(uuid)
redirect(url_for('edit_page', uuid=uuid))
# be sure we update with a copy instead of accidently editing the live object by reference # be sure we update with a copy instead of accidently editing the live object by reference
default = deepcopy(datastore.data['watching'][uuid]) default = deepcopy(datastore.data['watching'][uuid])
@@ -627,16 +623,6 @@ def changedetection_app(config=None, datastore_o=None):
if datastore.proxy_list is not None and form.data['proxy'] == '': if datastore.proxy_list is not None and form.data['proxy'] == '':
extra_update_obj['proxy'] = None extra_update_obj['proxy'] = None
# Unsetting all filter_text methods should make it go back to default
# This particularly affects tests running
if 'filter_text_added' in form.data and not form.data.get('filter_text_added') \
and 'filter_text_replaced' in form.data and not form.data.get('filter_text_replaced') \
and 'filter_text_removed' in form.data and not form.data.get('filter_text_removed'):
extra_update_obj['filter_text_added'] = True
extra_update_obj['filter_text_replaced'] = True
extra_update_obj['filter_text_removed'] = True
datastore.data['watching'][uuid].update(form.data) datastore.data['watching'][uuid].update(form.data)
datastore.data['watching'][uuid].update(extra_update_obj) datastore.data['watching'][uuid].update(extra_update_obj)
@@ -683,7 +669,6 @@ def changedetection_app(config=None, datastore_o=None):
visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and is_html_webdriver visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and is_html_webdriver
output = render_template("edit.html", output = render_template("edit.html",
available_processors=processors.available_processors(),
browser_steps_config=browser_step_ui_config, browser_steps_config=browser_step_ui_config,
current_base_url=datastore.data['settings']['application']['base_url'], current_base_url=datastore.data['settings']['application']['base_url'],
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
@@ -786,8 +771,6 @@ def changedetection_app(config=None, datastore_o=None):
@login_optionally_required @login_optionally_required
def import_page(): def import_page():
remaining_urls = [] remaining_urls = []
from . import forms
if request.method == 'POST': if request.method == 'POST':
from .importer import import_url_list, import_distill_io_json from .importer import import_url_list, import_distill_io_json
@@ -795,7 +778,7 @@ def changedetection_app(config=None, datastore_o=None):
if request.values.get('urls') and len(request.values.get('urls').strip()): if request.values.get('urls') and len(request.values.get('urls').strip()):
# Import and push into the queue for immediate update check # Import and push into the queue for immediate update check
importer = import_url_list() importer = import_url_list()
importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor')) importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore)
for uuid in importer.new_uuids: for uuid in importer.new_uuids:
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
@@ -813,12 +796,9 @@ def changedetection_app(config=None, datastore_o=None):
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
form = forms.importForm(formdata=request.form if request.method == 'POST' else None,
# data=default,
)
# Could be some remaining, or we could be on GET # Could be some remaining, or we could be on GET
output = render_template("import.html", output = render_template("import.html",
form=form,
import_url_list_remaining="\n".join(remaining_urls), import_url_list_remaining="\n".join(remaining_urls),
original_distill_json='' original_distill_json=''
) )
@@ -884,31 +864,36 @@ def changedetection_app(config=None, datastore_o=None):
# Save the current newest history as the most recently viewed # Save the current newest history as the most recently viewed
datastore.set_last_viewed(uuid, time.time()) datastore.set_last_viewed(uuid, time.time())
newest_file = history[dates[-1]]
# Read as binary and force decode as UTF-8 # Read as binary and force decode as UTF-8
# Windows may fail decode in python if we just use 'r' mode (chardet decode exception) # Windows may fail decode in python if we just use 'r' mode (chardet decode exception)
try: try:
newest_version_file_contents = watch.get_history_snapshot(dates[-1]) with open(newest_file, 'r', encoding='utf-8', errors='ignore') as f:
newest_version_file_contents = f.read()
except Exception as e: except Exception as e:
newest_version_file_contents = "Unable to read {}.\n".format(dates[-1]) newest_version_file_contents = "Unable to read {}.\n".format(newest_file)
previous_version = request.args.get('previous_version') previous_version = request.args.get('previous_version')
previous_timestamp = dates[-2] try:
if previous_version: previous_file = history[previous_version]
previous_timestamp = previous_version except KeyError:
# Not present, use a default value, the second one in the sorted list.
previous_file = history[dates[-2]]
try: try:
previous_version_file_contents = watch.get_history_snapshot(previous_timestamp) with open(previous_file, 'r', encoding='utf-8', errors='ignore') as f:
previous_version_file_contents = f.read()
except Exception as e: except Exception as e:
previous_version_file_contents = "Unable to read {}.\n".format(previous_timestamp) previous_version_file_contents = "Unable to read {}.\n".format(previous_file)
screenshot_url = watch.get_screenshot() screenshot_url = watch.get_screenshot()
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
is_html_webdriver = False is_html_webdriver = True if watch.get('fetch_backend') == 'html_webdriver' or (
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver': watch.get('fetch_backend', None) is None and system_uses_webdriver) else False
is_html_webdriver = True
password_enabled_and_share_is_off = False password_enabled_and_share_is_off = False
if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False): if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
@@ -958,9 +943,8 @@ def changedetection_app(config=None, datastore_o=None):
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
is_html_webdriver = False is_html_webdriver = True if watch.get('fetch_backend') == 'html_webdriver' or (
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver': watch.get('fetch_backend', None) is None and system_uses_webdriver) else False
is_html_webdriver = True
# Never requested successfully, but we detected a fetch error # Never requested successfully, but we detected a fetch error
if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()): if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
@@ -979,35 +963,37 @@ def changedetection_app(config=None, datastore_o=None):
return output return output
timestamp = list(watch.history.keys())[-1] timestamp = list(watch.history.keys())[-1]
filename = watch.history[timestamp]
try: try:
tmp = watch.get_history_snapshot(timestamp).splitlines() with open(filename, 'r', encoding='utf-8', errors='ignore') as f:
tmp = f.readlines()
# Get what needs to be highlighted # Get what needs to be highlighted
ignore_rules = watch.get('ignore_text', []) + datastore.data['settings']['application']['global_ignore_text'] ignore_rules = watch.get('ignore_text', []) + datastore.data['settings']['application']['global_ignore_text']
# .readlines will keep the \n, but we will parse it here again, in the future tidy this up # .readlines will keep the \n, but we will parse it here again, in the future tidy this up
ignored_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp), ignored_line_numbers = html_tools.strip_ignore_text(content="".join(tmp),
wordlist=ignore_rules, wordlist=ignore_rules,
mode='line numbers' mode='line numbers'
) )
trigger_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp), trigger_line_numbers = html_tools.strip_ignore_text(content="".join(tmp),
wordlist=watch['trigger_text'], wordlist=watch['trigger_text'],
mode='line numbers' mode='line numbers'
) )
# Prepare the classes and lines used in the template # Prepare the classes and lines used in the template
i=0 i=0
for l in tmp: for l in tmp:
classes=[] classes=[]
i+=1 i+=1
if i in ignored_line_numbers: if i in ignored_line_numbers:
classes.append('ignored') classes.append('ignored')
if i in trigger_line_numbers: if i in trigger_line_numbers:
classes.append('triggered') classes.append('triggered')
content.append({'line': l, 'classes': ' '.join(classes)}) content.append({'line': l, 'classes': ' '.join(classes)})
except Exception as e: except Exception as e:
content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''}) content.append({'line': "File doesnt exist or unable to read file {}".format(filename), 'classes': ''})
output = render_template("preview.html", output = render_template("preview.html",
content=content, content=content,
@@ -1049,8 +1035,7 @@ def changedetection_app(config=None, datastore_o=None):
os.unlink(previous_backup_filename) os.unlink(previous_backup_filename)
# create a ZipFile object # create a ZipFile object
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") backupname = "changedetection-backup-{}.zip".format(int(time.time()))
backupname = "changedetection-backup-{}.zip".format(timestamp)
backup_filepath = os.path.join(datastore_o.datastore_path, backupname) backup_filepath = os.path.join(datastore_o.datastore_path, backupname)
with zipfile.ZipFile(backup_filepath, "w", with zipfile.ZipFile(backup_filepath, "w",
@@ -1174,8 +1159,7 @@ def changedetection_app(config=None, datastore_o=None):
return redirect(url_for('index')) return redirect(url_for('index'))
add_paused = request.form.get('edit_and_watch_submit_button') != None add_paused = request.form.get('edit_and_watch_submit_button') != None
processor = request.form.get('processor', 'text_json_diff') new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip(), extras={'paused': add_paused})
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip(), extras={'paused': add_paused, 'processor': processor})
if new_uuid: if new_uuid:
if add_paused: if add_paused:
@@ -1217,8 +1201,7 @@ def changedetection_app(config=None, datastore_o=None):
new_uuid = datastore.clone(uuid) new_uuid = datastore.clone(uuid)
if new_uuid: if new_uuid:
if not datastore.data['watching'].get(uuid).get('paused'): update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid, 'skip_when_checksum_same': True}))
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid, 'skip_when_checksum_same': True}))
flash('Cloned.') flash('Cloned.')
return redirect(url_for('index')) return redirect(url_for('index'))
@@ -1284,13 +1267,6 @@ def changedetection_app(config=None, datastore_o=None):
datastore.data['watching'][uuid.strip()]['paused'] = False datastore.data['watching'][uuid.strip()]['paused'] = False
flash("{} watches unpaused".format(len(uuids))) flash("{} watches unpaused".format(len(uuids)))
elif (op == 'mark-viewed'):
for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
datastore.set_last_viewed(uuid, int(time.time()))
flash("{} watches updated".format(len(uuids)))
elif (op == 'mute'): elif (op == 'mute'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip() uuid = uuid.strip()
+24 -75
View File
@@ -33,7 +33,7 @@ class Watch(Resource):
@auth.check_token @auth.check_token
def get(self, uuid): def get(self, uuid):
""" """
@api {get} /api/v1/watch/:uuid Get a single watch data @api {get} /api/v1/watch/:uuid Single watch information
@apiDescription Retrieve watch information and set muted/paused status @apiDescription Retrieve watch information and set muted/paused status
@apiExample {curl} Example usage: @apiExample {curl} Example usage:
curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45" curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@@ -70,16 +70,13 @@ class Watch(Resource):
return "OK", 200 return "OK", 200
# Return without history, get that via another API call # Return without history, get that via another API call
# Properties are not returned as a JSON, so add the required props manually
watch['history_n'] = watch.history_n watch['history_n'] = watch.history_n
watch['last_changed'] = watch.last_changed
return watch return watch
@auth.check_token @auth.check_token
def delete(self, uuid): def delete(self, uuid):
""" """
@api {delete} /api/v1/watch/:uuid Delete a watch and related history @api {delete} /api/v1/watch/:uuid Delete watch information
@apiExample {curl} Example usage: @apiExample {curl} Example usage:
curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiParam {uuid} uuid Watch unique ID. @apiParam {uuid} uuid Watch unique ID.
@@ -93,18 +90,21 @@ class Watch(Resource):
self.datastore.delete(uuid) self.datastore.delete(uuid)
return 'OK', 204 return 'OK', 204
# Update an existing
@auth.check_token @auth.check_token
@expects_json(schema_update_watch) @expects_json(schema_update_watch)
def put(self, uuid): def put(self, uuid):
""" """
@api {put} /api/v1/watch/:uuid Update watch information @api {put} /api/v1/watch/:uuid Update watch information
@apiExample {curl} Example usage: @apiExample {curl} Example usage:
Create a watch (POST)
curl http://localhost:4000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}'
Update (PUT) Update (PUT)
curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "new list"}' curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "new list"}'
@apiDescription Updates an existing watch using JSON, accepts the same structure as returned in <a href="#api-Watch-Watch">get single watch information</a> @apiDescription Updates an existing watch using JSON, accepts the same structure as at https://github.com/dgtlmoon/changedetection.io/blob/fab7d325f764d6912bef671f1d78bf217689c537/changedetectionio/model/Watch.py#L15
@apiParam {uuid} uuid Watch unique ID. @apiParam {uuid} uuid Watch unique ID.
@apiName Update a watch @apiName Update
@apiGroup Watch @apiGroup Watch
@apiSuccess (200) {String} OK Was updated @apiSuccess (200) {String} OK Was updated
@apiSuccess (500) {String} ERR Some other error @apiSuccess (500) {String} ERR Some other error
@@ -131,21 +131,6 @@ class WatchHistory(Resource):
# Get a list of available history for a watch by UUID # Get a list of available history for a watch by UUID
# curl http://localhost:4000/api/v1/watch/<string:uuid>/history # curl http://localhost:4000/api/v1/watch/<string:uuid>/history
def get(self, uuid): def get(self, uuid):
"""
@api {get} /api/v1/watch/<string:uuid>/history Get a list of all historical snapshots available for a watch
@apiDescription Requires `uuid`, returns list
@apiExample {curl} Example usage:
curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
{
"1676649279": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/cb7e9be8258368262246910e6a2a4c30.txt",
"1677092785": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/e20db368d6fc633e34f559ff67bb4044.txt",
"1677103794": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/02efdd37dacdae96554a8cc85dc9c945.txt"
}
@apiName Get list of available stored snapshots for watch
@apiGroup Watch History
@apiSuccess (200) {String} OK
@apiSuccess (404) {String} ERR Not found
"""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
if not watch: if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid)) abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -157,18 +142,11 @@ class WatchSingleHistory(Resource):
# datastore is a black box dependency # datastore is a black box dependency
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
# Read a given history snapshot and return its content
# <string:timestamp> or "latest"
# curl http://localhost:4000/api/v1/watch/<string:uuid>/history/<int:timestamp>
@auth.check_token @auth.check_token
def get(self, uuid, timestamp): def get(self, uuid, timestamp):
"""
@api {get} /api/v1/watch/<string:uuid>/history/<int:timestamp> Get single snapshot from watch
@apiDescription Requires watch `uuid` and `timestamp`. `timestamp` of "`latest`" for latest available snapshot, or <a href="#api-Watch_History-Get_list_of_available_stored_snapshots_for_watch">use the list returned here</a>
@apiExample {curl} Example usage:
curl http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
@apiName Get single snapshot content
@apiGroup Watch History
@apiSuccess (200) {String} OK
@apiSuccess (404) {String} ERR Not found
"""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
if not watch: if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid)) abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -179,7 +157,8 @@ class WatchSingleHistory(Resource):
if timestamp == 'latest': if timestamp == 'latest':
timestamp = list(watch.history.keys())[-1] timestamp = list(watch.history.keys())[-1]
content = watch.get_history_snapshot(timestamp) with open(watch.history[timestamp], 'r') as f:
content = f.read()
response = make_response(content, 200) response = make_response(content, 200)
response.mimetype = "text/plain" response.mimetype = "text/plain"
@@ -196,19 +175,21 @@ class CreateWatch(Resource):
@expects_json(schema_create_watch) @expects_json(schema_create_watch)
def post(self): def post(self):
""" """
@api {post} /api/v1/watch Create a single watch @api {post} /api/v1/watch Create a watch
@apiDescription Requires atleast `url` set, can accept the same structure as <a href="#api-Watch-Watch">get single watch information</a> to create. @apiDescription requires `url`, Creates a watch, also accepts accepts the same structure as at https://github.com/dgtlmoon/changedetection.io/blob/fab7d325f764d6912bef671f1d78bf217689c537/changedetectionio/model/Watch.py#L15
@apiExample {curl} Example usage: @apiExample {curl} Example usage:
curl http://localhost:4000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}' curl http://localhost:4000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}'
@apiName Create @apiName Create
@apiGroup Watch @apiGroup CreateWatch
@apiSuccess (200) {String} OK Was created @apiSuccess (200) {String} OK Was created
@apiSuccess (500) {String} ERR Some other error @apiSuccess (500) {String} ERR Some other error
""" """
#
json_data = request.get_json() json_data = request.get_json()
url = json_data['url'].strip() url = json_data['url'].strip()
if not validators.url(json_data['url'].strip()): if not validators.url(json_data['url'].strip()):
return "Invalid or unsupported URL", 400 return "Invalid or unsupported URL", 400
@@ -230,32 +211,17 @@ class CreateWatch(Resource):
@auth.check_token @auth.check_token
def get(self): def get(self):
""" """
@api {get} /api/v1/watch List watches @api {get} /api/v1/watch
@apiDescription Return concise list of available watches and some very basic info @apiDescription Return concise list of available watches and some very basic info
@apiExample {curl} Example usage: @apiExample {curl} Example usage:
curl http://localhost:4000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" curl http://localhost:4000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45"
{ recheck_all=1 to recheck all
"6a4b7d5c-fee4-4616-9f43-4ac97046b595": {
"last_changed": 1677103794,
"last_checked": 1677103794,
"last_error": false,
"title": "",
"url": "http://www.quotationspage.com/random.php"
},
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
"last_changed": 0,
"last_checked": 1676662819,
"last_error": false,
"title": "QuickLook",
"url": "https://github.com/QL-Win/QuickLook/tags"
}
}
@apiParam {String} [recheck_all] Optional Set to =1 to force recheck of all watches @apiParam {String} [recheck_all] Optional Set to =1 to force recheck of all watches
@apiParam {String} [tag] Optional name of tag to limit results @apiParam {String} [tag] Optional name of tag to limit results
@apiName ListWatches @apiName ListWatches
@apiGroup Watch Management @apiGroup CreateWatch
@apiSuccess (200) {String} OK JSON dict
:return:
""" """
list = {} list = {}
@@ -286,22 +252,6 @@ class SystemInfo(Resource):
@auth.check_token @auth.check_token
def get(self): def get(self):
"""
@api {get} /api/v1/systeminfo Return system info
@apiDescription Return some info about the current system state
@apiExample {curl} Example usage:
curl http://localhost:4000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
HTTP/1.0 200
{
'queue_size': 10 ,
'overdue_watches': ["watch-uuid-list"],
'uptime': 38344.55,
'watch_count': 800,
'version': "0.40.1"
}
@apiName Get Info
@apiGroup System Information
"""
import time import time
overdue_watches = [] overdue_watches = []
@@ -320,11 +270,10 @@ class SystemInfo(Resource):
# Allow 5 minutes of grace time before we decide it's overdue # Allow 5 minutes of grace time before we decide it's overdue
if time_since_check - (5 * 60) > t: if time_since_check - (5 * 60) > t:
overdue_watches.append(uuid) overdue_watches.append(uuid)
from changedetectionio import __version__ as main_version
return { return {
'queue_size': self.update_q.qsize(), 'queue_size': self.update_q.qsize(),
'overdue_watches': overdue_watches, 'overdue_watches': overdue_watches,
'uptime': round(time.time() - self.datastore.start_time, 2), 'uptime': round(time.time() - self.datastore.start_time, 2),
'watch_count': len(self.datastore.data.get('watching', {})), 'watch_count': len(self.datastore.data.get('watching', {}))
'version': main_version
}, 200 }, 200
@@ -106,8 +106,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
if step_operation == 'Goto site': if step_operation == 'Goto site':
step_operation = 'goto_url' step_operation = 'goto_url'
step_optional_value = datastore.data['watching'][uuid].get('url') step_optional_value = None
step_selector = None step_selector = datastore.data['watching'][uuid].get('url')
# @todo try.. accept.. nice errors not popups.. # @todo try.. accept.. nice errors not popups..
try: try:
@@ -25,14 +25,12 @@ browser_step_ui_config = {'Choose one': '0 0',
'Execute JS': '0 1', 'Execute JS': '0 1',
# 'Extract text and use as filter': '1 0', # 'Extract text and use as filter': '1 0',
'Goto site': '0 0', 'Goto site': '0 0',
'Goto URL': '0 1',
'Press Enter': '0 0', 'Press Enter': '0 0',
'Select by label': '1 1', 'Select by label': '1 1',
'Scroll down': '0 0', 'Scroll down': '0 0',
'Uncheck checkbox': '1 0', 'Uncheck checkbox': '1 0',
'Wait for seconds': '0 1', 'Wait for seconds': '0 1',
'Wait for text': '0 1', 'Wait for text': '0 1',
'Wait for text in element': '1 1',
# 'Press Page Down': '0 0', # 'Press Page Down': '0 0',
# 'Press Page Up': '0 0', # 'Press Page Up': '0 0',
# weird bug, come back to it later # weird bug, come back to it later
@@ -55,7 +53,7 @@ class steppable_browser_interface():
print("> action calling", call_action_name) print("> action calling", call_action_name)
# https://playwright.dev/python/docs/selectors#xpath-selectors # https://playwright.dev/python/docs/selectors#xpath-selectors
if selector and selector.startswith('/') and not selector.startswith('//'): if selector.startswith('/') and not selector.startswith('//'):
selector = "xpath=" + selector selector = "xpath=" + selector
action_handler = getattr(self, "action_" + call_action_name) action_handler = getattr(self, "action_" + call_action_name)
@@ -74,10 +72,10 @@ class steppable_browser_interface():
self.page.wait_for_timeout(3 * 1000) self.page.wait_for_timeout(3 * 1000)
print("Call action done in", time.time() - now) print("Call action done in", time.time() - now)
def action_goto_url(self, selector, value): def action_goto_url(self, url, optional_value):
# self.page.set_viewport_size({"width": 1280, "height": 5000}) # self.page.set_viewport_size({"width": 1280, "height": 5000})
now = time.time() now = time.time()
response = self.page.goto(value, timeout=0, wait_until='commit') response = self.page.goto(url, timeout=0, wait_until='commit')
# Wait_until = commit # Wait_until = commit
# - `'commit'` - consider operation to be finished when network response is received and the document started loading. # - `'commit'` - consider operation to be finished when network response is received and the document started loading.
@@ -134,17 +132,6 @@ class steppable_browser_interface():
def action_wait_for_seconds(self, selector, value): def action_wait_for_seconds(self, selector, value):
self.page.wait_for_timeout(int(value) * 1000) self.page.wait_for_timeout(int(value) * 1000)
def action_wait_for_text(self, selector, value):
import json
v = json.dumps(value)
self.page.wait_for_function(f'document.querySelector("body").innerText.includes({v});', timeout=30000)
def action_wait_for_text_in_element(self, selector, value):
import json
s = json.dumps(selector)
v = json.dumps(value)
self.page.wait_for_function(f'document.querySelector({s}).innerText.includes({v});', timeout=30000)
# @todo - in the future make some popout interface to capture what needs to be set # @todo - in the future make some popout interface to capture what needs to be set
# https://playwright.dev/python/docs/api/class-keyboard # https://playwright.dev/python/docs/api/class-keyboard
def action_press_enter(self, selector, value): def action_press_enter(self, selector, value):
+10 -11
View File
@@ -78,18 +78,18 @@ class ReplyWithContentButNoText(Exception):
return return
class Fetcher(): class Fetcher():
error = None
status_code = None
content = None
headers = None
browser_steps = None browser_steps = None
browser_steps_screenshot_path = None browser_steps_screenshot_path = None
content = None
error = None
fetcher_description = "No description" fetcher_description = "No description"
headers = None
status_code = None
webdriver_js_execute_code = None webdriver_js_execute_code = None
xpath_data = None
xpath_element_js = "" xpath_element_js = ""
instock_data = None
instock_data_js = "" xpath_data = None
# Will be needed in the future by the VisualSelector, always get this where possible. # Will be needed in the future by the VisualSelector, always get this where possible.
screenshot = False screenshot = False
@@ -103,7 +103,6 @@ class Fetcher():
from pkg_resources import resource_string from pkg_resources import resource_string
# The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector # The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector
self.xpath_element_js = resource_string(__name__, "res/xpath_element_scraper.js").decode('utf-8') self.xpath_element_js = resource_string(__name__, "res/xpath_element_scraper.js").decode('utf-8')
self.instock_data_js = resource_string(__name__, "res/stock-not-in-stock.js").decode('utf-8')
@abstractmethod @abstractmethod
@@ -298,8 +297,8 @@ class base_html_playwright(Fetcher):
proxy=self.proxy, proxy=self.proxy,
# This is needed to enable JavaScript execution on GitHub and others # This is needed to enable JavaScript execution on GitHub and others
bypass_csp=True, bypass_csp=True,
# Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers # Can't think why we need the service workers for our use case?
service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), service_workers='block',
# Should never be needed # Should never be needed
accept_downloads=False accept_downloads=False
) )
@@ -374,6 +373,7 @@ class base_html_playwright(Fetcher):
raise EmptyReply(url=url, status_code=response.status) raise EmptyReply(url=url, status_code=response.status)
self.status_code = response.status self.status_code = response.status
self.content = self.page.content()
self.headers = response.all_headers() self.headers = response.all_headers()
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc) # So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
@@ -383,7 +383,6 @@ class base_html_playwright(Fetcher):
self.page.evaluate("var include_filters=''") self.page.evaluate("var include_filters=''")
self.xpath_data = self.page.evaluate("async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}") self.xpath_data = self.page.evaluate("async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}")
self.instock_data = self.page.evaluate("async () => {" + self.instock_data_js + "}")
# Bug 3 in Playwright screenshot handling # Bug 3 in Playwright screenshot handling
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
+19 -24
View File
@@ -10,7 +10,7 @@ def same_slicer(l, a, b):
return l[a:b] return l[a:b]
# like .compare but a little different output # like .compare but a little different output
def customSequenceMatcher(before, after, include_equal=False, include_removed=True, include_added=True, include_replaced=True, include_change_type_prefix=True): def customSequenceMatcher(before, after, include_equal=False):
cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \\t", a=before, b=after) cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \\t", a=before, b=after)
# @todo Line-by-line mode instead of buncghed, including `after` that is not in `before` (maybe unset?) # @todo Line-by-line mode instead of buncghed, including `after` that is not in `before` (maybe unset?)
@@ -18,39 +18,34 @@ def customSequenceMatcher(before, after, include_equal=False, include_removed=Tr
if include_equal and tag == 'equal': if include_equal and tag == 'equal':
g = before[alo:ahi] g = before[alo:ahi]
yield g yield g
elif include_removed and tag == 'delete': elif tag == 'delete':
row_prefix = "(removed) " if include_change_type_prefix else '' g = ["(removed) " + i for i in same_slicer(before, alo, ahi)]
g = [ row_prefix + i for i in same_slicer(before, alo, ahi)]
yield g yield g
elif include_replaced and tag == 'replace': elif tag == 'replace':
row_prefix = "(changed) " if include_change_type_prefix else '' g = ["(changed) " + i for i in same_slicer(before, alo, ahi)]
g = [row_prefix + i for i in same_slicer(before, alo, ahi)] g += ["(into ) " + i for i in same_slicer(after, blo, bhi)]
row_prefix = "(into) " if include_change_type_prefix else ''
g += [row_prefix + i for i in same_slicer(after, blo, bhi)]
yield g yield g
elif include_added and tag == 'insert': elif tag == 'insert':
row_prefix = "(added) " if include_change_type_prefix else '' g = ["(added ) " + i for i in same_slicer(after, blo, bhi)]
g = [row_prefix + i for i in same_slicer(after, blo, bhi)]
yield g yield g
# only_differences - only return info about the differences, no context # only_differences - only return info about the differences, no context
# line_feed_sep could be "<br>" or "<li>" or "\n" etc # line_feed_sep could be "<br/>" or "<li>" or "\n" etc
def render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=True, include_replaced=True, line_feed_sep="\n", include_change_type_prefix=True): def render_diff(previous_file, newest_file, include_equal=False, line_feed_sep="\n"):
with open(newest_file, 'r') as f:
newest_version_file_contents = f.read()
newest_version_file_contents = [line.rstrip() for line in newest_version_file_contents.splitlines()]
newest_version_file_contents = [line.rstrip() for line in newest_version_file_contents.splitlines()] if previous_file:
with open(previous_file, 'r') as f:
if previous_version_file_contents: previous_version_file_contents = f.read()
previous_version_file_contents = [line.rstrip() for line in previous_version_file_contents.splitlines()] previous_version_file_contents = [line.rstrip() for line in previous_version_file_contents.splitlines()]
else: else:
previous_version_file_contents = "" previous_version_file_contents = ""
rendered_diff = customSequenceMatcher(before=previous_version_file_contents, rendered_diff = customSequenceMatcher(previous_version_file_contents,
after=newest_version_file_contents, newest_version_file_contents,
include_equal=include_equal, include_equal)
include_removed=include_removed,
include_added=include_added,
include_replaced=include_replaced,
include_change_type_prefix=include_change_type_prefix)
# Recursively join lists # Recursively join lists
f = lambda L: line_feed_sep.join([f(x) if type(x) is list else x for x in L]) f = lambda L: line_feed_sep.join([f(x) if type(x) is list else x for x in L])
@@ -1,5 +1,3 @@
# HTML to TEXT/JSON DIFFERENCE FETCHER
import hashlib import hashlib
import json import json
import logging import logging
@@ -10,14 +8,10 @@ import urllib3
from changedetectionio import content_fetcher, html_tools from changedetectionio import content_fetcher, html_tools
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
from copy import deepcopy from copy import deepcopy
from . import difference_detection_processor
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
name = 'Webpage Text/HTML, JSON and PDF changes'
description = 'Detects all text changes where possible'
class FilterNotFoundInResponse(ValueError): class FilterNotFoundInResponse(ValueError):
def __init__(self, msg): def __init__(self, msg):
ValueError.__init__(self, msg) ValueError.__init__(self, msg)
@@ -29,7 +23,7 @@ class PDFToHTMLToolNotFound(ValueError):
# Some common stuff here that can be moved to a base class # Some common stuff here that can be moved to a base class
# (set_proxy_from_list) # (set_proxy_from_list)
class perform_site_check(difference_detection_processor): class perform_site_check():
screenshot = None screenshot = None
xpath_data = None xpath_data = None
@@ -59,7 +53,7 @@ class perform_site_check(difference_detection_processor):
watch = deepcopy(self.datastore.data['watching'].get(uuid)) watch = deepcopy(self.datastore.data['watching'].get(uuid))
if not watch: if not watch:
raise Exception("Watch no longer exists.") return
# Protect against file:// access # Protect against file:// access
if re.search(r'^file', watch.get('url', ''), re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): if re.search(r'^file', watch.get('url', ''), re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False):
@@ -279,34 +273,6 @@ class perform_site_check(difference_detection_processor):
# Re #340 - return the content before the 'ignore text' was applied # Re #340 - return the content before the 'ignore text' was applied
text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8')
# @todo whitespace coming from missing rtrim()?
# stripped_text_from_html could be based on their preferences, replace the processed text with only that which they want to know about.
# Rewrite's the processing text based on only what diff result they want to see
if watch.has_special_diff_filter_options_set() and len(watch.history.keys()):
# Now the content comes from the diff-parser and not the returned HTTP traffic, so could be some differences
from .. import diff
# needs to not include (added) etc or it may get used twice
# Replace the processed text with the preferred result
rendered_diff = diff.render_diff(previous_version_file_contents=watch.get_last_fetched_before_filters(),
newest_version_file_contents=stripped_text_from_html,
include_equal=False, # not the same lines
include_added=watch.get('filter_text_added', True),
include_removed=watch.get('filter_text_removed', True),
include_replaced=watch.get('filter_text_replaced', True),
line_feed_sep="\n",
include_change_type_prefix=False)
watch.save_last_fetched_before_filters(text_content_before_ignored_filter)
if not rendered_diff and stripped_text_from_html:
# We had some content, but no differences were found
# Store our new file as the MD5 so it will trigger in the future
c = hashlib.md5(text_content_before_ignored_filter.translate(None, b'\r\n\t ')).hexdigest()
return False, {'previous_md5': c}, stripped_text_from_html.encode('utf-8')
else:
stripped_text_from_html = rendered_diff
# Treat pages with no renderable text content as a change? No by default # Treat pages with no renderable text content as a change? No by default
empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0: if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0:
@@ -365,7 +331,6 @@ class perform_site_check(difference_detection_processor):
blocked = True blocked = True
# Filter and trigger works the same, so reuse it # Filter and trigger works the same, so reuse it
# It should return the line numbers that match # It should return the line numbers that match
# Unblock flow if the trigger was found (some text remained after stripped what didnt match)
result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), result = html_tools.strip_ignore_text(content=str(stripped_text_from_html),
wordlist=trigger_text, wordlist=trigger_text,
mode="line numbers") mode="line numbers")
+8 -22
View File
@@ -147,12 +147,12 @@ class ValidateContentFetcherIsReady(object):
except urllib3.exceptions.MaxRetryError as e: except urllib3.exceptions.MaxRetryError as e:
driver_url = some_object.command_executor driver_url = some_object.command_executor
message = field.gettext('Content fetcher \'%s\' did not respond.' % (field.data)) message = field.gettext('Content fetcher \'%s\' did not respond.' % (field.data))
message += '<br>' + field.gettext( message += '<br/>' + field.gettext(
'Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.') 'Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.')
message += '<br>' + field.gettext('Did you follow the instructions in the wiki?') message += '<br/>' + field.gettext('Did you follow the instructions in the wiki?')
message += '<br><br>' + field.gettext('WebDriver Host: %s' % (driver_url)) message += '<br/><br/>' + field.gettext('WebDriver Host: %s' % (driver_url))
message += '<br><a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">Go here for more information</a>' message += '<br/><a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">Go here for more information</a>'
message += '<br>'+field.gettext('Content fetcher did not respond properly, unable to use it.\n %s' % (str(e))) message += '<br/>'+field.gettext('Content fetcher did not respond properly, unable to use it.\n %s' % (str(e)))
raise ValidationError(message) raise ValidationError(message)
@@ -344,15 +344,13 @@ class ValidateCSSJSONXPATHInput(object):
raise ValidationError("A system-error occurred when validating your jq expression") raise ValidationError("A system-error occurred when validating your jq expression")
class quickWatchForm(Form): class quickWatchForm(Form):
from . import processors
url = fields.URLField('URL', validators=[validateURL()]) url = fields.URLField('URL', validators=[validateURL()])
tag = StringField('Group tag', [validators.Optional()]) tag = StringField('Group tag', [validators.Optional()])
watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"}) watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
# Common to a single watch and the global settings # Common to a single watch and the global settings
class commonSettingsForm(Form): class commonSettingsForm(Form):
notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers()]) notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers()])
@@ -363,10 +361,6 @@ class commonSettingsForm(Form):
extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False) extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False)
webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1,
message="Should contain one or more seconds")]) message="Should contain one or more seconds")])
class importForm(Form):
from . import processors
processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
urls = TextAreaField('URLs')
class SingleBrowserStep(Form): class SingleBrowserStep(Form):
@@ -399,19 +393,11 @@ class watchForm(commonSettingsForm):
body = TextAreaField('Request body', [validators.Optional()]) body = TextAreaField('Request body', [validators.Optional()])
method = SelectField('Request method', choices=valid_method, default=default_method) method = SelectField('Request method', choices=valid_method, default=default_method)
ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False) ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False)
check_unique_lines = BooleanField('Only trigger when unique lines appear', default=False) check_unique_lines = BooleanField('Only trigger when new lines appear', default=False)
filter_text_added = BooleanField('Added lines', default=True)
filter_text_replaced = BooleanField('Replaced/changed lines', default=True)
filter_text_removed = BooleanField('Removed lines', default=True)
# @todo this class could be moved to its own text_json_diff_watchForm and this goes to restock_diff_Watchform perhaps
in_stock_only = BooleanField('Only trigger when product goes BACK to in-stock', default=True)
trigger_text = StringListField('Trigger/wait for text', [validators.Optional(), ValidateListRegex()]) trigger_text = StringListField('Trigger/wait for text', [validators.Optional(), ValidateListRegex()])
if os.getenv("PLAYWRIGHT_DRIVER_URL"): if os.getenv("PLAYWRIGHT_DRIVER_URL"):
browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10) browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10)
text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()]) text_should_not_be_present = StringListField('Block change-detection if text matches', [validators.Optional(), ValidateListRegex()])
webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()]) webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()])
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
+1 -16
View File
@@ -8,7 +8,7 @@ import json
import re import re
# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis # HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis
TEXT_FILTER_LIST_LINE_SUFFIX = "<br>" TEXT_FILTER_LIST_LINE_SUFFIX = "<br/>"
# 'price' , 'lowPrice', 'highPrice' are usually under here # 'price' , 'lowPrice', 'highPrice' are usually under here
# all of those may or may not appear on different websites # all of those may or may not appear on different websites
@@ -287,18 +287,3 @@ def workarounds_for_obfuscations(content):
content = re.sub('<!--\s+-->', '', content) content = re.sub('<!--\s+-->', '', content)
return content return content
def get_triggered_text(content, trigger_text):
triggered_text = []
result = strip_ignore_text(content=content,
wordlist=trigger_text,
mode="line numbers")
i = 1
for p in content.splitlines():
if i in result:
triggered_text.append(p)
i += 1
return triggered_text
+1 -6
View File
@@ -29,7 +29,6 @@ class import_url_list(Importer):
data, data,
flash, flash,
datastore, datastore,
processor=None
): ):
urls = data.split("\n") urls = data.split("\n")
@@ -53,11 +52,7 @@ class import_url_list(Importer):
# Flask wtform validators wont work with basic auth, use validators package # Flask wtform validators wont work with basic auth, use validators package
# Up to 5000 per batch so we dont flood the server # Up to 5000 per batch so we dont flood the server
if len(url) and validators.url(url.replace('source:', '')) and good < 5000: if len(url) and validators.url(url.replace('source:', '')) and good < 5000:
extras = None new_uuid = datastore.add_watch(url=url.strip(), tag=tags, write_to_disk_now=False)
if processor:
extras = {'processor': processor}
new_uuid = datastore.add_watch(url=url.strip(), tag=tags, write_to_disk_now=False, extras=extras)
if new_uuid: if new_uuid:
# Straight into the queue. # Straight into the queue.
self.new_uuids.append(new_uuid) self.new_uuids.append(new_uuid)
+23 -88
View File
@@ -20,21 +20,15 @@ base_config = {
'body': None, 'body': None,
'check_unique_lines': False, # On change-detected, compare against all history if its something new 'check_unique_lines': False, # On change-detected, compare against all history if its something new
'check_count': 0, 'check_count': 0,
'date_created': None,
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine. 'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
'extract_text': [], # Extract text by regex after filters 'extract_text': [], # Extract text by regex after filters
'extract_title_as_title': False, 'extract_title_as_title': False,
'fetch_backend': 'system', # plaintext, playwright etc 'fetch_backend': 'system',
'processor': 'text_json_diff', # could be restock_diff or others from .processors
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')), 'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
'filter_text_added': True,
'filter_text_replaced': True,
'filter_text_removed': True,
'has_ldjson_price_data': None, 'has_ldjson_price_data': None,
'track_ldjson_price_data': None, 'track_ldjson_price_data': None,
'headers': {}, # Extra headers to send 'headers': {}, # Extra headers to send
'ignore_text': [], # List of text to ignore when calculating the comparison checksum 'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'in_stock_only' : True, # Only trigger change on going to instock from out-of-stock
'include_filters': [], 'include_filters': [],
'last_checked': 0, 'last_checked': 0,
'last_error': False, 'last_error': False,
@@ -159,9 +153,7 @@ class model(dict):
@property @property
def is_pdf(self): def is_pdf(self):
# content_type field is set in the future # content_type field is set in the future
# https://github.com/dgtlmoon/changedetection.io/issues/1392 return '.pdf' in self.get('url', '').lower() or 'pdf' in self.get('content_type', '').lower()
# Not sure the best logic here
return self.get('url', '').lower().endswith('.pdf') or 'pdf' in self.get('content_type', '').lower()
@property @property
def label(self): def label(self):
@@ -245,32 +237,9 @@ class model(dict):
bump = self.history bump = self.history
return self.__newest_history_key return self.__newest_history_key
def get_history_snapshot(self, timestamp):
import brotli
filepath = self.history[timestamp]
# See if a brotli versions exists and switch to that
if not filepath.endswith('.br') and os.path.isfile(f"{filepath}.br"):
filepath = f"{filepath}.br"
# OR in the backup case that the .br does not exist, but the plain one does
if filepath.endswith('.br') and not os.path.isfile(filepath):
if os.path.isfile(filepath.replace('.br', '')):
filepath = filepath.replace('.br', '')
if filepath.endswith('.br'):
# Brotli doesnt have a fileheader to detect it, so we rely on filename
# https://www.rfc-editor.org/rfc/rfc7932
with open(filepath, 'rb') as f:
return(brotli.decompress(f.read()).decode('utf-8'))
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
# Save some text file to the appropriate path and bump the history # Save some text file to the appropriate path and bump the history
# result_obj from fetch_site_status.run() # result_obj from fetch_site_status.run()
def save_history_text(self, contents, timestamp, snapshot_id): def save_history_text(self, contents, timestamp):
import brotli
self.ensure_data_dir_exists() self.ensure_data_dir_exists()
@@ -279,21 +248,13 @@ class model(dict):
if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key): if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key):
time.sleep(timestamp - self.__newest_history_key) time.sleep(timestamp - self.__newest_history_key)
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024)) snapshot_fname = "{}.txt".format(str(uuid.uuid4()))
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
if not skip_brotli and len(contents) > threshold: # in /diff/ and /preview/ we are going to assume for now that it's UTF-8 when reading
snapshot_fname = f"{snapshot_id}.txt.br" # most sites are utf-8 and some are even broken utf-8
dest = os.path.join(self.watch_data_dir, snapshot_fname) with open(os.path.join(self.watch_data_dir, snapshot_fname), 'wb') as f:
if not os.path.exists(dest): f.write(contents)
with open(dest, 'wb') as f: f.close()
f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
else:
snapshot_fname = f"{snapshot_id}.txt"
dest = os.path.join(self.watch_data_dir, snapshot_fname)
if not os.path.exists(dest):
with open(dest, 'wb') as f:
f.write(contents)
# Append to index # Append to index
# @todo check last char was \n # @todo check last char was \n
@@ -330,8 +291,7 @@ class model(dict):
# Compare each lines (set) against each history text file (set) looking for something new.. # Compare each lines (set) against each history text file (set) looking for something new..
existing_history = set({}) existing_history = set({})
for k, v in self.history.items(): for k, v in self.history.items():
content = self.get_history_snapshot(k) alist = set([line.decode('utf-8').strip().lower() for line in open(v, 'rb')])
alist = set([line.strip().lower() for line in content.splitlines()])
existing_history = existing_history.union(alist) existing_history = existing_history.union(alist)
# Check that everything in local_lines(new stuff) already exists in existing_history - it should # Check that everything in local_lines(new stuff) already exists in existing_history - it should
@@ -346,6 +306,17 @@ class model(dict):
# False is not an option for AppRise, must be type None # False is not an option for AppRise, must be type None
return None return None
def get_screenshot_as_jpeg(self):
# Created by save_screenshot()
fname = os.path.join(self.watch_data_dir, "last-screenshot.jpg")
if os.path.isfile(fname):
return fname
# False is not an option for AppRise, must be type None
return None
def __get_file_ctime(self, filename): def __get_file_ctime(self, filename):
fname = os.path.join(self.watch_data_dir, filename) fname = os.path.join(self.watch_data_dir, filename)
if os.path.isfile(fname): if os.path.isfile(fname):
@@ -392,7 +363,6 @@ class model(dict):
return fname return fname
return False return False
def pause(self): def pause(self):
self['paused'] = True self['paused'] = True
@@ -422,8 +392,8 @@ class model(dict):
# self.history will be keyed with the full path # self.history will be keyed with the full path
for k, fname in self.history.items(): for k, fname in self.history.items():
if os.path.isfile(fname): if os.path.isfile(fname):
if True: with open(fname, "r") as f:
contents = self.get_history_snapshot(k) contents = f.read()
res = re.findall(regex, contents, re.MULTILINE) res = re.findall(regex, contents, re.MULTILINE)
if res: if res:
if not csv_writer: if not csv_writer:
@@ -459,38 +429,3 @@ class model(dict):
# Return list of tags, stripped and lowercase, used for searching # Return list of tags, stripped and lowercase, used for searching
def all_tags(self): def all_tags(self):
return [s.strip().lower() for s in self.get('tag','').split(',')] return [s.strip().lower() for s in self.get('tag','').split(',')]
def has_special_diff_filter_options_set(self):
# All False - nothing would be done, so act like it's not processable
if not self.get('filter_text_added', True) and not self.get('filter_text_replaced', True) and not self.get('filter_text_removed', True):
return False
# Or one is set
if not self.get('filter_text_added', True) or not self.get('filter_text_replaced', True) or not self.get('filter_text_removed', True):
return True
# None is set
return False
def get_last_fetched_before_filters(self):
import brotli
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
if not os.path.isfile(filepath):
# If a previous attempt doesnt yet exist, just snarf the previous snapshot instead
dates = list(self.history.keys())
if len(dates):
return self.get_history_snapshot(dates[-1])
else:
return ''
with open(filepath, 'rb') as f:
return(brotli.decompress(f.read()).decode('utf-8'))
def save_last_fetched_before_filters(self, contents):
import brotli
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
with open(filepath, 'wb') as f:
f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
+17 -23
View File
@@ -5,18 +5,15 @@ import json
valid_tokens = { valid_tokens = {
'base_url': '', 'base_url': '',
'current_snapshot': '',
'diff': '',
'diff_added': '',
'diff_full': '',
'diff_removed': '',
'diff_url': '',
'preview_url': '',
'triggered_text': '',
'watch_tag': '',
'watch_title': '',
'watch_url': '', 'watch_url': '',
'watch_uuid': '', 'watch_uuid': '',
'watch_title': '',
'watch_tag': '',
'diff': '',
'diff_full': '',
'diff_url': '',
'preview_url': '',
'current_snapshot': ''
} }
default_notification_format_for_watch = 'System default' default_notification_format_for_watch = 'System default'
@@ -89,7 +86,7 @@ def process_notification(n_object, datastore):
n_body = jinja2_env.from_string(n_object.get('notification_body', default_notification_body)).render(**notification_parameters) n_body = jinja2_env.from_string(n_object.get('notification_body', default_notification_body)).render(**notification_parameters)
n_title = jinja2_env.from_string(n_object.get('notification_title', default_notification_title)).render(**notification_parameters) n_title = jinja2_env.from_string(n_object.get('notification_title', default_notification_title)).render(**notification_parameters)
n_format = valid_notification_formats.get( n_format = valid_notification_formats.get(
n_object.get('notification_format', default_notification_format), n_object['notification_format'],
valid_notification_formats[default_notification_format], valid_notification_formats[default_notification_format],
) )
@@ -123,10 +120,10 @@ def process_notification(n_object, datastore):
url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png'
if url.startswith('tgram://'): if url.startswith('tgram://'):
# Telegram only supports a limit subset of HTML, remove the '<br>' we place in. # Telegram only supports a limit subset of HTML, remove the '<br/>' we place in.
# re https://github.com/dgtlmoon/changedetection.io/issues/555 # re https://github.com/dgtlmoon/changedetection.io/issues/555
# @todo re-use an existing library we have already imported to strip all non-allowed tags # @todo re-use an existing library we have already imported to strip all non-allowed tags
n_body = n_body.replace('<br>', '\n') n_body = n_body.replace('<br/>', '\n')
n_body = n_body.replace('</br>', '\n') n_body = n_body.replace('</br>', '\n')
# real limit is 4096, but minus some for extra metadata # real limit is 4096, but minus some for extra metadata
payload_max_size = 3600 payload_max_size = 3600
@@ -212,18 +209,15 @@ def create_notification_parameters(n_object, datastore):
tokens.update( tokens.update(
{ {
'base_url': base_url if base_url is not None else '', 'base_url': base_url if base_url is not None else '',
'current_snapshot': n_object['current_snapshot'] if 'current_snapshot' in n_object else '',
'diff': n_object.get('diff', ''), # Null default in the case we use a test
'diff_added': n_object.get('diff_added', ''), # Null default in the case we use a test
'diff_full': n_object.get('diff_full', ''), # Null default in the case we use a test
'diff_removed': n_object.get('diff_removed', ''), # Null default in the case we use a test
'diff_url': diff_url,
'preview_url': preview_url,
'triggered_text': n_object.get('triggered_text', ''),
'watch_tag': watch_tag if watch_tag is not None else '',
'watch_title': watch_title if watch_title is not None else '',
'watch_url': watch_url, 'watch_url': watch_url,
'watch_uuid': uuid, 'watch_uuid': uuid,
'watch_title': watch_title if watch_title is not None else '',
'watch_tag': watch_tag if watch_tag is not None else '',
'diff_url': diff_url,
'diff': n_object.get('diff', ''), # Null default in the case we use a test
'diff_full': n_object.get('diff_full', ''), # Null default in the case we use a test
'preview_url': preview_url,
'current_snapshot': n_object['current_snapshot'] if 'current_snapshot' in n_object else ''
}) })
return tokens return tokens
-11
View File
@@ -1,11 +0,0 @@
# Change detection post-processors
The concept here is to be able to switch between different domain specific problems to solve.
- `text_json_diff` The traditional text and JSON comparison handler
- `restock_diff` Only cares about detecting if a product looks like it has some text that suggests that it's out of stock, otherwise assumes that it's in stock.
Some suggestions for the future
- `graphical`
- `restock_and_price` - extract price AND stock text
-24
View File
@@ -1,24 +0,0 @@
from abc import abstractmethod
import hashlib
class difference_detection_processor():
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@abstractmethod
def run(self, uuid, skip_when_checksum_same=True):
update_obj = {'last_notification_error': False, 'last_error': False}
some_data = 'xxxxx'
update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()
changed_detected = False
return changed_detected, update_obj, ''.encode('utf-8')
def available_processors():
from . import restock_diff, text_json_diff
x=[('text_json_diff', text_json_diff.name), ('restock_diff', restock_diff.name)]
# @todo Make this smarter with introspection of sorts.
return x
@@ -1,125 +0,0 @@
import hashlib
import os
import re
import urllib3
from . import difference_detection_processor
from changedetectionio import content_fetcher
from copy import deepcopy
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
name = 'Re-stock detection for single product pages'
description = 'Detects if the product goes back to in-stock'
class perform_site_check(difference_detection_processor):
screenshot = None
xpath_data = None
def __init__(self, *args, datastore, **kwargs):
super().__init__(*args, **kwargs)
self.datastore = datastore
def run(self, uuid, skip_when_checksum_same=True):
# DeepCopy so we can be sure we don't accidently change anything by reference
watch = deepcopy(self.datastore.data['watching'].get(uuid))
if not watch:
raise Exception("Watch no longer exists.")
# Protect against file:// access
if re.search(r'^file', watch.get('url', ''), re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False):
raise Exception(
"file:// type access is denied for security reasons."
)
# Unset any existing notification error
update_obj = {'last_notification_error': False, 'last_error': False}
extra_headers = watch.get('headers', [])
# Tweak the base config with the per-watch ones
request_headers = deepcopy(self.datastore.data['settings']['headers'])
request_headers.update(extra_headers)
# https://github.com/psf/requests/issues/4525
# Requests doesnt yet support brotli encoding, so don't put 'br' here, be totally sure that the user cannot
# do this by accident.
if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']:
request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '')
timeout = self.datastore.data['settings']['requests'].get('timeout')
url = watch.link
request_body = self.datastore.data['watching'][uuid].get('body')
request_method = self.datastore.data['watching'][uuid].get('method')
ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False)
# Pluggable content fetcher
prefer_backend = watch.get_fetch_backend
if not prefer_backend or prefer_backend == 'system':
prefer_backend = self.datastore.data['settings']['application']['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")
proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=uuid)
proxy_url = None
if proxy_id:
proxy_url = self.datastore.proxy_list.get(proxy_id).get('url')
print("UUID {} Using proxy {}".format(uuid, proxy_url))
fetcher = klass(proxy_override=proxy_url)
# Configurable per-watch or global extra delay before extracting text (for webDriver types)
system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None)
if watch['webdriver_delay'] is not None:
fetcher.render_extract_delay = watch.get('webdriver_delay')
elif system_webdriver_delay is not None:
fetcher.render_extract_delay = system_webdriver_delay
# Could be removed if requests/plaintext could also return some info?
if prefer_backend != 'html_webdriver':
raise Exception("Re-stock detection requires Chrome or compatible webdriver/playwright fetcher to work")
if watch.get('webdriver_js_execute_code') is not None and watch.get('webdriver_js_execute_code').strip():
fetcher.webdriver_js_execute_code = watch.get('webdriver_js_execute_code')
fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, watch.get('include_filters'))
fetcher.quit()
self.screenshot = fetcher.screenshot
self.xpath_data = fetcher.xpath_data
# Track the content type
update_obj['content_type'] = fetcher.headers.get('Content-Type', '')
update_obj["last_check_status"] = fetcher.get_last_status_code()
# Main detection method
fetched_md5 = None
if fetcher.instock_data:
fetched_md5 = hashlib.md5(fetcher.instock_data.encode('utf-8')).hexdigest()
# 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
update_obj["in_stock"] = True if fetcher.instock_data == 'Possibly in stock' else False
# The main thing that all this at the moment comes down to :)
changed_detected = False
if watch.get('previous_md5') and watch.get('previous_md5') != fetched_md5:
# Yes if we only care about it going to instock, AND we are in stock
if watch.get('in_stock_only') and update_obj["in_stock"]:
changed_detected = True
if not watch.get('in_stock_only'):
# All cases
changed_detected = True
# Always record the new checksum
update_obj["previous_md5"] = fetched_md5
return changed_detected, update_obj, fetcher.instock_data.encode('utf-8')
@@ -1,97 +0,0 @@
function isItemInStock() {
// @todo Pass these in so the same list can be used in non-JS fetchers
const outOfStockTexts = [
'0 in stock',
'agotado',
'artikel zurzeit vergriffen',
'as soon as stock is available',
'available for back order',
'backordered',
'brak na stanie',
'brak w magazynie',
'coming soon',
'currently unavailable',
'en rupture de stock',
'item is no longer available',
'message if back in stock',
'nachricht bei',
'nicht auf lager',
'nicht lieferbar',
'nicht zur verfügung',
'no disponible temporalmente',
'no longer in stock',
'not available',
'not in stock',
'notify me when available',
'não estamos a aceitar encomendas',
'out of stock',
'out-of-stock',
'produkt niedostępny',
'sold out',
'temporarily out of stock',
'temporarily unavailable',
'we do not currently have an estimate of when this product will be back in stock.',
'zur zeit nicht an lager',
];
const negateOutOfStockRegexs = [
'[0-9] in stock'
]
var negateOutOfStockRegexs_r = [];
for (let i = 0; i < negateOutOfStockRegexs.length; i++) {
negateOutOfStockRegexs_r.push(new RegExp(negateOutOfStockRegexs[0], 'g'));
}
const elementsWithZeroChildren = Array.from(document.getElementsByTagName('*')).filter(element => element.children.length === 0);
// REGEXS THAT REALLY MEAN IT'S IN STOCK
for (let i = elementsWithZeroChildren.length - 1; i >= 0; i--) {
const element = elementsWithZeroChildren[i];
if (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0) {
var elementText="";
if (element.tagName.toLowerCase() === "input") {
elementText = element.value.toLowerCase();
} else {
elementText = element.textContent.toLowerCase();
}
if (elementText.length) {
// try which ones could mean its in stock
for (let i = 0; i < negateOutOfStockRegexs.length; i++) {
if (negateOutOfStockRegexs_r[i].test(elementText)) {
return 'Possibly in stock';
}
}
}
}
}
// OTHER STUFF THAT COULD BE THAT IT'S OUT OF STOCK
for (let i = elementsWithZeroChildren.length - 1; i >= 0; i--) {
const element = elementsWithZeroChildren[i];
if (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0) {
var elementText="";
if (element.tagName.toLowerCase() === "input") {
elementText = element.value.toLowerCase();
} else {
elementText = element.textContent.toLowerCase();
}
if (elementText.length) {
// and these mean its out of stock
for (const outOfStockText of outOfStockTexts) {
if (elementText.includes(outOfStockText)) {
return elementText; // item is out of stock
}
}
}
}
}
return 'Possibly in stock'; // possibly in stock, cant decide otherwise.
}
// returns the element text that makes it think it's out of stock
return isItemInStock();
-8
View File
@@ -28,11 +28,3 @@ pytest tests/test_notification.py
# Re-run with HIDE_REFERER set - could affect login # Re-run with HIDE_REFERER set - could affect login
export HIDE_REFERER=True export HIDE_REFERER=True
pytest tests/test_access_control.py pytest tests/test_access_control.py
# Re-run a few tests that will trigger brotli based storage
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
pytest tests/test_access_control.py
pytest tests/test_notification.py
pytest tests/test_backend.py
pytest tests/test_rss.py
pytest tests/test_unique_lines.py
-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#FFFFFF"
height="7.5005589"
width="11.248507"
version="1.1"
id="Layer_1"
viewBox="0 0 7.1975545 4.7993639"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs19" />
<g
id="g14"
transform="matrix(-0.01406065,0,0,0.01406065,7.1975543,-1.1990922)">
<g
id="g12">
<g
id="g10">
<path
d="M 468.373,85.28 H 45.333 C 21.227,85.28 0,105.76 0,129.014 V 383.2 c 0,23.147 21.227,43.413 45.333,43.413 h 422.933 c 23.68,0 43.627,-19.84 43.627,-43.413 V 129.014 C 512,105.334 492.053,85.28 468.373,85.28 Z m 0,320 H 45.333 c -12.373,0 -24,-10.773 -24,-22.08 V 129.014 c 0,-11.307 11.84,-22.4 24,-22.4 h 422.933 c 11.733,0 22.293,10.667 22.293,22.4 V 383.2 h 0.107 c 10e-4,11.734 -10.453,22.08 -22.293,22.08 z"
id="path2" />
<path
d="m 440.853,153.974 c -3.307,-4.907 -9.92,-6.187 -14.827,-2.987 L 256,264.48 85.973,151.094 c -4.907,-3.2 -11.52,-1.707 -14.72,3.2 -3.093,4.8 -1.813,11.307 2.88,14.507 l 176,117.333 c 3.627,2.347 8.213,2.347 11.84,0 l 176,-117.333 c 4.8,-3.201 6.187,-9.921 2.88,-14.827 z"
id="path4" />
<path
d="m 143.573,257.654 c -0.107,0.107 -0.32,0.213 -0.427,0.32 L 68.48,311.307 c -4.907,3.307 -6.187,9.92 -2.88,14.827 3.307,4.907 9.92,6.187 14.827,2.88 0.107,-0.107 0.32,-0.213 0.427,-0.32 l 74.667,-53.333 c 4.907,-3.307 6.187,-9.92 2.88,-14.827 -3.308,-4.907 -9.921,-6.187 -14.828,-2.88 z"
id="path6" />
<path
d="m 443.947,311.627 c -0.107,-0.107 -0.32,-0.213 -0.427,-0.32 l -74.667,-53.333 c -4.693,-3.52 -11.413,-2.56 -14.933,2.133 -3.52,4.693 -2.56,11.413 2.133,14.933 0.107,0.107 0.32,0.213 0.427,0.32 l 74.667,53.333 c 4.693,3.52 11.413,2.56 14.933,-2.133 3.52,-4.693 2.56,-11.413 -2.133,-14.933 z"
id="path8" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="61.649mm" height="61.649mm" version="1.1" viewBox="0 0 61.649 61.649" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(66.269 -15.463)" fill="#3056d3"><g transform="matrix(1.423 0 0 1.423 101.16 69.23)" fill="#3056d3"><g transform="matrix(.8229 0 0 .8229 -23.378 -2.3935)" fill="#3056d3"><path d="m-88.248-43.007a26.323 26.323 0 0 0-26.323 26.323 26.323 26.323 0 0 0 26.323 26.323 26.323 26.323 0 0 0 26.323-26.323 26.323 26.323 0 0 0-26.323-26.323zm0 2.8417a23.482 23.482 0 0 1 23.482 23.482 23.482 23.482 0 0 1-23.482 23.482 23.482 23.482 0 0 1-23.482-23.482 23.482 23.482 0 0 1 23.482-23.482z"/><g transform="matrix(.26458 0 0 .26458 -115.65 -44.085)"><path d="m33.02 64.43c0.35-0.05 2.04-0.13 2.04-0.13h25.53s3.17 0.32 3.67 0.53c2.5 1.05 3.98 1.89 6.04 3.57 0.72 0.58 4.12 4.01 4.12 4.01l51.67 57.39s1.61 1.65 1.97 1.94c1.2 0.97 2.48 1.96 3.98 2.32 0.5 0.12 2.72 0.21 2.72 0.21h27.32l-8.83-9.04s-1.31-1.65-1.44-1.94c-0.45-0.93-0.59-2.59-0.13-3.51 0.35-0.69 1.46-1.87 2.23-1.98 1.03-0.14 2.12-0.39 3.02 0.14 0.33 0.2 1.64 1.32 1.64 1.32l17.49 17.49s1.35 1.09 1.6 1.6c0.17 0.34 0.29 0.82 0.15 1.18-0.17 0.42-1.42 1.63-1.42 1.63l-0.94 0.98-15.69 16.37s-1.44 1.4-1.79 1.67c-0.76 0.6-1.99 0.89-2.96 0.9-1.03 0-2.62-1.11-3.26-1.91-0.6-0.76-1.1-2.22-0.77-3.13 0.16-0.45 1.28-1.85 1.28-1.85l11.36-11.3-29.47-0.02-1.68 0.09s-4.16-0.66-5.26-1.03c-1.63-0.56-3.44-1.82-4.75-2.93-0.39-0.33-1.8-1.92-1.8-1.92l-51.7-59.28s-2-2.06-2.43-2.43c-1.37-1.17-2-1.62-3.76-2.34-0.44-0.18-3.45-0.55-3.45-0.55l-24.13-0.22s-2.23-0.15-2.61-0.22c-1.08-0.21-2.16-1.07-2.81-1.83-0.79-0.92-0.59-3.06 0.06-4.09 0.57-0.89 2.14-1.52 3.19-1.66z"/><path d="m86.1 109.7-17.13 19.65s-2 2.06-2.43 2.43c-1.37 1.17-2 1.62-3.76 2.34-0.44 0.18-3.45 0.55-3.45 0.55l-24.13 0.22s-2.23 0.15-2.61 0.22c-1.08 0.21-2.16 1.07-2.81 1.83-0.79 0.92-0.59 3.06 0.06 4.09 0.57 0.89 2.14 1.52 3.19 1.66 0.35 0.05 2.04 0.13 2.04 0.13h25.53s3.17-0.32 3.67-0.53c2.5-1.05 3.98-1.89 6.04-3.57 0.72-0.58 4.12-4.01 4.12-4.01l17.38-19.3z"/><path d="m177.81 67.6c-0.17-0.42-1.42-1.63-1.42-1.63l-0.94-0.98-15.69-16.37s-1.44-1.4-1.79-1.67c-0.76-0.6-1.99-0.89-2.96-0.9-1.03 0-2.62 1.11-3.26 1.91-0.6 0.76-1.1 2.22-0.77 3.13 0.16 0.45 1.28 1.85 1.28 1.85l11.36 11.3-29.47 0.02-1.68-0.09s-4.16 0.66-5.26 1.03c-1.63 0.56-3.44 1.82-4.75 2.93-0.39 0.33-1.8 1.92-1.8 1.92l-18.91 21.69 5.98 5.98 18.38-20.41s1.61-1.65 1.97-1.94c1.2-0.97 2.48-1.96 3.98-2.32 0.5-0.12 2.72-0.21 2.72-0.21h27.32l-8.83 9.04s-1.31 1.65-1.44 1.94c-0.45 0.93-0.59 2.59-0.13 3.51 0.35 0.69 1.46 1.87 2.23 1.98 1.03 0.14 2.12 0.39 3.02-0.14 0.33-0.2 1.64-1.32 1.64-1.32l17.49-17.49s1.35-1.09 1.6-1.6c0.17-0.34 0.29-0.82 0.15-1.18z"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -26,6 +26,9 @@ $(document).ready(function() {
data = { data = {
window_url : window.location.href, window_url : window.location.href,
notification_urls : $('.notification-urls').val(), notification_urls : $('.notification-urls').val(),
notification_title : $('.notification-title').val(),
notification_body : $('.notification-body').val(),
notification_format : $('.notification-format').val(),
} }
for (key in data) { for (key in data) {
if (!data[key].length) { if (!data[key].length) {
@@ -1,37 +0,0 @@
.pagination-page-info {
color: #fff;
font-size: 0.85rem;
text-transform: capitalize;
}
.pagination.menu {
> * {
display: inline-block;
}
li {
display: inline-block;
}
a {
padding: 0.65rem;
margin: 3px;
border: none;
background: #444;
border-radius: 2px;
color: var(--color-text-button);
&.disabled {
display: none;
}
&.active {
font-weight: bold;
background: #888;
}
&:hover {
background: #999;
}
}
}
@@ -5,7 +5,6 @@
@import "parts/_arrows"; @import "parts/_arrows";
@import "parts/_browser-steps"; @import "parts/_browser-steps";
@import "parts/_extra_proxies"; @import "parts/_extra_proxies";
@import "parts/_pagination";
@import "parts/_spinners"; @import "parts/_spinners";
@import "parts/_variables"; @import "parts/_variables";
@@ -242,10 +241,6 @@ body:before {
font-size: 85%; font-size: 85%;
} }
.button-xsmall {
font-size: 70%;
}
.fetch-error { .fetch-error {
padding-top: 1em; padding-top: 1em;
font-size: 80%; font-size: 80%;
@@ -894,21 +889,6 @@ body.full-width {
font-size: .875em; font-size: .875em;
} }
} }
.text-filtering {
h3 {
margin-top: 0;
}
border: 1px solid #ccc;
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
fieldset:last-of-type {
padding-bottom: 0;
.pure-control-group {
padding-bottom: 0;
}
}
}
} }
ul { ul {
@@ -1064,30 +1044,3 @@ ul {
vertical-align: middle; vertical-align: middle;
} }
#quick-watch-processor-type {
color: #fff;
ul {
padding: 0.3rem;
li {
list-style: none;
font-size: 0.8rem;
}
}
}
.restock-label {
&.in-stock {
background-color: var(--color-background-button-green);
color: #fff;
}
&.not-in-stock {
background-color: var(--color-background-button-cancel);
color: #777;
}
padding: 3px;
border-radius: 3px;
white-space: nowrap;
}
@@ -95,32 +95,6 @@ ul#requests-extra_proxies {
ul#requests-extra_proxies table tr { ul#requests-extra_proxies table tr {
display: inline; } display: inline; }
.pagination-page-info {
color: #fff;
font-size: 0.85rem;
text-transform: capitalize; }
.pagination.menu > * {
display: inline-block; }
.pagination.menu li {
display: inline-block; }
.pagination.menu a {
padding: 0.65rem;
margin: 3px;
border: none;
background: #444;
border-radius: 2px;
color: var(--color-text-button); }
.pagination.menu a.disabled {
display: none; }
.pagination.menu a.active {
font-weight: bold;
background: #888; }
.pagination.menu a:hover {
background: #999; }
/* spinner */ /* spinner */
.spinner, .spinner,
.spinner:after { .spinner:after {
@@ -458,9 +432,6 @@ body:before {
.button-small { .button-small {
font-size: 85%; } font-size: 85%; }
.button-xsmall {
font-size: 70%; }
.fetch-error { .fetch-error {
padding-top: 1em; padding-top: 1em;
font-size: 80%; font-size: 80%;
@@ -898,17 +869,6 @@ body.full-width .edit-form {
color: var(--color-text-input-description); } color: var(--color-text-input-description); }
.edit-form .pure-form-message-inline code { .edit-form .pure-form-message-inline code {
font-size: .875em; } font-size: .875em; }
.edit-form .text-filtering {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem; }
.edit-form .text-filtering h3 {
margin-top: 0; }
.edit-form .text-filtering fieldset:last-of-type {
padding-bottom: 0; }
.edit-form .text-filtering fieldset:last-of-type .pure-control-group {
padding-bottom: 0; }
ul { ul {
padding-left: 1em; padding-left: 1em;
@@ -1020,22 +980,3 @@ ul {
display: inline-block; display: inline-block;
height: 0.8rem; height: 0.8rem;
vertical-align: middle; } vertical-align: middle; }
#quick-watch-processor-type {
color: #fff; }
#quick-watch-processor-type ul {
padding: 0.3rem; }
#quick-watch-processor-type ul li {
list-style: none;
font-size: 0.8rem; }
.restock-label {
padding: 3px;
border-radius: 3px;
white-space: nowrap; }
.restock-label.in-stock {
background-color: var(--color-background-button-green);
color: #fff; }
.restock-label.not-in-stock {
background-color: var(--color-background-button-cancel);
color: #777; }
+17 -21
View File
@@ -192,24 +192,27 @@ class ChangeDetectionStore:
tags.sort() tags.sort()
return tags return tags
def unlink_history_file(self, path):
try:
unlink(path)
except (FileNotFoundError, IOError):
pass
# Delete a single watch by UUID # Delete a single watch by UUID
def delete(self, uuid): def delete(self, uuid):
import pathlib
import shutil
with self.lock: with self.lock:
if uuid == 'all': if uuid == 'all':
self.__data['watching'] = {} self.__data['watching'] = {}
# GitHub #30 also delete history records # GitHub #30 also delete history records
for uuid in self.data['watching']: for uuid in self.data['watching']:
path = pathlib.Path(os.path.join(self.datastore_path, uuid)) for path in self.data['watching'][uuid].history.values():
shutil.rmtree(path) self.unlink_history_file(path)
self.needs_write_urgent = True
else: else:
path = pathlib.Path(os.path.join(self.datastore_path, uuid)) for path in self.data['watching'][uuid].history.values():
shutil.rmtree(path) self.unlink_history_file(path)
del self.data['watching'][uuid] del self.data['watching'][uuid]
self.needs_write_urgent = True self.needs_write_urgent = True
@@ -287,7 +290,6 @@ class ChangeDetectionStore:
'method', 'method',
'paused', 'paused',
'previous_md5', 'previous_md5',
'processor',
'subtractive_selectors', 'subtractive_selectors',
'tag', 'tag',
'text_should_not_be_present', 'text_should_not_be_present',
@@ -316,8 +318,7 @@ class ChangeDetectionStore:
# #Re 569 # #Re 569
new_watch = Watch.model(datastore_path=self.datastore_path, default={ new_watch = Watch.model(datastore_path=self.datastore_path, default={
'url': url, 'url': url,
'tag': tag, 'tag': tag
'date_created': int(time.time())
}) })
new_uuid = new_watch['uuid'] new_uuid = new_watch['uuid']
@@ -362,6 +363,11 @@ class ChangeDetectionStore:
f.write(screenshot) f.write(screenshot)
f.close() f.close()
# Make a JPEG that's used in notifications (due to being a smaller size) available
from PIL import Image
im1 = Image.open(target_path)
im1.convert('RGB').save(target_path.replace('.png','.jpg'), quality=int(os.getenv("NOTIFICATION_SCREENSHOT_JPG_QUALITY", 75)))
def save_error_text(self, watch_uuid, contents): def save_error_text(self, watch_uuid, contents):
if not self.data['watching'].get(watch_uuid): if not self.data['watching'].get(watch_uuid):
@@ -680,13 +686,3 @@ class ChangeDetectionStore:
except: except:
continue continue
return return
# We don't know when the date_created was in the past until now, so just add an index number for now.
def update_11(self):
i = 0
for uuid, watch in self.data['watching'].items():
if not watch.get('date_created'):
watch['date_created'] = i
i+=1
return
@@ -17,15 +17,14 @@
<li><code>tgram://</code> bots cant send messages to other bots, so you should specify chat ID of non-bot user.</li> <li><code>tgram://</code> bots cant send messages to other bots, so you should specify chat ID of non-bot user.</li>
<li><code>tgram://</code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li> <li><code>tgram://</code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li>
<li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> for direct API calls (or omit the "<code>s</code>" for non-SSL ie <code>get://</code>)</li> <li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> for direct API calls (or omit the "<code>s</code>" for non-SSL ie <code>get://</code>)</li>
<li>Accepts the <code>{{ '{{token}}' }}</code> placeholders listed below</li>
</ul> </ul>
</div> </div>
<div class="notifications-wrapper"> <div class="notifications-wrapper">
<a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> <a id="send-test-notification" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Send test notification</a>
{% if emailprefix %} {% if emailprefix %}
<a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a> <a id="add-email-helper" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Add email</a>
{% endif %} {% endif %}
<a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a> <a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Notification debug logs</a>
</div> </div>
</div> </div>
<div id="notification-customisation" class="pure-control-group"> <div id="notification-customisation" class="pure-control-group">
@@ -56,66 +55,48 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><code>{{ '{{base_url}}' }}</code></td> <td><code>{{ '{{ base_url }}' }}</code></td>
<td>The URL of the changedetection.io instance you are running.</td> <td>The URL of the changedetection.io instance you are running.</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_url}}' }}</code></td> <td><code>{{ '{{ watch_url }}' }}</code></td>
<td>The URL being watched.</td> <td>The URL being watched.</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_uuid}}' }}</code></td> <td><code>{{ '{{ watch_uuid }}' }}</code></td>
<td>The UUID of the watch.</td> <td>The UUID of the watch.</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_title}}' }}</code></td> <td><code>{{ '{{ watch_title }}' }}</code></td>
<td>The title of the watch.</td> <td>The title of the watch.</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_tag}}' }}</code></td> <td><code>{{ '{{ watch_tag }}' }}</code></td>
<td>The watch label / tag</td> <td>The watch label / tag</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{preview_url}}' }}</code></td> <td><code>{{ '{{ preview_url }}' }}</code></td>
<td>The URL of the preview page generated by changedetection.io.</td> <td>The URL of the preview page generated by changedetection.io.</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_url}}' }}</code></td> <td><code>{{ '{{ diff_url }}' }}</code></td>
<td>The URL of the diff output for the watch.</td> <td>The diff output - differences only</td>
</tr>
<tr>
<td><code>{{ '{{diff}}' }}</code></td>
<td>The diff output - only changes, additions, and removals</td>
</tr>
<tr>
<td><code>{{ '{{diff_added}}' }}</code></td>
<td>The diff output - only changes and additions</td>
</tr>
<tr>
<td><code>{{ '{{diff_removed}}' }}</code></td>
<td>The diff output - only changes and removals</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_full}}' }}</code></td> <td><code>{{ '{{ diff_full }}' }}</code></td>
<td>The diff output - full difference output</td> <td>The diff output - full difference output</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{current_snapshot}}' }}</code></td> <td><code>{{ '{{ current_snapshot }}' }}</code></td>
<td>The current snapshot value, useful when combined with JSON or CSS filters <td>The current snapshot value, useful when combined with JSON or CSS filters
</td> </td>
</tr> </tr>
<tr>
<td><code>{{ '{{triggered_text}}' }}</code></td>
<td>Text that tripped the trigger from filters</td>
</tr>
</tbody> </tbody>
</table> </table>
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<br> <br>
URLs generated by changedetection.io (such as <code>{{ '{{diff_url}}' }}</code>) require the <code>BASE_URL</code> environment variable set.<br> 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 "{{settings_application['current_base_url']}}" Your <code>BASE_URL</code> var is currently "{{settings_application['current_base_url']}}"
<br>
Warning: Contents of <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, and <code>{{ '{{diff_added}}' }}</code> depend on how the difference algorithm perceives the change. For example, an addition or removal could be perceived as a change in some cases. <a target="_new" href="https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removal%7D%7D-notification-tokens">More Here</a> <br>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,7 @@
{% macro pagination(sorted_watches, total_per_page, current_page) %}
{{ sorted_watches|length }}
{% for row in sorted_watches|batch(total_per_page, '&nbsp;') %}
{{ loop.index}}
{% endfor %}
{% endmacro %}
+22 -19
View File
@@ -2,35 +2,35 @@
<html lang="en" data-darkmode="{{ get_darkmode_state() }}"> <html lang="en" data-darkmode="{{ get_darkmode_state() }}">
<head> <head>
<meta charset="utf-8" > <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" > <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content="Self hosted website change detection." > <meta name="description" content="Self hosted website change detection."/>
<title>Change Detection{{extra_title}}</title> <title>Change Detection{{extra_title}}</title>
<link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag %}- {{active_tag}}{% endif %}" href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}" > <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag %}- {{active_tag}}{% endif %}" href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"/>
<link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}" > <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}"/>
<link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}" > <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}"/>
{% if extra_stylesheets %} {% if extra_stylesheets %}
{% for m in extra_stylesheets %} {% for m in extra_stylesheets %}
<link rel="stylesheet" href="{{ m }}?ver=1000" > <link rel="stylesheet" href="{{ m }}?ver=1000"/>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<link rel="apple-touch-icon" sizes="180x180" href="{{url_for('static_content', group='favicons', filename='apple-touch-icon.png')}}"> <link rel="apple-touch-icon" sizes="180x180" href="{{url_for('static_content', group='favicons', filename='apple-touch-icon.png')}}"/>
<link rel="icon" type="image/png" sizes="32x32" href="{{url_for('static_content', group='favicons', filename='favicon-32x32.png')}}"> <link rel="icon" type="image/png" sizes="32x32" href="{{url_for('static_content', group='favicons', filename='favicon-32x32.png')}}"/>
<link rel="icon" type="image/png" sizes="16x16" href="{{url_for('static_content', group='favicons', filename='favicon-16x16.png')}}"> <link rel="icon" type="image/png" sizes="16x16" href="{{url_for('static_content', group='favicons', filename='favicon-16x16.png')}}"/>
<link rel="manifest" href="{{url_for('static_content', group='favicons', filename='site.webmanifest')}}"> <link rel="manifest" href="{{url_for('static_content', group='favicons', filename='site.webmanifest')}}"/>
<link rel="mask-icon" href="{{url_for('static_content', group='favicons', filename='safari-pinned-tab.svg')}}" color="#5bbad5"> <link rel="mask-icon" href="{{url_for('static_content', group='favicons', filename='safari-pinned-tab.svg')}}" color="#5bbad5"/>
<link rel="shortcut icon" href="{{url_for('static_content', group='favicons', filename='favicon.ico')}}"> <link rel="shortcut icon" href="{{url_for('static_content', group='favicons', filename='favicon.ico')}}"/>
<meta name="msapplication-TileColor" content="#da532c"> <meta name="msapplication-TileColor" content="#da532c"/>
<meta name="msapplication-config" content="favicons/browserconfig.xml"> <meta name="msapplication-config" content="favicons/browserconfig.xml"/>
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff"/>
<style> <style>
body::before { body::before {
background-image: url({{url_for('static_content', group='images', filename='gradient-border.png') }}); background-image: url({{url_for('static_content', group='images', filename='gradient-border.png') }});
} }
</style> </style>
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
</head> </head>
<body> <body>
@@ -137,13 +137,16 @@
<li class="message"> <li class="message">
Share this link: Share this link:
<span id="share-link">{{ session['share-link'] }}</span> <span id="share-link">{{ session['share-link'] }}</span>
<img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" > <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}"/>
</li> </li>
</ul> </ul>
{% endif %} {% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</section> </section>
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script> <script
type="text/javascript"
src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}"
defer></script>
</body> </body>
</html> </html>
@@ -6,7 +6,7 @@
action="{{url_for('clear_all_history')}}" action="{{url_for('clear_all_history')}}"
method="POST" method="POST"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
This will remove version history (snapshots) for ALL watches, but keep This will remove version history (snapshots) for ALL watches, but keep
+17 -17
View File
@@ -7,7 +7,7 @@
const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
{% endif %} {% endif %}
</script> </script>
<script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script>
<div id="settings"> <div id="settings">
<h1>Differences</h1> <h1>Differences</h1>
@@ -15,15 +15,15 @@
<fieldset> <fieldset>
<label for="diffWords" class="pure-checkbox"> <label for="diffWords" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffWords" value="diffWords"> Words</label> <input type="radio" name="diff_type" id="diffWords" value="diffWords"/> Words</label>
<label for="diffLines" class="pure-checkbox"> <label for="diffLines" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""> Lines</label> <input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""/> Lines</label>
<label for="diffChars" class="pure-checkbox"> <label for="diffChars" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffChars" value="diffChars"> Chars</label> <input type="radio" name="diff_type" id="diffChars" value="diffChars"/> Chars</label>
<!-- @todo - when mimetype is JSON, select this by default? --> <!-- @todo - when mimetype is JSON, select this by default? -->
<label for="diffJson" class="pure-checkbox"> <label for="diffJson" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffJson" value="diffJson" > JSON</label> <input type="radio" name="diff_type" id="diffJson" value="diffJson" /> JSON</label>
{% if versions|length >= 1 %} {% if versions|length >= 1 %}
<label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label> <label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label>
@@ -43,7 +43,7 @@
<span> <span>
<!-- https://github.com/kpdecker/jsdiff/issues/389 ? --> <!-- https://github.com/kpdecker/jsdiff/issues/389 ? -->
<label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace"> <label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace">
<input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace" > Ignore Whitespace</label> <input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace"/> Ignore Whitespace</label>
</span> </span>
</div> </div>
@@ -51,7 +51,7 @@
<a onclick="next_diff();">Jump</a> <a onclick="next_diff();">Jump</a>
</div> </div>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<div class="tabs"> <div class="tabs">
<ul> <ul>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %}
@@ -72,7 +72,7 @@
<div class="tab-pane-inner" id="error-screenshot"> <div class="tab-pane-inner" id="error-screenshot">
<div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> <div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div>
<img id="error-screenshot-img" style="max-width: 80%" alt="Current error-ing screenshot from most recent request" > <img id="error-screenshot-img" style="max-width: 80%" alt="Current error-ing screenshot from most recent request"/>
</div> </div>
<div class="tab-pane-inner" id="text"> <div class="tab-pane-inner" id="text">
@@ -105,7 +105,7 @@
{% if is_html_webdriver %} {% if is_html_webdriver %}
{% if screenshot %} {% if screenshot %}
<div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> <div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div>
<img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" > <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/>
{% else %} {% else %}
No screenshot available just yet! Try rechecking the page. No screenshot available just yet! Try rechecking the page.
{% endif %} {% endif %}
@@ -117,19 +117,19 @@
<form id="extract-data-form" class="pure-form pure-form-stacked edit-form" <form id="extract-data-form" class="pure-form pure-form-stacked edit-form"
action="{{ url_for('diff_history_page', uuid=uuid) }}#extract" action="{{ url_for('diff_history_page', uuid=uuid) }}#extract"
method="POST"> method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<p>This tool will extract text data from all of the watch history.</p> <p>This tool will extract text data from all of the watch history.</p>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(extract_form.extract_regex) }} {{ render_field(extract_form.extract_regex) }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br> A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br/>
<p> <p>
For example, to extract only the numbers from text &dash;<br> For example, to extract only the numbers from text &dash;</br>
<strong>Raw text</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code><br> <strong>Raw text</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code></br>
<strong>RegEx to extract:</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br> <strong>RegEx to extract:</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br/>
</p> </p>
<p> <p>
<a href="https://RegExr.com/">Be sure to test your RegEx here.</a> <a href="https://RegExr.com/">Be sure to test your RegEx here.</a>
@@ -149,9 +149,9 @@
<script> <script>
const newest_version_timestamp = {{newest_version_timestamp}}; const newest_version_timestamp = {{newest_version_timestamp}};
</script> </script>
<script src="{{url_for('static_content', group='js', filename='diff.min.js')}}"></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script>
{% endblock %} {% endblock %}
+46 -90
View File
@@ -2,7 +2,7 @@
{% block content %} {% block content %}
{% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.jinja' import render_common_settings_form %} {% from '_common_fields.jinja' import render_common_settings_form %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script> <script>
const notification_base_url="{{url_for('ajax_callback_send_notification_test')}}"; const notification_base_url="{{url_for('ajax_callback_send_notification_test')}}";
const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}"; const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}";
@@ -17,12 +17,12 @@
const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}"; const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}";
</script> </script>
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script>
{% if playwright_enabled %} {% if playwright_enabled %}
<script src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script>
{% endif %} {% endif %}
<div class="edit-form monospaced-textarea"> <div class="edit-form monospaced-textarea">
@@ -34,15 +34,8 @@
{% if playwright_enabled %} {% if playwright_enabled %}
<li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li> <li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li>
{% endif %} {% endif %}
{% if watch['processor'] == 'text_json_diff' %}
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li> <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
<li class="tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li> <li class="tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
{% endif %}
{% if watch['processor'] == 'restock_diff' %}
<li class="tab"><a href="#restock">Restock Detection</a></li>
{% endif %}
<li class="tab"><a href="#notifications">Notifications</a></li> <li class="tab"><a href="#notifications">Notifications</a></li>
</ul> </ul>
</div> </div>
@@ -50,24 +43,14 @@
<div class="box-wrap inner"> <div class="box-wrap inner">
<form class="pure-form pure-form-stacked" <form class="pure-form pure-form-stacked"
action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next'), unpause_on_save = request.args.get('unpause_on_save')) }}" method="POST"> action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next'), unpause_on_save = request.args.get('unpause_on_save')) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="tab-pane-inner" id="general"> <div class="tab-pane-inner" id="general">
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }}
<span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span><br> <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span><br/>
<span class="pure-form-message-inline">You can use variables in the URL, perfect for inserting the current date and other logic, <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a></span><br> <span class="pure-form-message-inline">You can use variables in the URL, perfect for inserting the current date and other logic, <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a></span><br/>
<span class="pure-form-message-inline">
{% if watch['processor'] == 'text_json_diff' %}
Current mode: <strong>Webpage Text/HTML, JSON and PDF changes.</strong><br>
<a href="{{url_for('edit_page', uuid=uuid)}}?switch_processor=restock_diff" class="pure-button button-xsmall">Switch to re-stock detection mode.</a>
{% else %}
Current mode: <strong>Re-stock detection.</strong><br>
<a href="{{url_for('edit_page', uuid=uuid)}}?switch_processor=text_json_diff" class="pure-button button-xsmall">Switch to Webpage Text/HTML, JSON and PDF changes mode.</a>
{% endif %}
</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.title, class="m-d") }} {{ render_field(form.title, class="m-d") }}
@@ -123,10 +106,10 @@
{{ render_field(form.webdriver_delay) }} {{ render_field(form.webdriver_delay) }}
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong>
<br> <br/>
This will wait <i>n</i> seconds before extracting the text. This will wait <i>n</i> seconds before extracting the text.
{% if using_global_webdriver_wait %} {% if using_global_webdriver_wait %}
<br><strong>Using the current global default settings</strong> <br/><strong>Using the current global default settings</strong>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -163,7 +146,7 @@ User-Agent: wonderbra 1.0") }}
</div> </div>
{% if playwright_enabled %} {% if playwright_enabled %}
<div class="tab-pane-inner" id="browser-steps"> <div class="tab-pane-inner" id="browser-steps">
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality"> <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}">
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
<!-- <!--
@@ -190,7 +173,7 @@ User-Agent: wonderbra 1.0") }}
</span> </span>
<div class="spinner" style="display: none;"></div> <div class="spinner" style="display: none;"></div>
</span> </span>
<img class="noselect" id="browsersteps-img" src="" style="max-width: 100%; width: 100%;" > <img class="noselect" id="browsersteps-img" src="" style="max-width: 100%; width: 100%;" />
<canvas class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas> <canvas class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas>
</div> </div>
</div> </div>
@@ -220,7 +203,7 @@ User-Agent: wonderbra 1.0") }}
<div class="field-group" id="notification-field-group"> <div class="field-group" id="notification-field-group">
{% if has_default_notification_urls %} {% if has_default_notification_urls %}
<div class="inline-warning"> <div class="inline-warning">
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" > <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!"/>
There are <a href="{{ url_for('settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only &dash; an empty Notification URL list here will still send notifications. There are <a href="{{ url_for('settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only &dash; an empty Notification URL list here will still send notifications.
</div> </div>
{% endif %} {% endif %}
@@ -231,10 +214,9 @@ User-Agent: wonderbra 1.0") }}
</fieldset> </fieldset>
</div> </div>
{% if watch['processor'] == 'text_json_diff' %}
<div class="tab-pane-inner" id="filters-and-triggers"> <div class="tab-pane-inner" id="filters-and-triggers">
<div class="pure-control-group"> <div class="pure-control-group">
<strong>Pro-tips:</strong><br> <strong>Pro-tips:</strong><br/>
<ul> <ul>
<li> <li>
Use the preview page to see your filters and triggers highlighted. Use the preview page to see your filters and triggers highlighted.
@@ -244,6 +226,12 @@ User-Agent: wonderbra 1.0") }}
</li> </li>
</ul> </ul>
</div> </div>
<fieldset>
<div class="pure-control-group">
{{ render_checkbox_field(form.check_unique_lines) }}
<span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span>
</div>
</fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
{% set field = render_field(form.include_filters, {% set field = render_field(form.include_filters,
rows=5, rows=5,
@@ -253,9 +241,9 @@ xpath://body/div/span[contains(@class, 'example-class')]",
%} %}
{{ field }} {{ field }}
{% if '/text()' in field %} {% if '/text()' in field %}
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the &lt;element&gt; contains &lt;![CDATA[]]&gt;</strong></span><br> <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the &lt;element&gt; contains &lt;![CDATA[]]&gt;</strong></span><br/>
{% endif %} {% endif %}
<span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br> <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br/>
<ul> <ul>
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
@@ -278,42 +266,40 @@ xpath://body/div/span[contains(@class, 'example-class')]",
</li> </li>
</ul> </ul>
Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} 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> href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/>
</span> </span>
</div> </div>
<fieldset class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.subtractive_selectors, rows=5, placeholder="header {{ render_field(form.subtractive_selectors, rows=5, placeholder="header
footer footer
nav nav
.stockticker") }} .stockticker") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li> Remove HTML element(s) by CSS selector before text conversion. </li> <li> Remove HTML element(s) by CSS selector before text conversion. </li>
<li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li> <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
</ul> </ul>
</span> </span>
</fieldset> </div>
<div class="text-filtering"> <fieldset class="pure-group">
<fieldset class="pure-group" id="text-filtering-type-options"> {{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line
<h3>Text filtering</h3> /some.regex\d{2}/ for case-INsensitive regex
Limit trigger/ignore/block/extract to;<br> ") }}
{{ render_checkbox_field(form.filter_text_added) }} <span class="pure-form-message-inline">
{{ render_checkbox_field(form.filter_text_replaced) }} <ul>
{{ render_checkbox_field(form.filter_text_removed) }} <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
<span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span> <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
<span class="pure-form-message-inline">So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br> <li>Changing this will affect the comparison checksum which may trigger an alert</li>
<span class="pure-form-message-inline">When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span> <li>Use the preview/show current tab to see ignores</li>
</fieldset> </ul>
</span>
<fieldset class="pure-control-group"> </fieldset>
{{ render_checkbox_field(form.check_unique_lines) }}
<span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span>
</fieldset>
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.trigger_text, rows=5, placeholder="Some text to wait for in a line {{ render_field(form.trigger_text, rows=5, placeholder="Some text to wait for in a line
/some.regex\d{2}/ for case-INsensitive regex /some.regex\d{2}/ for case-INsensitive regex
") }} ") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li> <li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li>
@@ -324,21 +310,6 @@ nav
</span> </span>
</div> </div>
</fieldset> </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">
<ul>
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
<li>Use the preview/show current tab to see ignores</li>
</ul>
</span>
</fieldset>
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.text_should_not_be_present, rows=5, placeholder="For example: Out of stock {{ render_field(form.text_should_not_be_present, rows=5, placeholder="For example: Out of stock
@@ -363,7 +334,7 @@ Unavailable") }}
<li>Extracts text in the final output (line by line) after other filters using regular expressions; <li>Extracts text in the final output (line by line) after other filters using regular expressions;
<ul> <ul>
<li>Regular expression &dash; example <code>/reports.+?2022/i</code></li> <li>Regular expression &dash; example <code>/reports.+?2022/i</code></li>
<li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br></li> <li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br/></li>
<li>Keyword example &dash; example <code>Out of stock</code></li> <li>Keyword example &dash; example <code>Out of stock</code></li>
<li>Use groups to extract just that text &dash; example <code>/reports.+?(\d+)/i</code> returns a list of years only</li> <li>Use groups to extract just that text &dash; example <code>/reports.+?(\d+)/i</code> returns a list of years only</li>
</ul> </ul>
@@ -373,30 +344,16 @@ Unavailable") }}
</span> </span>
</div> </div>
</fieldset> </fieldset>
</div>
</div> </div>
{% endif %}
{% if watch['processor'] == 'restock_diff' %}
<div class="tab-pane-inner" id="restock">
<fieldset>
<div class="pure-control-group">
{{ render_checkbox_field(form.in_stock_only) }}
<span class="pure-form-message-inline">Only trigger notifications when page changes from <strong>out of stock</strong> to <strong>back in stock</strong></span>
</div>
</fieldset>
</div>
{% endif %}
{% if watch['processor'] == 'text_json_diff' %}
<div class="tab-pane-inner visual-selector-ui" id="visualselector"> <div class="tab-pane-inner visual-selector-ui" id="visualselector">
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality"> <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}">
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
{% if visualselector_enabled %} {% if visualselector_enabled %}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection &dash; after the <i>Browser Steps</i> has completed.<br><br> The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection &dash; after the <i>Browser Steps</i> has completed.<br/><br/>
</span> </span>
<div id="selector-header"> <div id="selector-header">
@@ -407,7 +364,7 @@ Unavailable") }}
<!-- request the screenshot and get the element offset info ready --> <!-- request the screenshot and get the element offset info ready -->
<!-- use img src ready load to know everything is ready to map out --> <!-- use img src ready load to know everything is ready to map out -->
<!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' --> <!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' -->
<img id="selector-background" > <img id="selector-background" />
<canvas id="selector-canvas"></canvas> <canvas id="selector-canvas"></canvas>
</div> </div>
<div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong>&nbsp;<span class="text">Loading...</span></div> <div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong>&nbsp;<span class="text">Loading...</span></div>
@@ -421,7 +378,6 @@ Unavailable") }}
</div> </div>
</fieldset> </fieldset>
</div> </div>
{% endif %}
<div id="actions"> <div id="actions">
<div class="pure-control-group"> <div class="pure-control-group">
+11 -13
View File
@@ -1,7 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% from '_helpers.jinja' import render_field %} <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<div class="edit-form monospaced-textarea"> <div class="edit-form monospaced-textarea">
<div class="tabs collapsable"> <div class="tabs collapsable">
@@ -13,8 +12,9 @@
<div class="box-wrap inner"> <div class="box-wrap inner">
<form class="pure-form pure-form-aligned" action="{{url_for('import_page')}}" method="POST"> <form class="pure-form pure-form-aligned" action="{{url_for('import_page')}}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="tab-pane-inner" id="url-list"> <div class="tab-pane-inner" id="url-list">
<fieldset class="pure-group">
<legend> <legend>
Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma
(,): (,):
@@ -23,7 +23,7 @@
<br> <br>
URLs which do not pass validation will stay in the textarea. URLs which do not pass validation will stay in the textarea.
</legend> </legend>
{{ render_field(form.processor, class="processor") }}
<textarea name="urls" class="pure-input-1-2" placeholder="https://" <textarea name="urls" class="pure-input-1-2" placeholder="https://"
style="width: 100%; style="width: 100%;
@@ -31,24 +31,22 @@
white-space: pre; white-space: pre;
overflow-wrap: normal; overflow-wrap: normal;
overflow-x: scroll;" rows="25">{{ import_url_list_remaining }}</textarea> overflow-x: scroll;" rows="25">{{ import_url_list_remaining }}</textarea>
</fieldset>
<div id="quick-watch-processor-type">
</div>
</div> </div>
<div class="tab-pane-inner" id="distill-io"> <div class="tab-pane-inner" id="distill-io">
<fieldset class="pure-group">
<legend> <legend>
Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.<br> Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.</br>
This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored. This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored.
<br> <br/>
<p> <p>
How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br> How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br/>
Be sure to set your default fetcher to Chrome if required.<br> Be sure to set your default fetcher to Chrome if required.</br>
</p> </p>
</legend> </legend>
@@ -77,7 +75,7 @@
] ]
} }
" rows="25">{{ original_distill_json }}</textarea> " rows="25">{{ original_distill_json }}</textarea>
</fieldset>
</div> </div>
<button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button> <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button>
</form> </form>
+2 -2
View File
@@ -4,13 +4,13 @@
<div class="login-form"> <div class="login-form">
<div class="inner"> <div class="inner">
<form class="pure-form pure-form-stacked" action="{{url_for('login')}}" method="POST"> <form class="pure-form pure-form-stacked" action="{{url_for('login')}}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" required="" name="password" value="" <input type="password" id="password" required="" name="password" value=""
size="15" autofocus /> size="15" autofocus />
<input type="hidden" id="email" name="email" value="defaultuser@changedetection.io" > <input type="hidden" id="email" name="email" value="defaultuser@changedetection.io" />
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<button type="submit" class="pure-button pure-button-primary">Login</button> <button type="submit" class="pure-button pure-button-primary">Login</button>
+6 -6
View File
@@ -7,9 +7,9 @@
const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
{% endif %} {% endif %}
</script> </script>
<script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<div class="tabs"> <div class="tabs">
<ul> <ul>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %}
@@ -31,7 +31,7 @@
<div class="tab-pane-inner" id="error-screenshot"> <div class="tab-pane-inner" id="error-screenshot">
<div class="snapshot-age error">{{watch.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> <div class="snapshot-age error">{{watch.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div>
<img id="error-screenshot-img" style="max-width: 80%" alt="Current erroring screenshot from most recent request" > <img id="error-screenshot-img" style="max-width: 80%" alt="Current erroring screenshot from most recent request"/>
</div> </div>
<div class="tab-pane-inner" id="text"> <div class="tab-pane-inner" id="text">
@@ -54,11 +54,11 @@
<div class="tip"> <div class="tip">
For now, Differences are performed on text, not graphically, only the latest screenshot is available. For now, Differences are performed on text, not graphically, only the latest screenshot is available.
</div> </div>
<br> </br>
{% if is_html_webdriver %} {% if is_html_webdriver %}
{% if screenshot %} {% if screenshot %}
<div class="snapshot-age">{{watch.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> <div class="snapshot-age">{{watch.snapshot_screenshot_ctime|format_timestamp_timeago}}</div>
<img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" > <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/>
{% else %} {% else %}
No screenshot available just yet! Try rechecking the page. No screenshot available just yet! Try rechecking the page.
{% endif %} {% endif %}
@@ -67,4 +67,4 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+14 -14
View File
@@ -9,10 +9,10 @@
const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}');
{% endif %} {% endif %}
</script> </script>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
<div class="edit-form"> <div class="edit-form">
<div class="tabs collapsable"> <div class="tabs collapsable">
<ul> <ul>
@@ -26,7 +26,7 @@
</div> </div>
<div class="box-wrap inner"> <div class="box-wrap inner">
<form class="pure-form pure-form-stacked settings" action="{{url_for('settings_page')}}" method="POST"> <form class="pure-form pure-form-stacked settings" action="{{url_for('settings_page')}}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="tab-pane-inner" id="general"> <div class="tab-pane-inner" id="general">
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
@@ -40,7 +40,7 @@
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }} {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }}
<span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification <span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification
<br> <br/>
Set to <strong>0</strong> to disable Set to <strong>0</strong> to disable
</span> </span>
</div> </div>
@@ -66,7 +66,7 @@
{{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/", {{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/",
class="m-d") }} class="m-d") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notifications and RSS links.<br>Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"), Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notifications and RSS links.<br/>Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"),
<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>.
</span> </span>
</div> </div>
@@ -105,13 +105,13 @@
<p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> <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, set by the ENV var 'WEBDRIVER_URL'. </p> <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p>
</span> </span>
<br> <br/>
Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a> Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a>
</div> </div>
<fieldset class="pure-group" id="webdriver-override-options"> <fieldset class="pure-group" id="webdriver-override-options">
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong>
<br> <br/>
This will wait <i>n</i> seconds before extracting the text. This will wait <i>n</i> seconds before extracting the text.
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
@@ -124,14 +124,14 @@
<fieldset class="pure-group"> <fieldset class="pure-group">
{{ render_checkbox_field(form.application.form.ignore_whitespace) }} {{ render_checkbox_field(form.application.form.ignore_whitespace) }}
<span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br> <span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br/>
<i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc. <i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc.
</span> </span>
</fieldset> </fieldset>
<fieldset class="pure-group"> <fieldset class="pure-group">
{{ render_checkbox_field(form.application.form.render_anchor_tag_content) }} {{ render_checkbox_field(form.application.form.render_anchor_tag_content) }}
<span class="pure-form-message-inline">Render anchor tag content, default disabled, when enabled renders links as <code>(link text)[https://somesite.com]</code> <span class="pure-form-message-inline">Render anchor tag content, default disabled, when enabled renders links as <code>(link text)[https://somesite.com]</code>
<br> <br/>
<i>Note:</i> Changing this could affect the content of your existing watches, possibly trigger alerts etc. <i>Note:</i> Changing this could affect the content of your existing watches, possibly trigger alerts etc.
</span> </span>
</fieldset> </fieldset>
@@ -151,7 +151,7 @@ nav
{{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line {{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line
/some.regex\d{2}/ for case-INsensitive regex /some.regex\d{2}/ for case-INsensitive regex
") }} ") }}
<span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br> <span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br/>
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Note: This is applied globally in addition to the per-watch rules.</li> <li>Note: This is applied globally in addition to the per-watch rules.</li>
@@ -170,8 +170,8 @@ nav
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.api_access_token_enabled) }} {{ render_checkbox_field(form.application.form.api_access_token_enabled) }}
<div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header</div><br> <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header</div><br/>
<div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span> <div class="pure-form-message-inline"><br/>API Key <span id="api-key">{{api_key}}</span>
<span style="display:none;" id="api-key-copy" >copy</span> <span style="display:none;" id="api-key-copy" >copy</span>
</div> </div>
</div> </div>
@@ -181,7 +181,7 @@ nav
<p><strong>Tip</strong>: You can connect to websites using <a href="https://brightdata.grsm.io/n0r16zf7eivq">BrightData</a> proxies, their service <strong>WebUnlocker</strong> will solve most CAPTCHAs, whilst their <strong>Residential Proxies</strong> may help to avoid CAPTCHA altogether. </p> <p><strong>Tip</strong>: You can connect to websites using <a href="https://brightdata.grsm.io/n0r16zf7eivq">BrightData</a> proxies, their service <strong>WebUnlocker</strong> will solve most CAPTCHAs, whilst their <strong>Residential Proxies</strong> may help to avoid CAPTCHA altogether. </p>
<p>It may be easier to try <strong>WebUnlocker</strong> first, WebUnlocker also supports country selection.</p> <p>It may be easier to try <strong>WebUnlocker</strong> first, WebUnlocker also supports country selection.</p>
<p> <p>
When you have <a href="https://brightdata.grsm.io/n0r16zf7eivq">registered</a>, enabled the required services, visit the <A href="https://brightdata.com/cp/api_example?">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the example URL below<br> When you have <a href="https://brightdata.grsm.io/n0r16zf7eivq">registered</a>, enabled the required services, visit the <A href="https://brightdata.com/cp/api_example?">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the example URL below<br/>
The Proxy URL with BrightData should start with <code>http://brd-customer...</code> The Proxy URL with BrightData should start with <code>http://brd-customer...</code>
</p> </p>
+42 -57
View File
@@ -1,13 +1,14 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% from '_helpers.jinja' import render_simple_field, render_field %} {% from '_helpers.jinja' import render_simple_field, render_field %}
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> {% from '_pagination.jinja' import pagination %}
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
<div class="box"> <div class="box">
<form class="pure-form" action="{{ url_for('form_quick_watch_add') }}" method="POST" id="new-watch-form"> <form class="pure-form" action="{{ url_for('form_quick_watch_add') }}" method="POST" id="new-watch-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<fieldset> <fieldset>
<legend>Add a new change detection watch</legend> <legend>Add a new change detection watch</legend>
<div id="watch-add-wrapper-zone"> <div id="watch-add-wrapper-zone">
@@ -20,29 +21,21 @@
{{ render_simple_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }} {{ render_simple_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
</div> </div>
</div> </div>
<div id="quick-watch-processor-type">
{{ render_simple_field(form.processor, title="Edit first then Watch") }}
</div>
</fieldset> </fieldset>
<span style="color:#eee; font-size: 80%;"><img alt="Create a shareable link" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></span> <span style="color:#eee; font-size: 80%;"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" /> Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></a></span>
</form> </form>
<form class="pure-form" action="{{ url_for('form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> <form class="pure-form" action="{{ url_for('form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div id="checkbox-operations"> <div id="checkbox-operations">
<button class="pure-button button-secondary button-xsmall" name="op" value="pause">Pause</button> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="pause">Pause</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="unpause">UnPause</button> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="unpause">UnPause</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="mute">Mute</button> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="mute">Mute</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="unmute">UnMute</button> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="unmute">UnMute</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="recheck">Recheck</button> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="recheck">Recheck</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="mark-viewed">Mark viewed</button> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="notification-default">Use default notification</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="notification-default">Use default notification</button>
<button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button> <button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button>
</div> </div>
{% if watches|length >= pagination.per_page %}
{{ pagination.info }}
{% endif %}
<div> <div>
<a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a> <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a>
{% for tag in tags %} {% for tag in tags %}
@@ -52,55 +45,59 @@
{% endfor %} {% endfor %}
</div> </div>
{% set sort_order = sort_order or 'asc' %} {% set sort_order = request.args.get('order', 'asc') == 'asc' %}
{% set sort_attribute = sort_attribute or 'last_changed' %} {% set sort_attribute = request.args.get('sort', 'last_changed') %}
{% set pagination_page = request.args.get('page', 0) %} {% set pagination_page = request.args.get('page', 0) %}
<div id="watch-table-wrapper"> <div id="watch-table-wrapper">
<table class="pure-table pure-table-striped watch-table"> <table class="pure-table pure-table-striped watch-table">
<thead> <thead>
<tr> <tr>
{% set link_order = "desc" if sort_order == 'asc' else "asc" %} <th><input style="vertical-align: middle" type="checkbox" id="check-all"/> #</th>
{% set arrow_span = "" %}
<th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}" href="{{url_for('index', sort='date_created', order=link_order, tag=active_tag)}}"># <span class='arrow {{link_order}}'></span></a></th>
<th></th> <th></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order, tag=active_tag)}}">Website <span class='arrow {{link_order}}'></span></a></th> {% set link_order = "desc" if sort_order else "asc" %}
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th> {% set arrow_span = "" %}
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th> <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order)}}">Website <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))[pagination.skip:pagination.skip+pagination.per_page] %} {% set sorted_watches = watches|sort(attribute=sort_attribute, reverse=sort_order) %}
{% for watch in sorted_watches %}
{# WIP for pagination, disabled for now
{% if not ( loop.index >= 3 and loop.index <=4) %}{% continue %}{% endif %} -->
#}
<tr id="{{ watch.uuid }}" <tr id="{{ watch.uuid }}"
class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }} class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }}
{% if watch.last_error is defined and watch.last_error != False %}error{% endif %} {% if watch.last_error is defined and watch.last_error != False %}error{% endif %}
{% if watch.last_notification_error is defined and watch.last_notification_error != False %}error{% endif %} {% if watch.last_notification_error is defined and watch.last_notification_error != False %}error{% endif %}
{% if watch.paused is defined and watch.paused != False %}paused{% endif %} {% if watch.paused is defined and watch.paused != False %}paused{% endif %}
{% if watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}unviewed{% endif %} {% if watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}unviewed{% endif %}
{% if watch.uuid in queued_uuids %}queued{% endif %}"> {% if watch.uuid in queued_uuids %}queued{% endif %}">
<td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td> <td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td>
<td class="inline watch-controls"> <td class="inline watch-controls">
{% if not watch.paused %} {% if not watch.paused %}
<a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a> <a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause"/></a>
{% else %} {% else %}
<a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a> <a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause"/></a>
{% endif %} {% endif %}
<a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a> <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute"/></a>
</td> </td>
<td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} <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.link.replace('source:','') }}"></a> <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
<a class="link-spread" href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a> <a class="link-spread" href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img class="status-icon" src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" /></a>
{% if watch.get_fetch_backend == "html_webdriver" {% if watch.get_fetch_backend == "html_webdriver"
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' ) or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
%} %}
<img class="status-icon" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" title="Using a chrome browser" > <img class="status-icon" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" title="Using a chrome browser" />
{% endif %} {% endif %}
{%if watch.is_pdf %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" >{% endif %} {%if watch.is_pdf %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" />{% endif %}
{% if watch.last_error is defined and watch.last_error != False %} {% if watch.last_error is defined and watch.last_error != False %}
<div class="fetch-error">{{ watch.last_error }} <div class="fetch-error">{{ watch.last_error }}
@@ -116,27 +113,12 @@
{% if watch.last_notification_error is defined and watch.last_notification_error != False %} {% if watch.last_notification_error is defined and watch.last_notification_error != False %}
<div class="fetch-error notification-error"><a href="{{url_for('notification_logs')}}">{{ watch.last_notification_error }}</a></div> <div class="fetch-error notification-error"><a href="{{url_for('notification_logs')}}">{{ watch.last_notification_error }}</a></div>
{% endif %} {% endif %}
{% if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] %}
{% if watch['processor'] == 'text_json_diff' %} <div class="ldjson-price-track-offer">Embedded price data detected, follow only price data? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
{% if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] %}
<div class="ldjson-price-track-offer">Embedded price data detected, follow only price data? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
{% endif %}
{% if watch['track_ldjson_price_data'] == 'accepted' %}
<span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}" class="status-icon price-follow-tag-icon" > Price</span>
{% endif %}
{% endif %} {% endif %}
{% if watch['track_ldjson_price_data'] == 'accepted' %}
{% if watch['processor'] == 'restock_diff' %} <span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}" class="status-icon price-follow-tag-icon"/> Price</span>
<span class="restock-label {{'in-stock' if watch['in_stock'] else 'not-in-stock' }}" title="detecting restock conditions">
<!-- maybe some object watch['processor'][restock_diff] or.. -->
{% if watch['last_checked'] %}
{% if watch['in_stock'] %} In stock {% else %} Not in stock {% endif %}
{% else %}
Not yet checked
{% endif %}
</span>
{% endif %} {% endif %}
{% if not active_tag %} {% if not active_tag %}
<span class="watch-tag-list">{{ watch.tag}}</span> <span class="watch-tag-list">{{ watch.tag}}</span>
{% endif %} {% endif %}
@@ -178,7 +160,10 @@
<a href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> <a href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a>
</li> </li>
</ul> </ul>
{{ pagination.links }} {# WIP for pagination, disabled for now
{{ pagination(sorted_watches,3, pagination_page) }}
#}
</div> </div>
</form> </form>
</div> </div>
@@ -1,2 +0,0 @@
"""Tests for the app."""
@@ -1,3 +0,0 @@
#!/usr/bin/python3
from .. import conftest
@@ -1,106 +0,0 @@
#!/usr/bin/python3
import os
import time
from flask import url_for
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
from changedetectionio.notification import (
default_notification_body,
default_notification_format,
default_notification_title,
valid_notification_formats,
)
def set_original_response():
test_return_data = """<html>
<body>
Some initial text<br>
<p>Which is across multiple lines</p>
<br>
So let's see what happens. <br>
<div>price: $10.99</div>
<div id="sametext">Out of stock</div>
</body>
</html>
"""
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write(test_return_data)
return None
def set_back_in_stock_response():
test_return_data = """<html>
<body>
Some initial text<br>
<p>Which is across multiple lines</p>
<br>
So let's see what happens. <br>
<div>price: $10.99</div>
<div id="sametext">Available!</div>
</body>
</html>
"""
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write(test_return_data)
return None
# Add a site in paused mode, add an invalid filter, we should still have visual selector data ready
def test_restock_detection(client, live_server):
set_original_response()
#assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
time.sleep(1)
live_server_setup(live_server)
#####################
notification_url = url_for('test_notification_endpoint', _external=True).replace('http://localhost', 'http://changedet').replace('http', 'json')
#####################
# Set this up for when we remove the notification from the watch, it should fallback with these details
res = client.post(
url_for("settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title "+default_notification_title,
"application-notification_body": "fallback-body "+default_notification_body,
"application-notification_format": default_notification_format,
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_webdriver"},
follow_redirects=True
)
# Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url
test_url = url_for('test_endpoint', _external=True).replace('http://localhost', 'http://changedet')
client.post(
url_for("form_quick_watch_add"),
data={"url": test_url, "tag": '', 'processor': 'restock_diff'},
follow_redirects=True
)
# Is it correctly show as NOT in stock?
wait_for_all_checks(client)
res = client.get(url_for("index"))
assert b'not-in-stock' in res.data
# Is it correctly shown as in stock
set_back_in_stock_response()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("index"))
assert b'not-in-stock' not in res.data
# We should have a notification
time.sleep(2)
assert os.path.isfile("test-datastore/notification.txt")
os.unlink("test-datastore/notification.txt")
# Default behaviour is to only fire notification when it goes OUT OF STOCK -> IN STOCK
# So here there should be no file, because we go IN STOCK -> OUT OF STOCK
set_original_response()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
assert not os.path.isfile("test-datastore/notification.txt")
@@ -1,176 +0,0 @@
#!/usr/bin/python3
import time
from flask import url_for
from .util import live_server_setup
from changedetectionio import html_tools
def set_original(excluding=None, add_line=None):
test_return_data = """<html>
<body>
<p>Some initial text</p>
<p>So let's see what happens.</p>
<p>and a new line!</p>
<p>The golden line</p>
<p>A BREAK TO MAKE THE TOP LINE STAY AS "REMOVED" OR IT WILL GET COUNTED AS "CHANGED INTO"</p>
<p>Something irrelevant</p>
</body>
</html>
"""
if add_line:
c=test_return_data.splitlines()
c.insert(5, add_line)
test_return_data = "\n".join(c)
if excluding:
output = ""
for i in test_return_data.splitlines():
if not excluding in i:
output += f"{i}\n"
test_return_data = output
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write(test_return_data)
def test_setup(client, live_server):
live_server_setup(live_server)
def test_check_removed_line_contains_trigger(client, live_server):
sleep_time_for_fetch_thread = 3
# Give the endpoint time to spin up
time.sleep(1)
set_original()
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("import_page"),
data={"urls": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data
# Give the thread time to pick it up
time.sleep(sleep_time_for_fetch_thread)
# Goto the edit page, add our ignore text
# Add our URL to the import page
res = client.post(
url_for("edit_page", uuid="first"),
data={"trigger_text": 'The golden line',
"url": test_url,
'fetch_backend': "html_requests",
'filter_text_removed': 'y'},
follow_redirects=True
)
assert b"Updated watch." in res.data
time.sleep(sleep_time_for_fetch_thread)
set_original(excluding='Something irrelevant')
# A line thats not the trigger should not trigger anything
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
assert b'1 watches queued for rechecking.' in res.data
time.sleep(sleep_time_for_fetch_thread)
res = client.get(url_for("index"))
assert b'unviewed' not in res.data
# The trigger line is REMOVED, this should trigger
set_original(excluding='The golden line')
client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(sleep_time_for_fetch_thread)
res = client.get(url_for("index"))
assert b'unviewed' in res.data
# Now add it back, and we should not get a trigger
client.get(url_for("mark_all_viewed"), follow_redirects=True)
set_original(excluding=None)
client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(sleep_time_for_fetch_thread)
res = client.get(url_for("index"))
assert b'unviewed' not in res.data
# Remove it again, and we should get a trigger
set_original(excluding='The golden line')
client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(sleep_time_for_fetch_thread)
res = client.get(url_for("index"))
assert b'unviewed' in res.data
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data
def test_check_add_line_contains_trigger(client, live_server):
sleep_time_for_fetch_thread = 3
# Give the endpoint time to spin up
time.sleep(1)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}"
res = client.post(
url_for("settings_page"),
data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
"application-notification_body": 'triggered text was -{{triggered_text}}-',
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_urls": test_notification_url,
"application-minutes_between_check": 180,
"application-fetch_backend": "html_requests"
},
follow_redirects=True
)
assert b'Settings updated' in res.data
set_original()
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("import_page"),
data={"urls": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data
# Give the thread time to pick it up
time.sleep(sleep_time_for_fetch_thread)
# Goto the edit page, add our ignore text
# Add our URL to the import page
res = client.post(
url_for("edit_page", uuid="first"),
data={"trigger_text": 'Oh yes please',
"url": test_url,
'fetch_backend': "html_requests",
'filter_text_removed': '',
'filter_text_added': 'y'},
follow_redirects=True
)
assert b"Updated watch." in res.data
time.sleep(sleep_time_for_fetch_thread)
set_original(excluding='Something irrelevant')
# A line thats not the trigger should not trigger anything
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
assert b'1 watches queued for rechecking.' in res.data
time.sleep(sleep_time_for_fetch_thread)
res = client.get(url_for("index"))
assert b'unviewed' not in res.data
# The trigger line is ADDED, this should trigger
set_original(add_line='<p>Oh yes please</p>')
client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(sleep_time_for_fetch_thread)
res = client.get(url_for("index"))
assert b'unviewed' in res.data
with open("test-datastore/notification.txt", 'r') as f:
response= f.read()
assert '-Oh yes please-' in response
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data
+6 -6
View File
@@ -11,10 +11,10 @@ import uuid
def set_original_response(): def set_original_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="sametext">Some text thats the same</div> <div id="sametext">Some text thats the same</div>
<div id="changetext">Some text that will change</div> <div id="changetext">Some text that will change</div>
</body> </body>
@@ -29,10 +29,10 @@ def set_original_response():
def set_modified_response(): def set_modified_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>which has this one new line</p> <p>which has this one new line</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="sametext">Some text thats the same</div> <div id="sametext">Some text thats the same</div>
<div id="changetext">Some text that changes</div> <div id="changetext">Some text that changes</div>
</body> </body>
@@ -7,10 +7,10 @@ from .util import live_server_setup, extract_UUID_from_client, extract_api_key_f
def set_response_with_ldjson(): def set_response_with_ldjson():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div class="sametext">Some text thats the same</div> <div class="sametext">Some text thats the same</div>
<div class="changetext">Some text that will change</div> <div class="changetext">Some text that will change</div>
<script type="application/ld+json"> <script type="application/ld+json">
@@ -61,10 +61,10 @@ def set_response_with_ldjson():
def set_response_without_ldjson(): def set_response_without_ldjson():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div class="sametext">Some text thats the same</div> <div class="sametext">Some text thats the same</div>
<div class="changetext">Some text that will change</div> <div class="changetext">Some text that will change</div>
</body> </body>
@@ -143,4 +143,4 @@ def test_check_ldjson_price_autodetect(client, live_server):
assert b'ldjson-price-track-offer' not in res.data assert b'ldjson-price-track-offer' not in res.data
########################################################################################## ##########################################################################################
client.get(url_for("form_delete", uuid="all"), follow_redirects=True) client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
+2 -2
View File
@@ -11,7 +11,7 @@ sleep_time_for_fetch_thread = 3
# Basic test to check inscriptus is not adding return line chars, basically works etc # Basic test to check inscriptus is not adding return line chars, basically works etc
def test_inscriptus(): def test_inscriptus():
from inscriptis import get_text from inscriptis import get_text
html_content = "<html><body>test!<br>ok man</body></html>" html_content = "<html><body>test!<br/>ok man</body></html>"
stripped_text_from_html = get_text(html_content) stripped_text_from_html = get_text(html_content)
assert stripped_text_from_html == 'test!\nok man' assert stripped_text_from_html == 'test!\nok man'
@@ -82,7 +82,7 @@ def test_check_basic_change_detection_functionality(client, live_server):
assert b'<rss' in res.data assert b'<rss' in res.data
# re #16 should have the diff in here too # re #16 should have the diff in here too
assert b'(into) which has this one new line' in res.data assert b'(into ) which has this one new line' in res.data
assert b'CDATA' in res.data assert b'CDATA' in res.data
assert expected_url.encode('utf-8') in res.data assert expected_url.encode('utf-8') in res.data
@@ -8,10 +8,10 @@ from changedetectionio import html_tools
def set_original_ignore_response(): def set_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -24,10 +24,10 @@ def set_original_ignore_response():
def set_modified_original_ignore_response(): def set_modified_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some NEW nice initial text<br> Some NEW nice initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<p>new ignore stuff</p> <p>new ignore stuff</p>
<p>out of stock</p> <p>out of stock</p>
<p>blah</p> <p>blah</p>
@@ -44,11 +44,11 @@ def set_modified_original_ignore_response():
def set_modified_response_minus_block_text(): def set_modified_response_minus_block_text():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some NEW nice initial text<br> Some NEW nice initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<p>now on sale $2/p> <p>now on sale $2/p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<p>new ignore stuff</p> <p>new ignore stuff</p>
<p>blah</p> <p>blah</p>
</body> </body>
@@ -87,10 +87,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server):
# Add our URL to the import page # Add our URL to the import page
res = client.post( res = client.post(
url_for("edit_page", uuid="first"), url_for("edit_page", uuid="first"),
data={"text_should_not_be_present": ignore_text, data={"text_should_not_be_present": ignore_text, "url": test_url, 'fetch_backend': "html_requests"},
"url": test_url,
'fetch_backend': "html_requests"
},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -132,6 +129,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server):
set_modified_response_minus_block_text() set_modified_response_minus_block_text()
client.get(url_for("form_watch_checknow"), follow_redirects=True) client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(sleep_time_for_fetch_thread) time.sleep(sleep_time_for_fetch_thread)
res = client.get(url_for("index")) res = client.get(url_for("index"))
assert b'unviewed' in res.data assert b'unviewed' in res.data
+6 -6
View File
@@ -12,10 +12,10 @@ def test_setup(live_server):
def set_original_response(): def set_original_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="sametext">Some text thats the same</div> <div id="sametext">Some text thats the same</div>
<div id="changetext">Some text that will change</div> <div id="changetext">Some text that will change</div>
</body> </body>
@@ -29,10 +29,10 @@ def set_original_response():
def set_modified_response(): def set_modified_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>which has this one new line</p> <p>which has this one new line</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="sametext">Some text thats the same</div> <div id="sametext">Some text thats the same</div>
<div id="changetext">Some text that changes</div> <div id="changetext">Some text that changes</div>
</body> </body>
@@ -25,10 +25,10 @@ def set_original_response():
</ul> </ul>
</nav> </nav>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="changetext">Some text that will change</div> <div id="changetext">Some text that will change</div>
</body> </body>
<footer> <footer>
@@ -54,10 +54,10 @@ def set_modified_response():
</ul> </ul>
</nav> </nav>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="changetext">Some text that changes</div> <div id="changetext">Some text that changes</div>
</body> </body>
<footer> <footer>
@@ -71,6 +71,7 @@ def set_modified_response():
def test_element_removal_output(): def test_element_removal_output():
from changedetectionio import fetch_site_status
from inscriptis import get_text from inscriptis import get_text
# Check text with sub-parts renders correctly # Check text with sub-parts renders correctly
@@ -84,7 +85,7 @@ def test_element_removal_output():
</ul> </ul>
</nav> </nav>
<body> <body>
Some initial text<br> Some initial text</br>
<p>across multiple lines</p> <p>across multiple lines</p>
<div id="changetext">Some text that changes</div> <div id="changetext">Some text that changes</div>
</body> </body>
@@ -59,8 +59,6 @@ def test_http_error_handler(client, live_server):
_runner_test_http_errors(client, live_server, 404, 'Page not found') _runner_test_http_errors(client, live_server, 404, 'Page not found')
_runner_test_http_errors(client, live_server, 500, '(Internal server Error) received') _runner_test_http_errors(client, live_server, 500, '(Internal server Error) received')
_runner_test_http_errors(client, live_server, 400, 'Error - Request returned a HTTP error code 400') _runner_test_http_errors(client, live_server, 400, 'Error - Request returned a HTTP error code 400')
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data
# Just to be sure error text is properly handled # Just to be sure error text is properly handled
def test_DNS_errors(client, live_server): def test_DNS_errors(client, live_server):
@@ -83,48 +81,4 @@ def test_DNS_errors(client, live_server):
assert found_name_resolution_error assert found_name_resolution_error
# Should always record that we tried # Should always record that we tried
assert bytes("just now".encode('utf-8')) in res.data assert bytes("just now".encode('utf-8')) in res.data
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data
# Re 1513
def test_low_level_errors_clear_correctly(client, live_server):
#live_server_setup(live_server)
# Give the endpoint time to spin up
time.sleep(1)
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("<html><body><div id=here>Hello world</div></body></html>")
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("import_page"),
data={"urls": "https://dfkjasdkfjaidjfsdajfksdajfksdjfDOESNTEXIST.com"},
follow_redirects=True
)
assert b"1 Imported" in res.data
time.sleep(2)
# We should see the DNS error
res = client.get(url_for("index"))
found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data
assert found_name_resolution_error
# Update with what should work
client.post(
url_for("edit_page", uuid="first"),
data={
"url": test_url,
"fetch_backend": "html_requests"},
follow_redirects=True
)
# Now the error should be gone
time.sleep(2)
res = client.get(url_for("index"))
found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data
assert not found_name_resolution_error
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data
@@ -10,10 +10,10 @@ from ..html_tools import *
def set_original_response(): def set_original_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="sametext">Some text thats the same</div> <div id="sametext">Some text thats the same</div>
<div class="changetext">Some text that will change</div> <div class="changetext">Some text that will change</div>
</body> </body>
@@ -28,12 +28,12 @@ def set_original_response():
def set_modified_response(): def set_modified_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>which has this one new line</p> <p>which has this one new line</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="sametext">Some text thats the same</div> <div id="sametext">Some text thats the same</div>
<div class="changetext">Some text that did change ( 1000 online <br> 80 guests<br> 2000 online )</div> <div class="changetext">Some text that did change ( 1000 online <br/> 80 guests<br/> 2000 online )</div>
<div class="changetext">SomeCase insensitive 3456</div> <div class="changetext">SomeCase insensitive 3456</div>
</body> </body>
</html> </html>
@@ -49,8 +49,8 @@ def set_multiline_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
<p>Something <br> <p>Something <br/>
across 6 billion multiple<br> across 6 billion multiple<br/>
lines lines
</p> </p>
@@ -11,10 +11,10 @@ from changedetectionio.model import App
def set_response_without_filter(): def set_response_without_filter():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="nope-doesnt-exist">Some text thats the same</div> <div id="nope-doesnt-exist">Some text thats the same</div>
</body> </body>
</html> </html>
@@ -28,10 +28,10 @@ def set_response_without_filter():
def set_response_with_filter(): def set_response_with_filter():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div class="ticket-available">Ticket now on sale!</div> <div class="ticket-available">Ticket now on sale!</div>
</body> </body>
</html> </html>
@@ -117,3 +117,18 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
assert 'Ticket now on sale' in notification assert 'Ticket now on sale' in notification
os.unlink("test-datastore/notification.txt") os.unlink("test-datastore/notification.txt")
# Test that if it gets removed, then re-added, we get a notification
# Remove the target and re-add it, we should get a new notification
set_response_without_filter()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(3)
assert not os.path.isfile("test-datastore/notification.txt")
set_response_with_filter()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(3)
assert os.path.isfile("test-datastore/notification.txt")
# Also test that the filter was updated after the first one was requested
@@ -8,10 +8,10 @@ from changedetectionio.model import App
def set_response_with_filter(): def set_response_with_filter():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div id="nope-doesnt-exist">Some text thats the same</div> <div id="nope-doesnt-exist">Some text thats the same</div>
</body> </body>
</html> </html>
@@ -145,4 +145,4 @@ def test_check_xpath_filter_failure_notification(client, live_server):
time.sleep(1) time.sleep(1)
run_filter_test(client, '//*[@id="nope-doesnt-exist"]') run_filter_test(client, '//*[@id="nope-doesnt-exist"]')
# Test that notification is never sent # Test that notification is never sent
+5 -5
View File
@@ -6,11 +6,11 @@ from ..html_tools import html_to_text
def test_html_to_text_func(): def test_html_to_text_func():
test_html = """<html> test_html = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<a href="/first_link"> More Text </a> <a href="/first_link"> More Text </a>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<a href="second_link.com"> Even More Text </a> <a href="second_link.com"> Even More Text </a>
</body> </body>
</html> </html>
@@ -21,7 +21,7 @@ def test_html_to_text_func():
no_links_text = \ no_links_text = \
"Some initial text\n\nWhich is across multiple " \ "Some initial text\n\nWhich is across multiple " \
"lines\n\nMore Text\nSo let's see what happens.\nEven More Text" "lines\n\nMore Text So let's see what happens. Even More Text"
# check that no links are in the extracted text # check that no links are in the extracted text
assert text_content == no_links_text assert text_content == no_links_text
@@ -31,7 +31,7 @@ def test_html_to_text_func():
links_text = \ links_text = \
"Some initial text\n\nWhich is across multiple lines\n\n[ More Text " \ "Some initial text\n\nWhich is across multiple lines\n\n[ More Text " \
"](/first_link)\nSo let's see what happens.\n[ Even More Text ]" \ "](/first_link) So let's see what happens. [ Even More Text ]" \
"(second_link.com)" "(second_link.com)"
# check that links are present in the extracted text # check that links are present in the extracted text
@@ -1,5 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
import time
from flask import url_for
from . util import live_server_setup from . util import live_server_setup
from changedetectionio import html_tools from changedetectionio import html_tools
@@ -9,7 +11,7 @@ def test_setup(live_server):
# Unit test of the stripper # Unit test of the stripper
# Always we are dealing in utf-8 # Always we are dealing in utf-8
def test_strip_regex_text_func(): def test_strip_regex_text_func():
from ..processors import text_json_diff as fetch_site_status from changedetectionio import fetch_site_status
test_content = """ test_content = """
but sometimes we want to remove the lines. but sometimes we want to remove the lines.
+10 -11
View File
@@ -11,8 +11,7 @@ def test_setup(live_server):
# Unit test of the stripper # Unit test of the stripper
# Always we are dealing in utf-8 # Always we are dealing in utf-8
def test_strip_text_func(): def test_strip_text_func():
from ..processors import text_json_diff as fetch_site_status from changedetectionio import fetch_site_status
test_content = """ test_content = """
Some content Some content
@@ -34,10 +33,10 @@ def test_strip_text_func():
def set_original_ignore_response(): def set_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -50,10 +49,10 @@ def set_original_ignore_response():
def set_modified_original_ignore_response(): def set_modified_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some NEW nice initial text<br> Some NEW nice initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<p>new ignore stuff</p> <p>new ignore stuff</p>
<p>blah</p> <p>blah</p>
</body> </body>
@@ -69,11 +68,11 @@ def set_modified_original_ignore_response():
def set_modified_ignore_response(): def set_modified_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<P>ZZZZz</P> <P>ZZZZz</P>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -12,10 +12,10 @@ def test_setup(live_server):
def set_original_ignore_response(): def set_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<a href="/original_link"> Some More Text </a> <a href="/original_link"> Some More Text </a>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
""" """
@@ -29,10 +29,10 @@ def set_original_ignore_response():
def set_modified_ignore_response(): def set_modified_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<a href="/modified_link"> Some More Text </a> <a href="/modified_link"> Some More Text </a>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
""" """
@@ -12,10 +12,10 @@ def test_setup(live_server):
def set_original_response(): def set_original_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
""" """
@@ -27,10 +27,10 @@ def set_original_response():
def set_some_changed_response(): def set_some_changed_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines, and a new thing too.</p> <p>Which is across multiple lines, and a new thing too.</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
""" """
@@ -12,15 +12,15 @@ def test_setup(live_server):
def set_original_ignore_response_but_with_whitespace(): def set_original_ignore_response_but_with_whitespace():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p> <p>
Which is across multiple lines</p> Which is across multiple lines</p>
<br> <br>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
@@ -34,10 +34,10 @@ def set_original_ignore_response_but_with_whitespace():
def set_original_ignore_response(): def set_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -198,8 +198,8 @@ def test_check_json_without_filter(client, live_server):
) )
# Should still see '"html": "<b>"' # Should still see '"html": "<b>"'
assert b'&#34;html&#34;: &#34;&lt;b&gt;&#34;' in res.data assert b'&#34;&lt;b&gt;' in res.data
assert res.data.count(b'{') >= 2 assert res.data.count(b'{\n') >= 2
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data assert b'Deleted' in res.data
+10 -11
View File
@@ -73,12 +73,16 @@ def test_check_notification(client, live_server):
# We write the PNG to disk, but a JPEG should appear in the notification # We write the PNG to disk, but a JPEG should appear in the notification
# Write the last screenshot png # Write the last screenshot png
testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
# This one is created when we save the screenshot from the webdriver/playwright session (converted from PNG)
testimage_jpg = '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=='
uuid = extract_UUID_from_client(client) uuid = extract_UUID_from_client(client)
datastore = 'test-datastore' datastore = 'test-datastore'
with open(os.path.join(datastore, str(uuid), 'last-screenshot.png'), 'wb') as f: with open(os.path.join(datastore, str(uuid), 'last-screenshot.png'), 'wb') as f:
f.write(base64.b64decode(testimage_png)) f.write(base64.b64decode(testimage_png))
with open(os.path.join(datastore, str(uuid), 'last-screenshot.jpg'), 'wb') as f:
f.write(base64.b64decode(testimage_jpg))
# Goto the edit page, add our ignore text # Goto the edit page, add our ignore text
# Add our URL to the import page # Add our URL to the import page
@@ -96,8 +100,6 @@ def test_check_notification(client, live_server):
"Diff URL: {{diff_url}}\n" "Diff URL: {{diff_url}}\n"
"Snapshot: {{current_snapshot}}\n" "Snapshot: {{current_snapshot}}\n"
"Diff: {{diff}}\n" "Diff: {{diff}}\n"
"Diff Added: {{diff_added}}\n"
"Diff Removed: {{diff_removed}}\n"
"Diff Full: {{diff_full}}\n" "Diff Full: {{diff_full}}\n"
":-)", ":-)",
"notification_screenshot": True, "notification_screenshot": True,
@@ -145,7 +147,7 @@ def test_check_notification(client, live_server):
assert ':-)' in notification_submission assert ':-)' in notification_submission
assert "Diff Full: Some initial text" in notification_submission assert "Diff Full: Some initial text" in notification_submission
assert "Diff: (changed) Which is across multiple lines" in notification_submission assert "Diff: (changed) Which is across multiple lines" in notification_submission
assert "(into) which has this one new line" in notification_submission assert "(into ) which has this one new line" in notification_submission
# Re #342 - check for accidental python byte encoding of non-utf8/string # Re #342 - check for accidental python byte encoding of non-utf8/string
assert "b'" not in notification_submission assert "b'" not in notification_submission
assert re.search('Watch UUID: [0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}', notification_submission, re.IGNORECASE) assert re.search('Watch UUID: [0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}', notification_submission, re.IGNORECASE)
@@ -158,12 +160,12 @@ def test_check_notification(client, live_server):
# Check the attachment was added, and that it is a JPEG from the original PNG # Check the attachment was added, and that it is a JPEG from the original PNG
notification_submission_object = json.loads(notification_submission) notification_submission_object = json.loads(notification_submission)
# We keep PNG screenshots for now assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.jpg'
assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.png'
assert len(notification_submission_object['attachments'][0]['base64']) assert len(notification_submission_object['attachments'][0]['base64'])
assert notification_submission_object['attachments'][0]['mimetype'] == 'image/png' assert notification_submission_object['attachments'][0]['mimetype'] == 'image/jpeg'
jpeg_in_attachment = base64.b64decode(notification_submission_object['attachments'][0]['base64']) jpeg_in_attachment = base64.b64decode(notification_submission_object['attachments'][0]['base64'])
assert b'JFIF' in jpeg_in_attachment
assert testimage_png not in notification_submission
# Assert that the JPEG is readable (didn't get chewed up somewhere) # Assert that the JPEG is readable (didn't get chewed up somewhere)
from PIL import Image from PIL import Image
import io import io
@@ -295,10 +297,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server):
follow_redirects=True follow_redirects=True
) )
assert b'Settings updated' in res.data assert b'Settings updated' in res.data
client.get(
url_for("form_delete", uuid="all"),
follow_redirects=True
)
# Add a watch and trigger a HTTP POST # Add a watch and trigger a HTTP POST
test_url = url_for('test_endpoint', _external=True) test_url = url_for('test_endpoint', _external=True)
res = client.post( res = client.post(
+11 -11
View File
@@ -8,10 +8,10 @@ from . util import live_server_setup
def set_original_ignore_response(): def set_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -24,10 +24,10 @@ def set_original_ignore_response():
def set_modified_original_ignore_response(): def set_modified_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some NEW nice initial text<br> Some NEW nice initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -40,12 +40,12 @@ def set_modified_original_ignore_response():
def set_modified_with_trigger_text_response(): def set_modified_with_trigger_text_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some NEW nice initial text<br> Some NEW nice initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
Add to cart Add to cart
<br> <br/>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -142,4 +142,4 @@ def test_trigger_functionality(client, live_server):
res = client.get(url_for("preview_page", uuid="first")) res = client.get(url_for("preview_page", uuid="first"))
# We should be able to see what we triggered on # We should be able to see what we triggered on
assert b'<div class="triggered">Add to cart' in res.data assert b'<div class="triggered">Add to cart' in res.data
@@ -8,10 +8,10 @@ from . util import live_server_setup
def set_original_ignore_response(): def set_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -72,7 +72,7 @@ def test_trigger_regex_functionality(client, live_server):
assert b'unviewed' not in res.data assert b'unviewed' not in res.data
with open("test-datastore/endpoint-content.txt", "w") as f: with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("regex test123<br>\nsomething 123") f.write("regex test123<br/>\nsomething 123")
client.get(url_for("form_watch_checknow"), follow_redirects=True) client.get(url_for("form_watch_checknow"), follow_redirects=True)
time.sleep(sleep_time_for_fetch_thread) time.sleep(sleep_time_for_fetch_thread)
@@ -81,4 +81,4 @@ def test_trigger_regex_functionality(client, live_server):
# Cleanup everything # Cleanup everything
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data assert b'Deleted' in res.data
@@ -8,10 +8,10 @@ from . util import live_server_setup
def set_original_ignore_response(): def set_original_ignore_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
@@ -94,6 +94,7 @@ def test_unique_lines_functionality(client, live_server):
res = client.get(url_for("index")) res = client.get(url_for("index"))
assert b'unviewed' not in res.data assert b'unviewed' not in res.data
# Now set the content which contains the new text and re-ordered existing text # Now set the content which contains the new text and re-ordered existing text
set_modified_with_trigger_text_response() set_modified_with_trigger_text_response()
client.get(url_for("form_watch_checknow"), follow_redirects=True) client.get(url_for("form_watch_checknow"), follow_redirects=True)
@@ -12,10 +12,10 @@ def test_setup(live_server):
def set_original_response(): def set_original_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<div class="sametext">Some text thats the same</div> <div class="sametext">Some text thats the same</div>
<div class="changetext">Some text that will change</div> <div class="changetext">Some text that will change</div>
</body> </body>
@@ -29,10 +29,10 @@ def set_original_response():
def set_modified_response(): def set_modified_response():
test_return_data = """<html> test_return_data = """<html>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. THIS CHANGES AND SHOULDNT TRIGGER A CHANGE<br> So let's see what happens. THIS CHANGES AND SHOULDNT TRIGGER A CHANGE</br>
<div class="sametext">Some text thats the same</div> <div class="sametext">Some text thats the same</div>
<div class="changetext">Some new text</div> <div class="changetext">Some new text</div>
</body> </body>
@@ -13,51 +13,18 @@ class TestDiffBuilder(unittest.TestCase):
def test_expected_diff_output(self): def test_expected_diff_output(self):
base_dir = os.path.dirname(__file__) base_dir = os.path.dirname(__file__)
with open(base_dir + "/test-content/before.txt", 'r') as f: output = diff.render_diff(previous_file=base_dir + "/test-content/before.txt", newest_file=base_dir + "/test-content/after.txt")
previous_version_file_contents = f.read()
with open(base_dir + "/test-content/after.txt", 'r') as f:
newest_version_file_contents = f.read()
output = diff.render_diff(previous_version_file_contents=previous_version_file_contents,
newest_version_file_contents=newest_version_file_contents)
output = output.split("\n") output = output.split("\n")
self.assertIn('(changed) ok', output) self.assertIn('(changed) ok', output)
self.assertIn('(into) xok', output) self.assertIn('(into ) xok', output)
self.assertIn('(into) next-x-ok', output) self.assertIn('(into ) next-x-ok', output)
self.assertIn('(added) and something new', output) self.assertIn('(added ) and something new', output)
with open(base_dir + "/test-content/after-2.txt", 'r') as f:
newest_version_file_contents = f.read() output = diff.render_diff(previous_file=base_dir + "/test-content/before.txt", newest_file=base_dir + "/test-content/after-2.txt")
output = diff.render_diff(previous_version_file_contents, newest_version_file_contents)
output = output.split("\n") output = output.split("\n")
self.assertIn('(removed) for having learned computerese,', output) self.assertIn('(removed) for having learned computerese,', output)
self.assertIn('(removed) I continue to examine bits, bytes and words', output) self.assertIn('(removed) I continue to examine bits, bytes and words', output)
#diff_removed
with open(base_dir + "/test-content/before.txt", 'r') as f:
previous_version_file_contents = f.read()
with open(base_dir + "/test-content/after.txt", 'r') as f:
newest_version_file_contents = f.read()
output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False)
output = output.split("\n")
self.assertIn('(changed) ok', output)
self.assertIn('(into) xok', output)
self.assertIn('(into) next-x-ok', output)
self.assertNotIn('(added) and something new', output)
#diff_removed
with open(base_dir + "/test-content/after-2.txt", 'r') as f:
newest_version_file_contents = f.read()
output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False)
output = output.split("\n")
self.assertIn('(removed) for having learned computerese,', output)
self.assertIn('(removed) I continue to examine bits, bytes and words', output)
# @todo test blocks of changed, blocks of added, blocks of removed # @todo test blocks of changed, blocks of added, blocks of removed
+10 -10
View File
@@ -9,10 +9,10 @@ def set_original_response():
test_return_data = """<html> test_return_data = """<html>
<head><title>head title</title></head> <head><title>head title</title></head>
<body> <body>
Some initial text<br> Some initial text</br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
<span class="foobar-detection" style='display:none'></span> <span class="foobar-detection" style='display:none'></span>
</body> </body>
</html> </html>
@@ -26,10 +26,10 @@ def set_modified_response():
test_return_data = """<html> test_return_data = """<html>
<head><title>modified head title</title></head> <head><title>modified head title</title></head>
<body> <body>
Some initial text<br> Some initial text</br>
<p>which has this one new line</p> <p>which has this one new line</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
</body> </body>
</html> </html>
""" """
@@ -43,11 +43,11 @@ def set_more_modified_response():
test_return_data = """<html> test_return_data = """<html>
<head><title>modified head title</title></head> <head><title>modified head title</title></head>
<body> <body>
Some initial text<br> Some initial text</br>
<p>which has this one new line</p> <p>which has this one new line</p>
<br> </br>
So let's see what happens. <br> So let's see what happens. </br>
Ohh yeah awesome<br> Ohh yeah awesome<br/>
</body> </body>
</html> </html>
""" """
+35 -53
View File
@@ -4,8 +4,8 @@ import queue
import time import time
from changedetectionio import content_fetcher from changedetectionio import content_fetcher
from .processors.text_json_diff import FilterNotFoundInResponse from changedetectionio import queuedWatchMetaData
from changedetectionio.fetch_site_status import FilterNotFoundInResponse
# A single update worker # A single update worker
# #
@@ -65,32 +65,20 @@ class update_worker(threading.Thread):
if 'notification_urls' in n_object and n_object['notification_urls']: if 'notification_urls' in n_object and n_object['notification_urls']:
# HTML needs linebreak, but MarkDown and Text can use a linefeed # HTML needs linebreak, but MarkDown and Text can use a linefeed
if n_object['notification_format'] == 'HTML': if n_object['notification_format'] == 'HTML':
line_feed_sep = "<br>" line_feed_sep = "</br>"
else: else:
line_feed_sep = "\n" line_feed_sep = "\n"
# Add text that was triggered with open(watch_history[dates[-1]], 'rb') as f:
snapshot_contents = watch.get_history_snapshot(dates[-1]) snapshot_contents = f.read()
trigger_text = watch.get('trigger_text', [])
triggered_text = ''
if len(trigger_text):
from . import html_tools
triggered_text = html_tools.get_triggered_text(content=snapshot_contents, trigger_text=trigger_text)
if triggered_text:
triggered_text = line_feed_sep.join(triggered_text)
n_object.update({ n_object.update({
'current_snapshot': snapshot_contents,
'diff': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), line_feed_sep=line_feed_sep),
'diff_added': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), include_removed=False, line_feed_sep=line_feed_sep),
'diff_full': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), include_equal=True, line_feed_sep=line_feed_sep),
'diff_removed': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), include_added=False, line_feed_sep=line_feed_sep),
'screenshot': watch.get_screenshot() if watch.get('notification_screenshot') else None,
'triggered_text': triggered_text,
'uuid': watch_uuid,
'watch_url': watch['url'], 'watch_url': watch['url'],
'uuid': watch_uuid,
'screenshot': watch.get_screenshot_as_jpeg() if watch.get('notification_screenshot') else None,
'current_snapshot': snapshot_contents.decode('utf-8'),
'diff': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], line_feed_sep=line_feed_sep),
'diff_full': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], True, line_feed_sep=line_feed_sep)
}) })
logging.info (">> SENDING NOTIFICATION") logging.info (">> SENDING NOTIFICATION")
self.notification_q.put(n_object) self.notification_q.put(n_object)
@@ -163,8 +151,9 @@ class update_worker(threading.Thread):
os.unlink(full_path) os.unlink(full_path)
def run(self): def run(self):
from changedetectionio import fetch_site_status
from .processors import text_json_diff, restock_diff update_handler = fetch_site_status.perform_site_check(datastore=self.datastore)
while not self.app.config.exit.is_set(): while not self.app.config.exit.is_set():
@@ -180,22 +169,14 @@ class update_worker(threading.Thread):
if uuid in list(self.datastore.data['watching'].keys()): if uuid in list(self.datastore.data['watching'].keys()):
changed_detected = False changed_detected = False
contents = b'' contents = b''
screenshot = False
update_obj= {}
xpath_data = False
process_changedetection_results = True process_changedetection_results = True
update_obj = {} print("> Processing UUID {} Priority {} URL {}".format(uuid, queued_item_data.priority, self.datastore.data['watching'][uuid]['url']))
print("> Processing UUID {} Priority {} URL {}".format(uuid, queued_item_data.priority,
self.datastore.data['watching'][uuid]['url']))
now = time.time() now = time.time()
try: try:
processor = self.datastore.data['watching'][uuid].get('processor','text_json_diff')
# @todo some way to switch by name
if processor == 'restock_diff':
update_handler = restock_diff.perform_site_check(datastore=self.datastore)
else:
# Used as a default and also by some tests
update_handler = text_json_diff.perform_site_check(datastore=self.datastore)
changed_detected, update_obj, contents = update_handler.run(uuid, skip_when_checksum_same=queued_item_data.item.get('skip_when_checksum_same')) changed_detected, update_obj, contents = update_handler.run(uuid, skip_when_checksum_same=queued_item_data.item.get('skip_when_checksum_same'))
# Re #342 # Re #342
# In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes. # In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes.
@@ -231,7 +212,9 @@ class update_worker(threading.Thread):
if e.page_text: if e.page_text:
self.datastore.save_error_text(watch_uuid=uuid, contents=e.page_text) self.datastore.save_error_text(watch_uuid=uuid, contents=e.page_text)
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text}) self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
# So that we get a trigger when the content is added again
'previous_md5': ''})
process_changedetection_results = False process_changedetection_results = False
except FilterNotFoundInResponse as e: except FilterNotFoundInResponse as e:
@@ -239,7 +222,9 @@ class update_worker(threading.Thread):
continue continue
err_text = "Warning, no filters were found, no change detection ran." err_text = "Warning, no filters were found, no change detection ran."
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text}) self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
# So that we get a trigger when the content is added again
'previous_md5': ''})
# Only when enabled, send the notification # Only when enabled, send the notification
if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False): if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False):
@@ -256,12 +241,11 @@ class update_worker(threading.Thread):
self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c}) self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})
process_changedetection_results = False process_changedetection_results = True
except content_fetcher.checksumFromPreviousCheckWasTheSame as e: except content_fetcher.checksumFromPreviousCheckWasTheSame as e:
# Yes fine, so nothing todo, don't continue to process. # Yes fine, so nothing todo
process_changedetection_results = False pass
changed_detected = False
except content_fetcher.BrowserStepsStepTimout as e: except content_fetcher.BrowserStepsStepTimout as e:
@@ -269,7 +253,9 @@ class update_worker(threading.Thread):
continue continue
err_text = "Warning, browser step at position {} could not run, target not found, check the watch, add a delay if necessary.".format(e.step_n+1) err_text = "Warning, browser step at position {} could not run, target not found, check the watch, add a delay if necessary.".format(e.step_n+1)
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text}) self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
# So that we get a trigger when the content is added again
'previous_md5': ''})
if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False): if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False):
@@ -285,7 +271,6 @@ class update_worker(threading.Thread):
c = 0 c = 0
self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c}) self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})
process_changedetection_results = False process_changedetection_results = False
except content_fetcher.EmptyReply as e: except content_fetcher.EmptyReply as e:
@@ -293,7 +278,6 @@ class update_worker(threading.Thread):
err_text = "EmptyReply - try increasing 'Wait seconds before extracting text', Status Code {}".format(e.status_code) err_text = "EmptyReply - try increasing 'Wait seconds before extracting text', Status Code {}".format(e.status_code)
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
'last_check_status': e.status_code}) 'last_check_status': e.status_code})
process_changedetection_results = False
except content_fetcher.ScreenshotUnavailable as e: except content_fetcher.ScreenshotUnavailable as e:
err_text = "Screenshot unavailable, page did not render fully in the expected time - try increasing 'Wait seconds before extracting text'" err_text = "Screenshot unavailable, page did not render fully in the expected time - try increasing 'Wait seconds before extracting text'"
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
@@ -305,7 +289,6 @@ class update_worker(threading.Thread):
self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True) self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True)
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
'last_check_status': e.status_code}) 'last_check_status': e.status_code})
process_changedetection_results = False
except content_fetcher.PageUnloadable as e: except content_fetcher.PageUnloadable as e:
err_text = "Page request from server didnt respond correctly" err_text = "Page request from server didnt respond correctly"
if e.message: if e.message:
@@ -316,7 +299,6 @@ class update_worker(threading.Thread):
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
'last_check_status': e.status_code}) 'last_check_status': e.status_code})
process_changedetection_results = False
except Exception as e: except Exception as e:
self.app.logger.error("Exception reached processing watch UUID: %s - %s", uuid, str(e)) self.app.logger.error("Exception reached processing watch UUID: %s - %s", uuid, str(e))
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)}) self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
@@ -333,18 +315,18 @@ class update_worker(threading.Thread):
self.cleanup_error_artifacts(uuid) self.cleanup_error_artifacts(uuid)
#
# Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc # Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc
if process_changedetection_results: if process_changedetection_results:
try: try:
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'][uuid]
self.datastore.update_watch(uuid=uuid, update_obj=update_obj) fname = "" # Saved history text filename
# Also save the snapshot on the first time checked # For the FIRST time we check a site, or a change detected, save the snapshot.
if changed_detected or not watch['last_checked']: if changed_detected or not watch['last_checked']:
watch.save_history_text(contents=contents, # A change was detected
timestamp=str(round(time.time())), watch.save_history_text(contents=contents, timestamp=str(round(time.time())))
snapshot_id=update_obj.get('previous_md5', 'none'))
self.datastore.update_watch(uuid=uuid, update_obj=update_obj)
# A change was detected # A change was detected
if changed_detected: if changed_detected:
+2 -4
View File
@@ -41,6 +41,7 @@ services:
# #
# Base URL of your changedetection.io install (Added to the notification alert) # Base URL of your changedetection.io install (Added to the notification alert)
# - BASE_URL=https://mysite.com # - BASE_URL=https://mysite.com
# Respect proxy_pass type settings, `proxy_set_header Host "localhost";` and `proxy_set_header X-Forwarded-Prefix /app;` # Respect proxy_pass type settings, `proxy_set_header Host "localhost";` and `proxy_set_header X-Forwarded-Prefix /app;`
# More here https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy-sub-directory # More here https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy-sub-directory
# - USE_X_SETTINGS=1 # - USE_X_SETTINGS=1
@@ -94,10 +95,7 @@ services:
# - CHROME_REFRESH_TIME=600000 # - CHROME_REFRESH_TIME=600000
# - DEFAULT_BLOCK_ADS=true # - DEFAULT_BLOCK_ADS=true
# - DEFAULT_STEALTH=true # - DEFAULT_STEALTH=true
#
# Ignore HTTPS errors, like for self-signed certs
# - DEFAULT_IGNORE_HTTPS_ERRORS=true
#
volumes: volumes:
changedetection-data: changedetection-data:
File diff suppressed because one or more lines are too long
-1
View File
@@ -49,7 +49,6 @@ input[type="date"] {
src: url('./glyphicons-halflings-regular.eot'); src: url('./glyphicons-halflings-regular.eot');
src: url('./glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), src: url('./glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),
url('./glyphicons-halflings-regular.woff') format('woff'), url('./glyphicons-halflings-regular.woff') format('woff'),
url('./glyphicons-halflings-regular.woff2') format('woff2'),
url('./glyphicons-halflings-regular.ttf') format('truetype'), url('./glyphicons-halflings-regular.ttf') format('truetype'),
url('./glyphicons-halflings-regular.svg#glyphicons-halflingsregular') format('svg'); url('./glyphicons-halflings-regular.svg#glyphicons-halflingsregular') format('svg');
} }
+8 -8
View File
@@ -5,13 +5,13 @@
<meta name="description" content="Manage your changedetection.io watches via API, requires the `x-api-key` header which is found in the settings UI."> <meta name="description" content="Manage your changedetection.io watches via API, requires the `x-api-key` header which is found in the settings UI.">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<link href="assets/bootstrap.min.css?v=1677105736053" rel="stylesheet" media="screen"> <link href="assets/bootstrap.min.css" rel="stylesheet" media="screen">
<link href="assets/prism.css?v=1677105736053" rel="stylesheet" /> <link href="assets/prism.css" rel="stylesheet" />
<link href="assets/main.css?v=1677105736053" rel="stylesheet" media="screen, print"> <link href="assets/main.css" rel="stylesheet" media="screen, print">
<link href="assets/favicon.ico?v=1677105736053" rel="icon" type="image/x-icon"> <link href="assets/favicon.ico" rel="icon" type="image/x-icon">
<link href="assets/apple-touch-icon.png?v=1677105736053" rel="apple-touch-icon" sizes="180x180"> <link href="assets/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link href="assets/favicon-32x32.png?v=1677105736053" rel="icon" type="image/png" sizes="32x32"> <link href="assets/favicon-32x32.png" rel="icon" type="image/png" sizes="32x32">
<link href="assets/favicon-16x16.png?v=1677105736053" rel="icon" type="image/png" sizes="16x16"> <link href="assets/favicon-16x16.png"rel="icon" type="image/png" sizes="16x16">
</head> </head>
<body class="container-fluid"> <body class="container-fluid">
@@ -928,6 +928,6 @@
</div> </div>
</div> </div>
<script src="assets/main.bundle.js?v=1677105736053"></script> <script src="assets/main.bundle.js"></script>
</body> </body>
</html> </html>
+1 -2
View File
@@ -3,6 +3,5 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Manage your changedetection.io watches via API, requires the `x-api-key` header which is found in the settings UI.", "description": "Manage your changedetection.io watches via API, requires the `x-api-key` header which is found in the settings UI.",
"title": "changedetection.io API", "title": "changedetection.io API",
"url" : "", "url" : "https://changedetection.io/docs/api_v1/index.html"
"sampleUrl" : false
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"dependencies": { "dependencies": {
"apidoc": "^0.54.0" "apidoc": "^0.53.1"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 209 KiB

+3 -4
View File
@@ -2,7 +2,6 @@ eventlet>=0.31.0
feedgen~=0.9 feedgen~=0.9
flask-compress flask-compress
flask-login~=0.5 flask-login~=0.5
flask-paginate
flask_expects_json~=1.7 flask_expects_json~=1.7
flask_restful flask_restful
flask_wtf flask_wtf
@@ -32,7 +31,7 @@ dnspython<2.3.0
# jq not available on Windows so must be installed manually # jq not available on Windows so must be installed manually
# Notification library # Notification library
apprise~=1.3.0 apprise~=1.2.1
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
paho-mqtt paho-mqtt
@@ -43,7 +42,7 @@ paho-mqtt
cryptography~=3.4 cryptography~=3.4
# Used for CSS filtering # Used for CSS filtering
beautifulsoup4 bs4
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe. # XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
lxml lxml
@@ -69,5 +68,5 @@ pillow
# playwright is installed at Dockerfile build time because it's not available on all platforms # playwright is installed at Dockerfile build time because it's not available on all platforms
# Include pytest, so if theres a support issue we can ask them to run these tests on their setup # Include pytest, so if theres a support issue we can ask them to run these tests on their setup
pytest ~=7.2 pytest ~=6.2
pytest-flask ~=1.2 pytest-flask ~=1.2