Compare commits

..

1 Commits

Author SHA1 Message Date
dgtlmoon 9fcd229435 Remove unused function 2023-02-07 17:25:49 +01:00
82 changed files with 863 additions and 2356 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'}}
+3 -11
View File
@@ -30,7 +30,7 @@ jobs:
# Selenium+browserless # Selenium+browserless
docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome-debug:3.141.59 docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome-debug:3.141.59
docker run --network changedet-network -d --hostname browserless -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm -p 3000:3000 --shm-size="2g" browserless/chrome:1.53-chrome-stable docker run --network changedet-network -d --hostname browserless -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm -p 3000:3000 --shm-size="2g" browserless/chrome:1.53-chrome-stable
- name: Build changedetection.io container for testing - name: Build changedetection.io container for testing
run: | run: |
@@ -50,18 +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 with puppeteer fetcher and disk cache
run: |
docker run --rm -e "PUPPETEER_DISK_CACHE=/tmp/data/" -e "USE_EXPERIMENTAL_PUPPETEER_FETCH=yes" -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'
# Browserless would have had -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" added above
- 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
+56 -121
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.2'
datastore = None datastore = None
@@ -66,9 +64,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 +337,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 +359,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 +415,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 +425,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 +459,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)
@@ -541,9 +511,8 @@ def changedetection_app(config=None, datastore_o=None):
# 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 +527,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 +587,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 +633,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 +735,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 +742,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 +760,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,22 +828,28 @@ 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()
@@ -979,35 +929,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 +1001,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 +1125,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 +1167,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 +1233,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()
@@ -1313,13 +1255,6 @@ def changedetection_app(config=None, datastore_o=None):
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False})) update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
flash("{} watches queued for rechecking".format(len(uuids))) flash("{} watches queued for rechecking".format(len(uuids)))
elif (op == 'clear-history'):
for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
datastore.clear_watch_history(uuid)
flash("{} watches cleared/reset.".format(len(uuids)))
elif (op == 'notification-default'): elif (op == 'notification-default'):
from changedetectionio.notification import ( from changedetectionio.notification import (
default_notification_format_for_watch default_notification_format_for_watch
+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
@@ -27,82 +27,58 @@ import os
import logging import logging
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore
from changedetectionio import login_optionally_required from changedetectionio import login_optionally_required
browsersteps_live_ui_o = {}
browsersteps_playwright_browser_interface = None
browsersteps_playwright_browser_interface_browser = None
browsersteps_playwright_browser_interface_context = None
browsersteps_playwright_browser_interface_end_time = None
browsersteps_playwright_browser_interface_start_time = None
browsersteps_sessions = {} def cleanup_playwright_session():
io_interface_context = None
global browsersteps_live_ui_o
global browsersteps_playwright_browser_interface
global browsersteps_playwright_browser_interface_browser
global browsersteps_playwright_browser_interface_context
global browsersteps_playwright_browser_interface_end_time
global browsersteps_playwright_browser_interface_start_time
browsersteps_live_ui_o = {}
browsersteps_playwright_browser_interface = None
browsersteps_playwright_browser_interface_browser = None
browsersteps_playwright_browser_interface_end_time = None
browsersteps_playwright_browser_interface_start_time = None
print("Cleaning up old playwright session because time was up, calling .goodbye()")
try:
browsersteps_playwright_browser_interface_context.goodbye()
except Exception as e:
print ("Got exception in shutdown, probably OK")
print (str(e))
browsersteps_playwright_browser_interface_context = None
print ("Cleaning up old playwright session because time was up - done")
def construct_blueprint(datastore: ChangeDetectionStore): def construct_blueprint(datastore: ChangeDetectionStore):
browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates") browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates")
def start_browsersteps_session():
from . import nonContext
from . import browser_steps
import time
global browsersteps_sessions
global io_interface_context
# We keep the playwright session open for many minutes
seconds_keepalive = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
browsersteps_start_session = {'start_time': time.time()}
# You can only have one of these running
# This should be very fine to leave running for the life of the application
# @idea - Make it global so the pool of watch fetchers can use it also
if not io_interface_context:
io_interface_context = nonContext.c_sync_playwright()
# Start the Playwright context, which is actually a nodejs sub-process and communicates over STDIN/STDOUT pipes
io_interface_context = io_interface_context.start()
#Element is outside of the viewport
# keep it alive for 10 seconds more than we advertise, sometimes it helps to keep it shutting down cleanly
keepalive = "&timeout={}".format(((seconds_keepalive + 3) * 1000))
try:
browsersteps_start_session['browser'] = io_interface_context.chromium.connect_over_cdp(
os.getenv('PLAYWRIGHT_DRIVER_URL', '') + keepalive)
except Exception as e:
if 'ECONNREFUSED' in str(e):
return make_response('Unable to start the Playwright Browser session, is it running?', 401)
else:
return make_response(str(e), 401)
# Tell Playwright to connect to Chrome and setup a new session via our stepper interface
browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui(
playwright_browser=browsersteps_start_session['browser'])
# For test
#browsersteps_start_session['browserstepper'].action_goto_url(value="http://example.com?time="+str(time.time()))
return browsersteps_start_session
@login_optionally_required @login_optionally_required
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET']) @browser_steps_blueprint.route("/browsersteps_update", methods=['GET', 'POST'])
def browsersteps_start_session():
# A new session was requested, return sessionID
import uuid
browsersteps_session_id = str(uuid.uuid4())
watch_uuid = request.args.get('uuid')
global browsersteps_sessions
print("Starting connection with playwright")
logging.debug("browser_steps.py connecting")
browsersteps_sessions[browsersteps_session_id] = start_browsersteps_session()
print("Starting connection with playwright - done")
return {'browsersteps_session_id': browsersteps_session_id}
# A request for an action was received
@login_optionally_required
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
def browsersteps_ui_update(): def browsersteps_ui_update():
import base64 import base64
import playwright._impl._api_types import playwright._impl._api_types
global browsersteps_sessions import time
from changedetectionio.blueprint.browser_steps import browser_steps from changedetectionio.blueprint.browser_steps import browser_steps
global browsersteps_live_ui_o, browsersteps_playwright_browser_interface_end_time
global browsersteps_playwright_browser_interface_browser
global browsersteps_playwright_browser_interface
global browsersteps_playwright_browser_interface_start_time
step_n = None
remaining =0 remaining =0
uuid = request.args.get('uuid') uuid = request.args.get('uuid')
@@ -111,9 +87,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
if not browsersteps_session_id: if not browsersteps_session_id:
return make_response('No browsersteps_session_id specified', 500) return make_response('No browsersteps_session_id specified', 500)
if not browsersteps_sessions.get(browsersteps_session_id): # Because we don't "really" run in a context manager ( we make the playwright interface global/long-living )
return make_response('No session exists under that ID', 500) # We need to manage the shutdown when the time is up
if browsersteps_playwright_browser_interface_end_time:
remaining = browsersteps_playwright_browser_interface_end_time-time.time()
if browsersteps_playwright_browser_interface_end_time and remaining <= 0:
cleanup_playwright_session()
return make_response('Browser session expired, please reload the Browser Steps interface', 401)
# Actions - step/apply/etc, do the thing and return state # Actions - step/apply/etc, do the thing and return state
if request.method == 'POST': if request.method == 'POST':
@@ -126,13 +106,18 @@ 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:
browsersteps_sessions[browsersteps_session_id]['browserstepper'].call_action(action_name=step_operation, this_session = browsersteps_live_ui_o.get(browsersteps_session_id)
if not this_session:
print("Browser exited")
return make_response('Browser session ran out of time :( Please reload this page.', 401)
this_session.call_action(action_name=step_operation,
selector=step_selector, selector=step_selector,
optional_value=step_optional_value) optional_value=step_optional_value)
@@ -144,43 +129,99 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Get visual selector ready/update its data (also use the current filter info from the page?) # Get visual selector ready/update its data (also use the current filter info from the page?)
# When the last 'apply' button was pressed # When the last 'apply' button was pressed
# @todo this adds overhead because the xpath selection is happening twice # @todo this adds overhead because the xpath selection is happening twice
u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url u = this_session.page.url
if is_last_step and u: if is_last_step and u:
(screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].request_visualselector_data() (screenshot, xpath_data) = this_session.request_visualselector_data()
datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot) datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot)
datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data) datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data)
# if not this_session.page: # Setup interface
# cleanup_playwright_session() if request.method == 'GET':
# return make_response('Browser session ran out of time :( Please reload this page.', 401)
# Screenshots and other info only needed on requesting a step (POST) if not browsersteps_playwright_browser_interface:
try: print("Starting connection with playwright")
state = browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state() logging.debug("browser_steps.py connecting")
except playwright._impl._api_types.Error as e:
return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401)
# Use send_file() which is way faster than read/write loop on bytes global browsersteps_playwright_browser_interface_context
import json from . import nonContext
from tempfile import mkstemp browsersteps_playwright_browser_interface_context = nonContext.c_sync_playwright()
from flask import send_file browsersteps_playwright_browser_interface = browsersteps_playwright_browser_interface_context.start()
tmp_fd, tmp_file = mkstemp(text=True, suffix=".json", prefix="changedetectionio-")
output = json.dumps({'screenshot': "data:image/jpeg;base64,{}".format( time.sleep(1)
base64.b64encode(state[0]).decode('ascii')), # At 20 minutes, some other variable is closing it
'xpath_data': state[1], # @todo find out what it is and set it
'session_age_start': browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start, seconds_keepalive = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
'browser_time_remaining': round(remaining)
})
with os.fdopen(tmp_fd, 'w') as f: # keep it alive for 10 seconds more than we advertise, sometimes it helps to keep it shutting down cleanly
f.write(output) keepalive = "&timeout={}".format(((seconds_keepalive+3) * 1000))
try:
browsersteps_playwright_browser_interface_browser = browsersteps_playwright_browser_interface.chromium.connect_over_cdp(
os.getenv('PLAYWRIGHT_DRIVER_URL', '') + keepalive)
except Exception as e:
if 'ECONNREFUSED' in str(e):
return make_response('Unable to start the Playwright session properly, is it running?', 401)
response = make_response(send_file(path_or_file=tmp_file, browsersteps_playwright_browser_interface_end_time = time.time() + (seconds_keepalive-3)
mimetype='application/json; charset=UTF-8', print("Starting connection with playwright - done")
etag=True))
# No longer needed if not browsersteps_live_ui_o.get(browsersteps_session_id):
os.unlink(tmp_file) # Boot up a new session
proxy_id = datastore.get_preferred_proxy_for_watch(uuid=uuid)
proxy = None
if proxy_id:
proxy_url = datastore.proxy_list.get(proxy_id).get('url')
if proxy_url:
proxy = {'server': proxy_url}
print("Browser Steps: UUID {} Using proxy {}".format(uuid, proxy_url))
# Begin the new "Playwright Context" that re-uses the playwright interface
# Each session is a "Playwright Context" as a list, that uses the playwright interface
browsersteps_live_ui_o[browsersteps_session_id] = browser_steps.browsersteps_live_ui(
playwright_browser=browsersteps_playwright_browser_interface_browser,
proxy=proxy)
this_session = browsersteps_live_ui_o[browsersteps_session_id]
if not this_session.page:
cleanup_playwright_session()
return make_response('Browser session ran out of time :( Please reload this page.', 401)
response = None
if request.method == 'POST':
# Screenshots and other info only needed on requesting a step (POST)
try:
state = this_session.get_current_state()
except playwright._impl._api_types.Error as e:
return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401)
# Use send_file() which is way faster than read/write loop on bytes
import json
from tempfile import mkstemp
from flask import send_file
tmp_fd, tmp_file = mkstemp(text=True, suffix=".json", prefix="changedetectionio-")
output = json.dumps({'screenshot': "data:image/jpeg;base64,{}".format(
base64.b64encode(state[0]).decode('ascii')),
'xpath_data': state[1],
'session_age_start': this_session.age_start,
'browser_time_remaining': round(remaining)
})
with os.fdopen(tmp_fd, 'w') as f:
f.write(output)
response = make_response(send_file(path_or_file=tmp_file,
mimetype='application/json; charset=UTF-8',
etag=True))
# No longer needed
os.unlink(tmp_file)
elif request.method == 'GET':
# Just enough to get the session rolling, it will call for goto-site via POST next
response = make_response({
'session_age_start': this_session.age_start,
'browser_time_remaining': round(remaining)
})
return response return response
@@ -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)
@@ -71,13 +69,13 @@ class steppable_browser_interface():
optional_value = str(jinja2_env.from_string(optional_value).render()) optional_value = str(jinja2_env.from_string(optional_value).render())
action_handler(selector, optional_value) action_handler(selector, optional_value)
self.page.wait_for_timeout(1.5 * 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=None, value=None): 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.
@@ -105,8 +103,7 @@ class steppable_browser_interface():
print("Clicking element") print("Clicking element")
if not len(selector.strip()): if not len(selector.strip()):
return return
self.page.click(selector, timeout=10 * 1000, delay=randint(200, 500))
self.page.click(selector=selector, timeout=30 * 1000, delay=randint(200, 500))
def action_click_element_if_exists(self, selector, value): def action_click_element_if_exists(self, selector, value):
import playwright._impl._api_types as _api_types import playwright._impl._api_types as _api_types
@@ -135,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):
+23 -167
View File
@@ -10,7 +10,6 @@ import time
visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4, header, footer, section, article, aside, details, main, nav, section, summary' visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4, header, footer, section, article, aside, details, main, nav, section, summary'
class Non200ErrorCodeReceived(Exception): class Non200ErrorCodeReceived(Exception):
def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None): def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None):
# Set this so we can use it in other parts of the app # Set this so we can use it in other parts of the app
@@ -25,12 +24,10 @@ class Non200ErrorCodeReceived(Exception):
self.page_text = html_tools.html_to_text(page_html) self.page_text = html_tools.html_to_text(page_html)
return return
class checksumFromPreviousCheckWasTheSame(Exception): class checksumFromPreviousCheckWasTheSame(Exception):
def __init__(self): def __init__(self):
return return
class JSActionExceptions(Exception): class JSActionExceptions(Exception):
def __init__(self, status_code, url, screenshot, message=''): def __init__(self, status_code, url, screenshot, message=''):
self.status_code = status_code self.status_code = status_code
@@ -39,7 +36,6 @@ class JSActionExceptions(Exception):
self.message = message self.message = message
return return
class BrowserStepsStepTimout(Exception): class BrowserStepsStepTimout(Exception):
def __init__(self, step_n): def __init__(self, step_n):
self.step_n = step_n self.step_n = step_n
@@ -55,7 +51,6 @@ class PageUnloadable(Exception):
self.message = message self.message = message
return return
class EmptyReply(Exception): class EmptyReply(Exception):
def __init__(self, status_code, url, screenshot=None): def __init__(self, status_code, url, screenshot=None):
# Set this so we can use it in other parts of the app # Set this so we can use it in other parts of the app
@@ -64,7 +59,6 @@ class EmptyReply(Exception):
self.screenshot = screenshot self.screenshot = screenshot
return return
class ScreenshotUnavailable(Exception): class ScreenshotUnavailable(Exception):
def __init__(self, status_code, url, page_html=None): def __init__(self, status_code, url, page_html=None):
# Set this so we can use it in other parts of the app # Set this so we can use it in other parts of the app
@@ -75,7 +69,6 @@ class ScreenshotUnavailable(Exception):
self.page_text = html_to_text(page_html) self.page_text = html_to_text(page_html)
return return
class ReplyWithContentButNoText(Exception): class ReplyWithContentButNoText(Exception):
def __init__(self, status_code, url, screenshot=None): def __init__(self, status_code, url, screenshot=None):
# Set this so we can use it in other parts of the app # Set this so we can use it in other parts of the app
@@ -84,20 +77,19 @@ class ReplyWithContentButNoText(Exception):
self.screenshot = screenshot self.screenshot = screenshot
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 = {}
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
@@ -111,7 +103,7 @@ 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
def get_error(self): def get_error(self):
@@ -159,15 +151,13 @@ class Fetcher():
interface = steppable_browser_interface() interface = steppable_browser_interface()
interface.page = self.page interface.page = self.page
valid_steps = filter( valid_steps = filter(lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), self.browser_steps)
lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'),
self.browser_steps)
for step in valid_steps: for step in valid_steps:
step_n += 1 step_n += 1
print(">> Iterating check - browser Step n {} - {}...".format(step_n, step['operation'])) print(">> Iterating check - browser Step n {} - {}...".format(step_n, step['operation']))
self.screenshot_step("before-" + str(step_n)) self.screenshot_step("before-"+str(step_n))
self.save_step_html("before-" + str(step_n)) self.save_step_html("before-"+str(step_n))
try: try:
optional_value = step['optional_value'] optional_value = step['optional_value']
selector = step['selector'] selector = step['selector']
@@ -182,11 +172,12 @@ class Fetcher():
optional_value=optional_value) optional_value=optional_value)
self.screenshot_step(step_n) self.screenshot_step(step_n)
self.save_step_html(step_n) self.save_step_html(step_n)
except TimeoutError as e: except TimeoutError:
print(str(e))
# Stop processing here # Stop processing here
raise BrowserStepsStepTimout(step_n=step_n) raise BrowserStepsStepTimout(step_n=step_n)
# It's always good to reset these # It's always good to reset these
def delete_browser_steps_screenshots(self): def delete_browser_steps_screenshots(self):
import glob import glob
@@ -196,7 +187,6 @@ class Fetcher():
for f in files: for f in files:
os.unlink(f) os.unlink(f)
# Maybe for the future, each fetcher provides its own diff output, could be used for text, image # Maybe for the future, each fetcher provides its own diff output, could be used for text, image
# the current one would return javascript output (as we use JS to generate the diff) # the current one would return javascript output (as we use JS to generate the diff)
# #
@@ -214,7 +204,6 @@ def available_fetchers():
return p return p
class base_html_playwright(Fetcher): class base_html_playwright(Fetcher):
fetcher_description = "Playwright {}/Javascript".format( fetcher_description = "Playwright {}/Javascript".format(
os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize() os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize()
@@ -278,118 +267,6 @@ class base_html_playwright(Fetcher):
with open(destination, 'w') as f: with open(destination, 'w') as f:
f.write(content) f.write(content)
def run_fetch_browserless_puppeteer(self,
url,
timeout,
request_headers,
request_body,
request_method,
ignore_status_codes=False,
current_include_filters=None,
is_binary=False):
from pkg_resources import resource_string
extra_wait_ms = (int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay) * 1000
self.xpath_element_js = self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors)
code = resource_string(__name__, "res/puppeteer_fetch.js").decode('utf-8')
# In the future inject this is a proper JS package
code = code.replace('%xpath_scrape_code%', self.xpath_element_js)
code = code.replace('%instock_scrape_code%', self.instock_data_js)
from requests.exceptions import ConnectTimeout, ReadTimeout
wait_browserless_seconds = 240
browserless_function_url = os.getenv('BROWSERLESS_FUNCTION_URL')
from urllib.parse import urlparse
if not browserless_function_url:
# Convert/try to guess from PLAYWRIGHT_DRIVER_URL
o = urlparse(os.getenv('PLAYWRIGHT_DRIVER_URL'))
browserless_function_url = o._replace(scheme="http")._replace(path="function").geturl()
# Append proxy connect string
if self.proxy:
import urllib.parse
# Remove username/password if it exists in the URL or you will receive "ERR_NO_SUPPORTED_PROXIES" error
# Actual authentication handled by Puppeteer/node
o = urlparse(self.proxy.get('server'))
proxy_url = urllib.parse.quote(o._replace(netloc="{}:{}".format(o.hostname, o.port)).geturl())
browserless_function_url = f"{browserless_function_url}&--proxy-server={proxy_url}&dumpio=true"
try:
amp = '&' if '?' in browserless_function_url else '?'
response = requests.request(
method="POST",
json={
"code": code,
"context": {
# Very primitive disk cache - USE WITH EXTREME CAUTION
# Run browserless container with -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]"
'disk_cache_dir': os.getenv("PUPPETEER_DISK_CACHE", False), # or path to disk cache ending in /, ie /tmp/cache/
'execute_js': self.webdriver_js_execute_code,
'extra_wait_ms': extra_wait_ms,
'include_filters': current_include_filters,
'req_headers': request_headers,
'screenshot_quality': int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72)),
'url': url,
'user_agent': request_headers.get('User-Agent', 'Mozilla/5.0'),
'proxy_username': self.proxy.get('username','') if self.proxy else False,
'proxy_password': self.proxy.get('password', '') if self.proxy else False,
'no_cache_list': [
'twitter',
'.pdf'
],
# Could use https://github.com/easylist/easylist here, or install a plugin
'block_url_list': [
'adnxs.com',
'analytics.twitter.com',
'doubleclick.net',
'google-analytics.com',
'googletagmanager',
'trustpilot.com'
]
}
},
# @todo /function needs adding ws:// to http:// rebuild this
url=browserless_function_url+f"{amp}--disable-features=AudioServiceOutOfProcess&dumpio=true&--disable-remote-fonts",
timeout=wait_browserless_seconds)
except ReadTimeout:
raise PageUnloadable(url=url, status_code=None, message=f"No response from browserless in {wait_browserless_seconds}s")
except ConnectTimeout:
raise PageUnloadable(url=url, status_code=None, message=f"Timed out connecting to browserless, retrying..")
else:
# 200 Here means that the communication to browserless worked only, not the page state
if response.status_code == 200:
import base64
x = response.json()
if not x.get('screenshot'):
# https://github.com/puppeteer/puppeteer/blob/v1.0.0/docs/troubleshooting.md#tips
# https://github.com/puppeteer/puppeteer/issues/1834
# https://github.com/puppeteer/puppeteer/issues/1834#issuecomment-381047051
# Check your memory is shared and big enough
raise ScreenshotUnavailable(url=url, status_code=None)
if not x.get('content', '').strip():
raise EmptyReply(url=url, status_code=None)
if x.get('status_code', 200) != 200 and not ignore_status_codes:
raise Non200ErrorCodeReceived(url=url, status_code=x.get('status_code', 200), page_html=x['content'])
self.content = x.get('content')
self.headers = x.get('headers')
self.instock_data = x.get('instock_data')
self.screenshot = base64.b64decode(x.get('screenshot'))
self.xpath_data = x.get('xpath_data')
else:
# Some other error from browserless
raise PageUnloadable(url=url, status_code=None, message=response.content.decode('utf-8'))
def run(self, def run(self,
url, url,
timeout, timeout,
@@ -400,24 +277,6 @@ class base_html_playwright(Fetcher):
current_include_filters=None, current_include_filters=None,
is_binary=False): is_binary=False):
# For now, USE_EXPERIMENTAL_PUPPETEER_FETCH is not supported by watches with BrowserSteps (for now!)
has_browser_steps = self.browser_steps and list(filter(
lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'),
self.browser_steps))
if not has_browser_steps:
if os.getenv('USE_EXPERIMENTAL_PUPPETEER_FETCH'):
# Temporary backup solution until we rewrite the playwright code
return self.run_fetch_browserless_puppeteer(
url,
timeout,
request_headers,
request_body,
request_method,
ignore_status_codes,
current_include_filters,
is_binary)
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
import playwright._impl._api_types import playwright._impl._api_types
@@ -434,7 +293,7 @@ class base_html_playwright(Fetcher):
# Set user agent to prevent Cloudflare from blocking the browser # Set user agent to prevent Cloudflare from blocking the browser
# Use the default one configured in the App.py model that's passed from fetch_site_status.py # Use the default one configured in the App.py model that's passed from fetch_site_status.py
context = browser.new_context( context = browser.new_context(
user_agent=request_headers.get('User-Agent', 'Mozilla/5.0'), user_agent=request_headers['User-Agent'] if request_headers.get('User-Agent') else 'Mozilla/5.0',
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,
@@ -464,12 +323,12 @@ class base_html_playwright(Fetcher):
except playwright._impl._api_types.Error as e: except playwright._impl._api_types.Error as e:
# Retry once - https://github.com/browserless/chrome/issues/2485 # Retry once - https://github.com/browserless/chrome/issues/2485
# Sometimes errors related to invalid cert's and other can be random # Sometimes errors related to invalid cert's and other can be random
print("Content Fetcher > retrying request got error - ", str(e)) print ("Content Fetcher > retrying request got error - ", str(e))
time.sleep(1) time.sleep(1)
response = self.page.goto(url, wait_until='commit') response = self.page.goto(url, wait_until='commit')
except Exception as e: except Exception as e:
print("Content Fetcher > Other exception when page.goto", str(e)) print ("Content Fetcher > Other exception when page.goto", str(e))
context.close() context.close()
browser.close() browser.close()
raise PageUnloadable(url=url, status_code=None, message=str(e)) raise PageUnloadable(url=url, status_code=None, message=str(e))
@@ -488,7 +347,7 @@ class base_html_playwright(Fetcher):
# This can be ok, we will try to grab what we could retrieve # This can be ok, we will try to grab what we could retrieve
pass pass
except Exception as e: except Exception as e:
print("Content Fetcher > Other exception when executing custom JS code", str(e)) print ("Content Fetcher > Other exception when executing custom JS code", str(e))
context.close() context.close()
browser.close() browser.close()
raise PageUnloadable(url=url, status_code=None, message=str(e)) raise PageUnloadable(url=url, status_code=None, message=str(e))
@@ -496,7 +355,7 @@ class base_html_playwright(Fetcher):
if response is None: if response is None:
context.close() context.close()
browser.close() browser.close()
print("Content Fetcher > Response object was none") print ("Content Fetcher > Response object was none")
raise EmptyReply(url=url, status_code=None) raise EmptyReply(url=url, status_code=None)
# Run Browser Steps here # Run Browser Steps here
@@ -510,10 +369,11 @@ class base_html_playwright(Fetcher):
if len(self.page.content().strip()) == 0: if len(self.page.content().strip()) == 0:
context.close() context.close()
browser.close() browser.close()
print("Content Fetcher > Content was empty") print ("Content Fetcher > Content was empty")
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)
@@ -522,9 +382,7 @@ class base_html_playwright(Fetcher):
else: else:
self.page.evaluate("var include_filters=''") self.page.evaluate("var include_filters=''")
self.xpath_data = self.page.evaluate( self.xpath_data = self.page.evaluate("async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}")
"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
@@ -535,8 +393,7 @@ class base_html_playwright(Fetcher):
# acceptable screenshot quality here # acceptable screenshot quality here
try: try:
# The actual screenshot # The actual screenshot
self.screenshot = self.page.screenshot(type='jpeg', full_page=True, self.screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72)))
quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72)))
except Exception as e: except Exception as e:
context.close() context.close()
browser.close() browser.close()
@@ -545,7 +402,6 @@ class base_html_playwright(Fetcher):
context.close() context.close()
browser.close() browser.close()
class base_html_webdriver(Fetcher): class base_html_webdriver(Fetcher):
if os.getenv("WEBDRIVER_URL"): if os.getenv("WEBDRIVER_URL"):
fetcher_description = "WebDriver Chrome/Javascript via '{}'".format(os.getenv("WEBDRIVER_URL")) fetcher_description = "WebDriver Chrome/Javascript via '{}'".format(os.getenv("WEBDRIVER_URL"))
+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')
-183
View File
@@ -1,183 +0,0 @@
module.exports = async ({page, context}) => {
var {
url,
execute_js,
user_agent,
extra_wait_ms,
req_headers,
include_filters,
xpath_element_js,
screenshot_quality,
proxy_username,
proxy_password,
disk_cache_dir,
no_cache_list,
block_url_list,
} = context;
await page.setBypassCSP(true)
await page.setExtraHTTPHeaders(req_headers);
await page.setUserAgent(user_agent);
// https://ourcodeworld.com/articles/read/1106/how-to-solve-puppeteer-timeouterror-navigation-timeout-of-30000-ms-exceeded
await page.setDefaultNavigationTimeout(0);
if (proxy_username) {
await page.authenticate({
username: proxy_username,
password: proxy_password
});
}
await page.setViewport({
width: 1024,
height: 768,
deviceScaleFactor: 1,
});
await page.setRequestInterception(true);
if (disk_cache_dir) {
console.log(">>>>>>>>>>>>>>> LOCAL DISK CACHE ENABLED <<<<<<<<<<<<<<<<<<<<<");
}
const fs = require('fs');
const crypto = require('crypto');
function file_is_expired(file_path) {
if (!fs.existsSync(file_path)) {
return true;
}
var stats = fs.statSync(file_path);
const now_date = new Date();
const expire_seconds = 300;
if ((now_date / 1000) - (stats.mtime.getTime() / 1000) > expire_seconds) {
console.log("CACHE EXPIRED: " + file_path);
return true;
}
return false;
}
page.on('request', async (request) => {
// General blocking of requests that waste traffic
if (block_url_list.some(substring => request.url().toLowerCase().includes(substring))) return request.abort();
if (disk_cache_dir) {
const url = request.url();
const key = crypto.createHash('md5').update(url).digest("hex");
const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/';
// https://stackoverflow.com/questions/4482686/check-synchronously-if-file-directory-exists-in-node-js
if (fs.existsSync(dir_path + key)) {
console.log("* CACHE HIT , using - " + dir_path + key + " - " + url);
const cached_data = fs.readFileSync(dir_path + key);
// @todo headers can come from dir_path+key+".meta" json file
request.respond({
status: 200,
//contentType: 'text/html', //@todo
body: cached_data
});
return;
}
}
request.continue();
});
if (disk_cache_dir) {
page.on('response', async (response) => {
const url = response.url();
// Basic filtering for sane responses
if (response.request().method() != 'GET' || response.request().resourceType() == 'xhr' || response.request().resourceType() == 'document' || response.status() != 200) {
console.log("Skipping (not useful) - Status:" + response.status() + " Method:" + response.request().method() + " ResourceType:" + response.request().resourceType() + " " + url);
return;
}
if (no_cache_list.some(substring => url.toLowerCase().includes(substring))) {
console.log("Skipping (no_cache_list) - " + url);
return;
}
if (url.toLowerCase().includes('data:')) {
console.log("Skipping (embedded-data) - " + url);
return;
}
response.buffer().then(buffer => {
if (buffer.length > 100) {
console.log("Cache - Saving " + response.request().method() + " - " + url + " - " + response.request().resourceType());
const key = crypto.createHash('md5').update(url).digest("hex");
const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/';
if (!fs.existsSync(dir_path)) {
fs.mkdirSync(dir_path, {recursive: true})
}
if (fs.existsSync(dir_path + key)) {
if (file_is_expired(dir_path + key)) {
fs.writeFileSync(dir_path + key, buffer);
}
} else {
fs.writeFileSync(dir_path + key, buffer);
}
}
});
});
}
const r = await page.goto(url, {
waitUntil: 'load'
});
await page.waitForTimeout(1000);
await page.waitForTimeout(extra_wait_ms);
if (execute_js) {
await page.evaluate(execute_js);
await page.waitForTimeout(200);
}
var xpath_data;
var instock_data;
try {
// Not sure the best way here, in the future this should be a new package added to npm then run in browserless
// (Once the old playwright is removed)
xpath_data = await page.evaluate((include_filters) => {%xpath_scrape_code%}, include_filters);
instock_data = await page.evaluate(() => {%instock_scrape_code%});
} catch (e) {
console.log(e);
}
// Protocol error (Page.captureScreenshot): Cannot take screenshot with 0 width can come from a proxy auth failure
// Wrap it here (for now)
var b64s = false;
try {
b64s = await page.screenshot({encoding: "base64", fullPage: true, quality: screenshot_quality, type: 'jpeg'});
} catch (e) {
console.log(e);
}
// May fail on very large pages with 'WARNING: tile memory limits exceeded, some content may not draw'
if (!b64s) {
// @todo after text extract, we can place some overlay text with red background to say 'croppped'
console.error('ERROR: content-fetcher page was maybe too large for a screenshot, reverting to viewport only screenshot');
try {
b64s = await page.screenshot({encoding: "base64", quality: screenshot_quality, type: 'jpeg'});
} catch (e) {
console.log(e);
}
}
var html = await page.content();
return {
data: {
'content': html,
'headers': r.headers(),
'instock_data': instock_data,
'screenshot': b64s,
'status_code': r.status(),
'xpath_data': xpath_data
},
type: 'application/json',
};
};
@@ -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();
+4 -10
View File
@@ -8,14 +8,8 @@
// Some pages like https://www.londonstockexchange.com/stock/NCCL/ncondezi-energy-limited/analysis // Some pages like https://www.londonstockexchange.com/stock/NCCL/ncondezi-energy-limited/analysis
// will automatically force a scroll somewhere, so include the position offset // will automatically force a scroll somewhere, so include the position offset
// Lets hope the position doesnt change while we iterate the bbox's, but this is better than nothing // Lets hope the position doesnt change while we iterate the bbox's, but this is better than nothing
var scroll_y = 0;
try {
scroll_y = +document.documentElement.scrollTop || document.body.scrollTop
} catch (e) {
console.log(e);
}
var scroll_y=+document.documentElement.scrollTop || document.body.scrollTop
// Include the getXpath script directly, easier than fetching // Include the getXpath script directly, easier than fetching
function getxpath(e) { function getxpath(e) {
@@ -44,15 +38,15 @@ const findUpTag = (el) => {
if (el.name !== undefined && el.name.length) { if (el.name !== undefined && el.name.length) {
var proposed = el.tagName + "[name=" + el.name + "]"; var proposed = el.tagName + "[name=" + el.name + "]";
var proposed_element = window.document.querySelectorAll(proposed); var proposed_element = window.document.querySelectorAll(proposed);
if (proposed_element.length) { if(proposed_element.length) {
if (proposed_element.length === 1) { if (proposed_element.length === 1) {
return proposed; return proposed;
} else { } else {
// Some sites change ID but name= stays the same, we can hit it if we know the index // Some sites change ID but name= stays the same, we can hit it if we know the index
// Find all the elements that match and work out the input[n] // Find all the elements that match and work out the input[n]
var n = Array.from(proposed_element).indexOf(el); var n=Array.from(proposed_element).indexOf(el);
// Return a Playwright selector for nthinput[name=zipcode] // Return a Playwright selector for nthinput[name=zipcode]
return proposed + " >> nth=" + n; return proposed+" >> nth="+n;
} }
} }
} }
-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

+54 -44
View File
@@ -114,11 +114,11 @@ $(document).ready(function () {
e.preventDefault() e.preventDefault()
}); });
// When the mouse moves we know which element it should be above
// mousedown will link that to the UI (select the right action, highlight etc)
$('#browsersteps-selector-canvas').bind('mousedown', function (e) { $('#browsersteps-selector-canvas').bind('mousedown', function (e) {
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent
e.preventDefault() e.preventDefault()
console.log(e);
console.log("current xpath in index is " + current_selected_i);
last_click_xy = {'x': parseInt((1 / x_scale) * e.offsetX), 'y': parseInt((1 / y_scale) * e.offsetY)} last_click_xy = {'x': parseInt((1 / x_scale) * e.offsetX), 'y': parseInt((1 / y_scale) * e.offsetY)}
process_selected(current_selected_i); process_selected(current_selected_i);
current_selected_i = false; current_selected_i = false;
@@ -132,7 +132,6 @@ $(document).ready(function () {
} }
}); });
// Debounce and find the current most 'interesting' element we are hovering above
$('#browsersteps-selector-canvas').bind('mousemove', function (e) { $('#browsersteps-selector-canvas').bind('mousemove', function (e) {
if (!xpath_data) { if (!xpath_data) {
return; return;
@@ -152,38 +151,41 @@ $(document).ready(function () {
current_selected_i = false; current_selected_i = false;
// Reverse order - the most specific one should be deeper/"laster" // Reverse order - the most specific one should be deeper/"laster"
// Basically, find the most 'deepest' // Basically, find the most 'deepest'
var possible_elements = []; //$('#browsersteps-selector-canvas').css('cursor', 'pointer');
xpath_data['size_pos'].forEach(function (item, index) { for (var i = xpath_data['size_pos'].length; i !== 0; i--) {
// draw all of them? let them choose somehow?
var sel = xpath_data['size_pos'][i - 1];
// If we are in a bounding-box // If we are in a bounding-box
if (e.offsetY > item.top * y_scale && e.offsetY < item.top * y_scale + item.height * y_scale if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale
&& &&
e.offsetX > item.left * y_scale && e.offsetX < item.left * y_scale + item.width * y_scale e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale
) { ) {
// There could be many elements here, record them all and then we'll find out which is the most 'useful' // Only highlight these interesting types
// (input, textarea, button, A etc) if (1) {
possible_elements.push(item); ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
current_selected_i = i - 1;
break;
// find the smallest one at this x,y
// does it mean sort the xpath list by size (w*h) i think so!
} else {
if (include_text_elements[0].checked === true) {
// blue one with background instead?
ctx.fillStyle = 'rgba(0,0,255, 0.1)';
ctx.strokeStyle = 'rgba(0,0,200, 0.7)';
$('#browsersteps-selector-canvas').css('cursor', 'grab');
ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
current_selected_i = i - 1;
break;
}
}
} }
});
// Find the best one
if (possible_elements.length) {
possible_elements.forEach(function (item, index) {
if (["a", "input", "textarea", "button"].includes(item['tagName'])) {
current_selected_i = item;
}
});
if (!current_selected_i) {
current_selected_i = possible_elements[0];
}
sel = xpath_data['size_pos'][current_selected_i];
ctx.strokeRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale);
ctx.fillRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale);
} }
}.debounce(10)); }.debounce(10));
}); });
@@ -193,16 +195,16 @@ $(document).ready(function () {
// callback for clicking on an xpath on the canvas // callback for clicking on an xpath on the canvas
function process_selected(selected_in_xpath_list) { function process_selected(xpath_data_index) {
found_something = false; found_something = false;
var first_available = $("ul#browser_steps li.empty").first(); var first_available = $("ul#browser_steps li.empty").first();
if (selected_in_xpath_list !== false) { if (xpath_data_index !== false) {
// Nothing focused, so fill in a new one // Nothing focused, so fill in a new one
// if inpt type button or <button> // if inpt type button or <button>
// from the top, find the next not used one and use it // from the top, find the next not used one and use it
var x = selected_in_xpath_list; var x = xpath_data['size_pos'][xpath_data_index];
console.log(x); console.log(x);
if (x && first_available.length) { if (x && first_available.length) {
// @todo will it let you click shit that has a layer ontop? probably not. // @todo will it let you click shit that has a layer ontop? probably not.
@@ -212,18 +214,26 @@ $(document).ready(function () {
$('input[placeholder="Value"]', first_available).addClass('ok').click().focus(); $('input[placeholder="Value"]', first_available).addClass('ok').click().focus();
found_something = true; found_something = true;
} else { } else {
// There's no good way (that I know) to find if this if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') {
// see https://stackoverflow.com/questions/446892/how-to-find-event-listeners-on-a-dom-node-in-javascript-or-in-debugging
// https://codepen.io/azaslavsky/pen/DEJVWv
// So we dont know if its really a clickable element or not :-(
// Assume it is - then we dont fill the pages with unreliable "Click X,Y" selections
// If you switch to "Click X,y" after an element here is setup, it will give the last co-ords anyway
//if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') {
$('select', first_available).val('Click element').change(); $('select', first_available).val('Click element').change();
$('input[type=text]', first_available).first().val(x['xpath']); $('input[type=text]', first_available).first().val(x['xpath']);
found_something = true; found_something = true;
//} }
}
first_available.xpath_data_index = xpath_data_index;
if (!found_something) {
if (include_text_elements[0].checked === true) {
// Suggest that we use as filter?
// @todo filters should always be in the last steps, nothing non-filter after it
found_something = true;
ctx.strokeStyle = 'rgba(0,0,255, 0.9)';
ctx.fillStyle = 'rgba(0,0,255, 0.1)';
$('select', first_available).val('Extract text and use as filter').change();
$('input[type=text]', first_available).first().val(x['xpath']);
include_text_elements[0].checked = false;
}
} }
} }
} }
@@ -238,7 +248,7 @@ $(document).ready(function () {
function start() { function start() {
console.log("Starting browser-steps UI"); console.log("Starting browser-steps UI");
browsersteps_session_id = false; browsersteps_session_id = Date.now();
// @todo This setting of the first one should be done at the datalayer but wtforms doesnt wanna play nice // @todo This setting of the first one should be done at the datalayer but wtforms doesnt wanna play nice
$('#browser_steps >li:first-child').removeClass('empty'); $('#browser_steps >li:first-child').removeClass('empty');
set_first_gotosite_disabled(); set_first_gotosite_disabled();
@@ -246,7 +256,7 @@ $(document).ready(function () {
$('.clear,.remove', $('#browser_steps >li:first-child')).hide(); $('.clear,.remove', $('#browser_steps >li:first-child')).hide();
$.ajax({ $.ajax({
type: "GET", type: "GET",
url: browser_steps_start_url, url: browser_steps_sync_url + "&browsersteps_session_id=" + browsersteps_session_id,
statusCode: { statusCode: {
400: function () { 400: function () {
// More than likely the CSRF token was lost when the server restarted // More than likely the CSRF token was lost when the server restarted
@@ -254,12 +264,12 @@ $(document).ready(function () {
} }
} }
}).done(function (data) { }).done(function (data) {
xpath_data = data.xpath_data;
$("#loading-status-text").fadeIn(); $("#loading-status-text").fadeIn();
browsersteps_session_id = data.browsersteps_session_id;
// This should trigger 'Goto site' // This should trigger 'Goto site'
console.log("Got startup response, requesting Goto-Site (first) step fake click"); console.log("Got startup response, requesting Goto-Site (first) step fake click");
$('#browser_steps >li:first-child .apply').click(); $('#browser_steps >li:first-child .apply').click();
browserless_seconds_remaining = 500; browserless_seconds_remaining = data.browser_time_remaining;
set_first_gotosite_disabled(); set_first_gotosite_disabled();
}).fail(function (data) { }).fail(function (data) {
console.log(data); console.log(data);
@@ -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 -1
View File
@@ -12,7 +12,7 @@ window.addEventListener('hashchange', function () {
var has_errors = document.querySelectorAll(".messages .error"); var has_errors = document.querySelectorAll(".messages .error");
if (!has_errors.length) { if (!has_errors.length) {
if (document.location.hash == "") { if (document.location.hash == "") {
location.replace(document.querySelector(".tabs ul li:first-child a").hash); document.querySelector(".tabs ul li:first-child a").click();
} else { } else {
set_active_tab(); set_active_tab();
} }
@@ -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 %}
+47 -94
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)}}";
@@ -14,17 +14,15 @@
{% endif %} {% endif %}
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
const browser_steps_start_url="{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}";
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">
@@ -36,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>
@@ -52,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") }}
@@ -125,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>
@@ -165,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">
<!-- <!--
@@ -188,12 +169,11 @@ User-Agent: wonderbra 1.0") }}
<span class="loader" > <span class="loader" >
<span id="browsersteps-click-start"> <span id="browsersteps-click-start">
<h2 >Click here to Start</h2> <h2 >Click here to Start</h2>
<svg style="height: 3.5rem;" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Layer_1"/><g id="play_x5F_alt"><path d="M16,0C7.164,0,0,7.164,0,16s7.164,16,16,16s16-7.164,16-16S24.836,0,16,0z M10,24V8l16.008,8L10,24z" style="fill: var(--color-grey-400);"/></g></svg><br> Please allow 10-15 seconds for the browser to connect.
Please allow 10-15 seconds for the browser to connect.<br>
</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>
@@ -223,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 %}
@@ -234,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.
@@ -247,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,
@@ -256,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>
@@ -281,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>
@@ -327,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
@@ -366,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>
@@ -376,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">
@@ -410,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>
@@ -424,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>
+43 -59
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,30 +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;" name="op" value="clear-history">Clear/reset history</button>
<button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" 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 %}
@@ -53,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 }}
@@ -117,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 %}
@@ -179,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>
""" """
+36 -55
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:
@@ -315,9 +298,7 @@ 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})
'has_ldjson_price_data': None})
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)})
@@ -334,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:
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