Merge branch 'master' into regex-cleanup-311

regex-cleanup-311
dgtlmoon 1 year ago
commit 8c8f378395

@ -30,11 +30,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v1 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@ -45,7 +45,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v1 uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@ -59,4 +59,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1 uses: github/codeql-action/analyze@v2

@ -39,9 +39,9 @@ jobs:
# Or if we are in a tagged release scenario. # Or if we are in a tagged release scenario.
if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != '' if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != ''
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up Python 3.9 - name: Set up Python 3.9
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: 3.9
@ -58,27 +58,27 @@ jobs:
echo ${{ github.ref }} > changedetectionio/tag.txt echo ${{ github.ref }} > changedetectionio/tag.txt
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v3
with: with:
image: tonistiigi/binfmt:latest image: tonistiigi/binfmt:latest
platforms: all platforms: all
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub Container Registry - name: Login to Docker Hub Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v3
with: with:
install: true install: true
version: latest version: latest
@ -88,7 +88,7 @@ jobs:
- name: Build and push :dev - name: Build and push :dev
id: docker_build id: docker_build
if: ${{ github.ref }} == "refs/heads/master" if: ${{ github.ref }} == "refs/heads/master"
uses: docker/build-push-action@v2 uses: docker/build-push-action@v5
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile
@ -105,7 +105,7 @@ jobs:
- name: Build and push :tag - name: Build and push :tag
id: docker_build_tag_release id: docker_build_tag_release
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
uses: docker/build-push-action@v2 uses: docker/build-push-action@v5
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile
@ -125,7 +125,7 @@ jobs:
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 }}
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v2 uses: actions/cache@v3
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}

@ -24,22 +24,22 @@ jobs:
test-container-build: test-container-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up Python 3.9 - name: Set up Python 3.9
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: 3.9
# Just test that the build works, some libraries won't compile on ARM/rPi etc # Just test that the build works, some libraries won't compile on ARM/rPi etc
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v3
with: with:
image: tonistiigi/binfmt:latest image: tonistiigi/binfmt:latest
platforms: all platforms: all
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v3
with: with:
install: true install: true
version: latest version: latest
@ -49,7 +49,7 @@ jobs:
# Check we can still build under alpine/musl # Check we can still build under alpine/musl
- name: Test that the docker containers can build (musl via alpine check) - name: Test that the docker containers can build (musl via alpine check)
id: docker_build_musl id: docker_build_musl
uses: docker/build-push-action@v2 uses: docker/build-push-action@v5
with: with:
context: ./ context: ./
file: ./.github/test/Dockerfile-alpine file: ./.github/test/Dockerfile-alpine
@ -57,7 +57,7 @@ jobs:
- name: Test that the docker containers can build - name: Test that the docker containers can build
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v5
# https://github.com/docker/build-push-action#customizing # https://github.com/docker/build-push-action#customizing
with: with:
context: ./ context: ./

@ -7,11 +7,11 @@ jobs:
test-application: test-application:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
# Mainly just for link/flake8 # Mainly just for link/flake8
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: '3.10' python-version: '3.10'

@ -11,10 +11,10 @@ jobs:
test-pip-build-basics: test-pip-build-basics:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up Python 3.9 - name: Set up Python 3.9
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: 3.9

@ -38,7 +38,9 @@ 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.45.1' __version__ = '0.45.2'
from changedetectionio.store import BASE_URL_NOT_SET_TEXT
datastore = None datastore = None
@ -356,11 +358,9 @@ def changedetection_app(config=None, datastore_o=None):
# Include a link to the diff page, they will have to login here to see if password protection is enabled. # Include a link to the diff page, they will have to login here to see if password protection is enabled.
# Description is the page you watch, link takes you to the diff JS UI page # Description is the page you watch, link takes you to the diff JS UI page
# Dict val base_url will get overriden with the env var if it is set. # Dict val base_url will get overriden with the env var if it is set.
ext_base_url = datastore.data['settings']['application'].get('base_url') ext_base_url = datastore.data['settings']['application'].get('active_base_url')
if ext_base_url:
# Go with overriden value # Because we are called via whatever web server, flask should figure out the right path (
diff_link = {'href': "{}{}".format(ext_base_url, url_for('diff_history_page', uuid=watch['uuid'], _external=False))}
else:
diff_link = {'href': url_for('diff_history_page', uuid=watch['uuid'], _external=True)} diff_link = {'href': url_for('diff_history_page', uuid=watch['uuid'], _external=True)}
fe.link(link=diff_link) fe.link(link=diff_link)
@ -714,7 +714,6 @@ def changedetection_app(config=None, datastore_o=None):
output = render_template("edit.html", output = render_template("edit.html",
available_processors=processors.available_processors(), 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'],
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
form=form, form=form,
has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False, has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False,
@ -804,7 +803,6 @@ def changedetection_app(config=None, datastore_o=None):
output = render_template("settings.html", output = render_template("settings.html",
form=form, form=form,
current_base_url = datastore.data['settings']['application']['base_url'],
hide_remove_pass=os.getenv("SALTED_PASS", False), hide_remove_pass=os.getenv("SALTED_PASS", False),
api_key=datastore.data['settings']['application'].get('api_access_token'), api_key=datastore.data['settings']['application'].get('api_access_token'),
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
@ -1270,10 +1268,10 @@ 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}))
i = 1 i = 1
elif tag != None: elif tag:
# Items that have this current tag # Items that have this current tag
for watch_uuid, watch in datastore.data['watching'].items(): for watch_uuid, watch in datastore.data['watching'].items():
if (tag != None and tag in watch.get('tags', {})): if tag in watch.get('tags', {}):
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
update_q.put( update_q.put(
queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False}) queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False})

@ -57,9 +57,11 @@ def construct_blueprint(datastore: ChangeDetectionStore):
status.update({'status': 'ERROR OTHER', 'length': len(contents), 'text': f"Got empty reply with code {e.status_code} - Access denied"}) status.update({'status': 'ERROR OTHER', 'length': len(contents), 'text': f"Got empty reply with code {e.status_code} - Access denied"})
else: else:
status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': f"Empty reply with code {e.status_code}, needs chrome?"}) status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': f"Empty reply with code {e.status_code}, needs chrome?"})
except content_fetcher.ReplyWithContentButNoText as e:
txt = f"Got reply but with no content - Status code {e.status_code} - It's possible that the filters were found, but contained no usable text (or contained only an image)."
status.update({'status': 'ERROR', 'text': txt})
except Exception as e: except Exception as e:
status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': 'Error: '+str(e)}) status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': 'Error: '+type(e).__name__+str(e)})
else: else:
status.update({'status': 'OK', 'length': len(contents), 'text': ''}) status.update({'status': 'OK', 'length': len(contents), 'text': ''})

@ -77,11 +77,13 @@ class ScreenshotUnavailable(Exception):
class ReplyWithContentButNoText(Exception): class ReplyWithContentButNoText(Exception):
def __init__(self, status_code, url, screenshot=None): def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content=''):
# 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
self.status_code = status_code self.status_code = status_code
self.url = url self.url = url
self.screenshot = screenshot self.screenshot = screenshot
self.has_filters = has_filters
self.html_content = html_content
return return
@ -343,7 +345,7 @@ class base_html_playwright(Fetcher):
'req_headers': request_headers, 'req_headers': request_headers,
'screenshot_quality': int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72)), 'screenshot_quality': int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72)),
'url': url, 'url': url,
'user_agent': request_headers.get('User-Agent', 'Mozilla/5.0'), 'user_agent': {k.lower(): v for k, v in request_headers.items()}.get('user-agent', None),
'proxy_username': self.proxy.get('username', '') if self.proxy else False, 'proxy_username': self.proxy.get('username', '') if self.proxy else False,
'proxy_password': self.proxy.get('password', '') if self.proxy else False, 'proxy_password': self.proxy.get('password', '') if self.proxy else False,
'no_cache_list': [ 'no_cache_list': [
@ -443,7 +445,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={k.lower(): v for k, v in request_headers.items()}.get('user-agent', None),
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,
@ -684,7 +686,7 @@ class html_requests(Fetcher):
is_binary=False): is_binary=False):
# Make requests use a more modern looking user-agent # Make requests use a more modern looking user-agent
if not 'User-Agent' in request_headers: if not {k.lower(): v for k, v in request_headers.items()}.get('user-agent', None):
request_headers['User-Agent'] = os.getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT", request_headers['User-Agent'] = os.getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT",
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36') 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36')

@ -229,16 +229,19 @@ class ValidateJinja2Template(object):
def __call__(self, form, field): def __call__(self, form, field):
from changedetectionio import notification from changedetectionio import notification
from jinja2 import Environment, BaseLoader, TemplateSyntaxError from jinja2 import Environment, BaseLoader, TemplateSyntaxError, UndefinedError
from jinja2.meta import find_undeclared_variables from jinja2.meta import find_undeclared_variables
try: try:
jinja2_env = Environment(loader=BaseLoader) jinja2_env = Environment(loader=BaseLoader)
jinja2_env.globals.update(notification.valid_tokens) jinja2_env.globals.update(notification.valid_tokens)
rendered = jinja2_env.from_string(field.data).render() rendered = jinja2_env.from_string(field.data).render()
except TemplateSyntaxError as e: except TemplateSyntaxError as e:
raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e
except UndefinedError as e:
raise ValidationError(f"A variable or function is not defined: {e}") from e
ast = jinja2_env.parse(field.data) ast = jinja2_env.parse(field.data)
undefined = ", ".join(find_undeclared_variables(ast)) undefined = ", ".join(find_undeclared_variables(ast))
@ -502,7 +505,10 @@ class globalSettingsRequestForm(Form):
class globalSettingsApplicationForm(commonSettingsForm): class globalSettingsApplicationForm(commonSettingsForm):
api_access_token_enabled = BooleanField('API access token security check enabled', default=True, validators=[validators.Optional()]) api_access_token_enabled = BooleanField('API access token security check enabled', default=True, validators=[validators.Optional()])
base_url = StringField('Base URL', validators=[validators.Optional()]) base_url = StringField('Notification base URL override',
validators=[validators.Optional()],
render_kw={"placeholder": os.getenv('BASE_URL', 'Not set')}
)
empty_pages_are_a_change = BooleanField('Treat empty pages as a change?', default=False) empty_pages_are_a_change = BooleanField('Treat empty pages as a change?', default=False)
fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()])

@ -208,15 +208,11 @@ def create_notification_parameters(n_object, datastore):
watch_tag = '' watch_tag = ''
# Create URLs to customise the notification with # Create URLs to customise the notification with
base_url = datastore.data['settings']['application']['base_url'] # active_base_url - set in store.py data property
base_url = datastore.data['settings']['application'].get('active_base_url')
watch_url = n_object['watch_url'] watch_url = n_object['watch_url']
# Re #148 - Some people have just {{ base_url }} in the body or title, but this may break some notification services
# like 'Join', so it's always best to atleast set something obvious so that they are not broken.
if base_url == '':
base_url = "<base-url-env-var-not-set>"
diff_url = "{}/diff/{}".format(base_url, uuid) diff_url = "{}/diff/{}".format(base_url, uuid)
preview_url = "{}/preview/{}".format(base_url, uuid) preview_url = "{}/preview/{}".format(base_url, uuid)
@ -226,7 +222,7 @@ def create_notification_parameters(n_object, datastore):
# Valid_tokens also used as a field validator # Valid_tokens also used as a field validator
tokens.update( tokens.update(
{ {
'base_url': base_url if base_url is not None else '', 'base_url': base_url,
'current_snapshot': n_object['current_snapshot'] if 'current_snapshot' in n_object 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': 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_added': n_object.get('diff_added', ''), # Null default in the case we use a test

@ -314,7 +314,12 @@ class perform_site_check(difference_detection_processor):
# 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:
raise content_fetcher.ReplyWithContentButNoText(url=url, status_code=fetcher.get_last_status_code(), screenshot=screenshot) raise content_fetcher.ReplyWithContentButNoText(url=url,
status_code=fetcher.get_last_status_code(),
screenshot=screenshot,
has_filters=has_filter_rule,
html_content=html_content
)
# We rely on the actual text in the html output.. many sites have random script vars etc, # We rely on the actual text in the html output.. many sites have random script vars etc,
# in the future we'll implement other mechanisms. # in the future we'll implement other mechanisms.

@ -18,7 +18,9 @@ module.exports = async ({page, context}) => {
await page.setBypassCSP(true) await page.setBypassCSP(true)
await page.setExtraHTTPHeaders(req_headers); await page.setExtraHTTPHeaders(req_headers);
if (user_agent) {
await page.setUserAgent(user_agent); await page.setUserAgent(user_agent);
}
// https://ourcodeworld.com/articles/read/1106/how-to-solve-puppeteer-timeouterror-navigation-timeout-of-30000-ms-exceeded // https://ourcodeworld.com/articles/read/1106/how-to-solve-puppeteer-timeouterror-navigation-timeout-of-30000-ms-exceeded
await page.setDefaultNavigationTimeout(0); await page.setDefaultNavigationTimeout(0);

@ -5,14 +5,19 @@ function isItemInStock() {
'agotado', 'agotado',
'artikel zurzeit vergriffen', 'artikel zurzeit vergriffen',
'as soon as stock is available', 'as soon as stock is available',
'ausverkauft', // sold out
'available for back order', 'available for back order',
'back-order or out of stock',
'backordered', 'backordered',
'benachrichtigt mich', // notify me
'brak na stanie', 'brak na stanie',
'brak w magazynie', 'brak w magazynie',
'coming soon', 'coming soon',
'currently have any tickets for this', 'currently have any tickets for this',
'currently unavailable', 'currently unavailable',
'dostępne wkrótce',
'en rupture de stock', 'en rupture de stock',
'ist derzeit nicht auf lager',
'item is no longer available', 'item is no longer available',
'message if back in stock', 'message if back in stock',
'nachricht bei', 'nachricht bei',
@ -37,6 +42,7 @@ function isItemInStock() {
'unavailable tickets', 'unavailable tickets',
'we do not currently have an estimate of when this product will be back in stock.', 'we do not currently have an estimate of when this product will be back in stock.',
'zur zeit nicht an lager', 'zur zeit nicht an lager',
'已售完',
]; ];

@ -208,7 +208,7 @@ $(document).ready(function () {
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.
if (x['tagtype'] === 'text' || x['tagtype'] === 'email' || x['tagName'] === 'textarea' || x['tagtype'] === 'password' || x['tagtype'] === 'search') { if (x['tagtype'] === 'text' || x['tagtype'] === 'number' || x['tagtype'] === 'email' || x['tagName'] === 'textarea' || x['tagtype'] === 'password' || x['tagtype'] === 'search') {
$('select', first_available).val('Enter text in field').change(); $('select', first_available).val('Enter text in field').change();
$('input[type=text]', first_available).first().val(x['xpath']); $('input[type=text]', first_available).first().val(x['xpath']);
$('input[placeholder="Value"]', first_available).addClass('ok').click().focus(); $('input[placeholder="Value"]', first_available).addClass('ok').click().focus();

@ -32,5 +32,10 @@ $(document).ready(function () {
window.getSelection().removeAllRanges(); window.getSelection().removeAllRanges();
}); });
$("#notification-token-toggle").click(function (e) {
e.preventDefault();
$('#notification-tokens-info').toggle();
});
}); });

@ -42,4 +42,8 @@ $(document).ready(function () {
$('#notification_urls').val(''); $('#notification_urls').val('');
e.preventDefault(); e.preventDefault();
}); });
$("#notification-token-toggle").click(function (e) {
e.preventDefault();
$('#notification-tokens-info').toggle();
});
}); });

@ -44,7 +44,7 @@
#browser-steps .flex-wrapper { #browser-steps .flex-wrapper {
display: flex; display: flex;
flex-flow: row; flex-flow: row;
height: 600px; /*@todo make this dynamic */ height: 70vh;
} }
/* this is duplicate :( */ /* this is duplicate :( */

@ -50,8 +50,7 @@
#browser-steps .flex-wrapper { #browser-steps .flex-wrapper {
display: flex; display: flex;
flex-flow: row; flex-flow: row;
height: 600px; height: 70vh; }
/*@todo make this dynamic */ }
/* this is duplicate :( */ /* this is duplicate :( */
#browsersteps-selector-wrapper { #browsersteps-selector-wrapper {

@ -18,6 +18,9 @@ import threading
import time import time
import uuid as uuid_builder import uuid as uuid_builder
# Because the server will run as a daemon and wont know the URL for notification links when firing off a notification
BASE_URL_NOT_SET_TEXT = '("Base URL" not set - see settings - notifications)'
dictfilt = lambda x, y: dict([ (i,x[i]) for i in x if i in set(y) ]) dictfilt = lambda x, y: dict([ (i,x[i]) for i in x if i in set(y) ])
# Is there an existing library to ensure some data store (JSON etc) is in sync with CRUD methods? # Is there an existing library to ensure some data store (JSON etc) is in sync with CRUD methods?
@ -175,12 +178,21 @@ class ChangeDetectionStore:
@property @property
def data(self): def data(self):
# Re #152, Return env base_url if not overriden, @todo also prefer the proxy pass url # Re #152, Return env base_url if not overriden
env_base_url = os.getenv('BASE_URL','') # Re #148 - Some people have just {{ base_url }} in the body or title, but this may break some notification services
if not self.__data['settings']['application']['base_url']: # like 'Join', so it's always best to atleast set something obvious so that they are not broken.
self.__data['settings']['application']['base_url'] = env_base_url.strip('" ')
active_base_url = BASE_URL_NOT_SET_TEXT
return self.__data if self.__data['settings']['application'].get('base_url'):
active_base_url = self.__data['settings']['application'].get('base_url')
elif os.getenv('BASE_URL'):
active_base_url = os.getenv('BASE_URL')
# I looked at various ways todo the following, but in the end just copying the dict seemed simplest/most reliable
# even given the memory tradeoff - if you know a better way.. maybe return d|self.__data.. or something
d = self.__data
d['settings']['application']['active_base_url'] = active_base_url.strip('" ')
return d
# Delete a single watch by UUID # Delete a single watch by UUID
def delete(self, uuid): def delete(self, uuid):
@ -327,6 +339,9 @@ class ChangeDetectionStore:
if k in apply_extras: if k in apply_extras:
del apply_extras[k] del apply_extras[k]
if not apply_extras.get('date_created'):
apply_extras['date_created'] = int(time.time())
new_watch.update(apply_extras) new_watch.update(apply_extras)
new_watch.ensure_data_dir_exists() new_watch.ensure_data_dir_exists()
self.__data['watching'][new_uuid] = new_watch self.__data['watching'][new_uuid] = new_watch

@ -13,9 +13,9 @@
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<ul> <ul>
<li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li> <li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li>
<li><code>discord://</code> only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li> <li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) </code> only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</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><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't 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><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></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> <li>Accepts the <code>{{ '{{token}}' }}</code> placeholders listed below</li>
</ul> </ul>
@ -35,18 +35,14 @@
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }} {{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }}
<span class="pure-form-message-inline">Body for all notifications</span> <span class="pure-form-message-inline">Body for all notifications &dash; You can use <a target="_new" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> templating in the notification title, body and URL, and tokens from below.
</div> </span>
<div class="pure-control-group">
<!-- unsure -->
{{ render_field(form.notification_format , class="notification-format") }}
<span class="pure-form-message-inline">Format for all notifications</span>
</div> </div>
<div class="pure-controls"> <div class="pure-controls">
<p class="pure-form-message-inline"> <div id="notification-token-toggle" class="pure-button button-tag button-xsmall">Show token/placeholders</div>
You can use <a target="_new" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> templating in the notification title, body and URL. </div>
</p> <div class="pure-controls" style="display: none;" id="notification-tokens-info">
<table class="pure-table" id="token-table"> <table class="pure-table" id="token-table">
<thead> <thead>
<tr> <tr>
@ -105,7 +101,7 @@
</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 text contents value, useful when combined with JSON or CSS filters
</td> </td>
</tr> </tr>
<tr> <tr>
@ -115,12 +111,15 @@
</tbody> </tbody>
</table> </table>
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<br> <p>
URLs generated by changedetection.io (such as <code>{{ '{{diff_url}}' }}</code>) require the <code>BASE_URL</code> environment variable set.<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. <br>
Your <code>BASE_URL</code> var is currently "{{settings_application['current_base_url']}}" 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_removed%7D%7D-notification-tokens">More Here</a> <br>
<br> </p>
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_removed%7D%7D-notification-tokens">More Here</a> <br> </div>
</div> </div>
<div class="pure-control-group">
{{ render_field(form.notification_format , class="notification-format") }}
<span class="pure-form-message-inline">Format for all notifications</span>
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}

@ -62,14 +62,6 @@
<span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page) <span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page)
</span> </span>
</div> </div>
<div class="pure-control-group">
{{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/",
class="m-d") }}
<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']}}"),
<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>.
</span>
</div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.pager_size) }} {{ render_field(form.application.form.pager_size) }}
<span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span> <span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span>
@ -100,6 +92,13 @@
{{ render_common_settings_form(form.application.form, emailprefix, settings_application) }} {{ render_common_settings_form(form.application.form, emailprefix, settings_application) }}
</div> </div>
</fieldset> </fieldset>
<div class="pure-control-group" id="notification-base-url">
{{ render_field(form.application.form.base_url, class="m-d") }}
<span class="pure-form-message-inline">
Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notification links.<br>
Default value is the system environment variable '<code>BASE_URL</code>' - <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>.
</span>
</div>
</div> </div>
<div class="tab-pane-inner" id="fetching"> <div class="tab-pane-inner" id="fetching">

@ -119,6 +119,9 @@
<a href="{{ url_for('settings_page', uuid=watch.uuid) }}#proxies">Try adding external proxies/locations</a> <a href="{{ url_for('settings_page', uuid=watch.uuid) }}#proxies">Try adding external proxies/locations</a>
{% endif %} {% endif %}
{% if 'empty result or contain only an image' in watch.last_error %}
<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Detecting-changes-in-images">more help here</a>.
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% 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 %}

@ -1,6 +1,6 @@
#!/usr/bin/python3 #!/usr/bin/python3
from .util import set_original_response, set_modified_response, live_server_setup from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
from flask import url_for from flask import url_for
from urllib.request import urlopen from urllib.request import urlopen
from zipfile import ZipFile from zipfile import ZipFile
@ -19,12 +19,12 @@ def test_backup(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("import_page"), url_for("import_page"),
data={"urls": url_for('test_endpoint', _external=True)}, data={"urls": url_for('test_endpoint', _external=True)+"?somechar=őőőőőőőő"},
follow_redirects=True follow_redirects=True
) )
assert b"1 Imported" in res.data assert b"1 Imported" in res.data
time.sleep(3) wait_for_all_checks(client)
res = client.get( res = client.get(
url_for("get_backup"), url_for("get_backup"),

@ -2,7 +2,7 @@
import time import time
from flask import url_for from flask import url_for
from . util import live_server_setup from .util import live_server_setup, wait_for_all_checks
from ..html_tools import * from ..html_tools import *
@ -176,3 +176,77 @@ def test_check_multiple_filters(client, live_server):
assert b"Blob A" in res.data # CSS was ok assert b"Blob A" in res.data # CSS was ok
assert b"Blob B" in res.data # xPath was ok assert b"Blob B" in res.data # xPath was ok
assert b"Blob C" not in res.data # Should not be included assert b"Blob C" not in res.data # Should not be included
# The filter exists, but did not contain anything useful
# Mainly used when the filter contains just an IMG, this can happen when someone selects an image in the visual-selector
# Tests fetcher can throw a "ReplyWithContentButNoText" exception after applying filter and extracting text
def test_filter_is_empty_help_suggestion(client, live_server):
#live_server_setup(live_server)
include_filters = "#blob-a"
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("""<html><body>
<div id="blob-a">
<img src="something.jpg">
</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": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data
wait_for_all_checks(client)
# 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={"include_filters": include_filters,
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests"},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
res = client.get(
url_for("index"),
follow_redirects=True
)
assert b'empty result or contain only an image' in res.data
### Just an empty selector, no image
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("""<html><body>
<div id="blob-a">
<!-- doo doo -->
</div>
</body>
</html>
""")
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(
url_for("index"),
follow_redirects=True
)
assert b'empty result or contain only an image' not in res.data
assert b'but contained no usable text' in res.data

@ -3,7 +3,7 @@ import threading
import queue import queue
import time import time
from changedetectionio import content_fetcher from changedetectionio import content_fetcher, html_tools
from .processors.text_json_diff import FilterNotFoundInResponse from .processors.text_json_diff import FilterNotFoundInResponse
from .processors.restock_diff import UnableToExtractRestockData from .processors.restock_diff import UnableToExtractRestockData
@ -251,7 +251,20 @@ class update_worker(threading.Thread):
# Totally fine, it's by choice - just continue on, nothing more to care about # Totally fine, it's by choice - just continue on, nothing more to care about
# Page had elements/content but no renderable text # Page had elements/content but no renderable text
# Backend (not filters) gave zero output # Backend (not filters) gave zero output
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': "Got HTML content but no text found (With {} reply code).".format(e.status_code)}) extra_help = ""
if e.has_filters:
# Maybe it contains an image? offer a more helpful link
has_img = html_tools.include_filters(include_filters='img',
html_content=e.html_content)
if has_img:
extra_help = ", it's possible that the filters you have give an empty result or contain only an image."
else:
extra_help = ", it's possible that the filters were found, but contained no usable text."
self.datastore.update_watch(uuid=uuid, update_obj={
'last_error': f"Got HTML content but no text found (With {e.status_code} reply code){extra_help}"
})
if e.screenshot: if e.screenshot:
self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot) self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot)
process_changedetection_results = False process_changedetection_results = False

Loading…
Cancel
Save