From 12627002632f4431dd9785214cb8242f95bcf1cc Mon Sep 17 00:00:00 2001 From: Jason Nader Date: Fri, 9 Sep 2022 19:08:01 +0900 Subject: [PATCH 01/27] Fix typo (#924) --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 04f37d13..696eb89b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: # # https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-option-proxy # - # Plain requsts - proxy support example. + # Plain requests - proxy support example. # - HTTP_PROXY=socks5h://10.10.1.10:1080 # - HTTPS_PROXY=socks5h://10.10.1.10:1080 # From e154a3cb7a9171e6ec87df95d8f5d588e5c15e9f Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sat, 10 Sep 2022 15:01:11 +0200 Subject: [PATCH 02/27] Notification system update - set watch to use defaults if it is the same as the default --- changedetectionio/store.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/changedetectionio/store.py b/changedetectionio/store.py index d6f74146..53c50b79 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -536,3 +536,24 @@ class ChangeDetectionStore: except: continue return + + def update_5(self): + # If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings + # In other words - the watch notification_title and notification_body are not needed if they are the same as the default one + current_system_body = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n ")) + current_system_title = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n ")) + for uuid, watch in self.data['watching'].items(): + try: + watch_body = watch.get('notification_body', '') + if watch_body and watch_body.translate(str.maketrans('', '', "\r\n ")) == current_system_body: + # Looks the same as the default one, so unset it + watch['notification_body'] = None + + watch_title = watch.get('notification_title', '') + if watch_title and watch_title.translate(str.maketrans('', '', "\r\n ")) == current_system_title: + # Looks the same as the default one, so unset it + watch['notification_title'] = None + except Exception as e: + continue + return + From b046d6ef32ac95115c7b8800342b1d234fb7a214 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sat, 10 Sep 2022 15:11:31 +0200 Subject: [PATCH 03/27] Notification watch settings - add button to make watch use defaults (empties the settings) --- changedetectionio/static/js/watch-settings.js | 6 ++++++ changedetectionio/templates/_common_fields.jinja | 1 + 2 files changed, 7 insertions(+) diff --git a/changedetectionio/static/js/watch-settings.js b/changedetectionio/static/js/watch-settings.js index b45ca95d..803016bc 100644 --- a/changedetectionio/static/js/watch-settings.js +++ b/changedetectionio/static/js/watch-settings.js @@ -30,4 +30,10 @@ $(document).ready(function() { }); toggle(); + $('#notification-setting-reset-to-default').click(function (e) { + $('#notification_title').val(''); + $('#notification_body').val(''); + $('#notification_format').val('System default'); + + }); }); diff --git a/changedetectionio/templates/_common_fields.jinja b/changedetectionio/templates/_common_fields.jinja index a12f6dff..4c717835 100644 --- a/changedetectionio/templates/_common_fields.jinja +++ b/changedetectionio/templates/_common_fields.jinja @@ -26,6 +26,7 @@ Notification debug logs
+ Use defaults
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }} Title for all notifications From b6009ae9ffd604b097dda44cd1d3679835731949 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sat, 10 Sep 2022 15:19:18 +0200 Subject: [PATCH 04/27] Notification - Reset defaults button should be on edit page only --- changedetectionio/static/js/watch-settings.js | 3 ++- changedetectionio/templates/_common_fields.jinja | 1 - changedetectionio/templates/edit.html | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/changedetectionio/static/js/watch-settings.js b/changedetectionio/static/js/watch-settings.js index 803016bc..902e12f4 100644 --- a/changedetectionio/static/js/watch-settings.js +++ b/changedetectionio/static/js/watch-settings.js @@ -34,6 +34,7 @@ $(document).ready(function() { $('#notification_title').val(''); $('#notification_body').val(''); $('#notification_format').val('System default'); - + $('#notification_urls').val(''); + e.preventDefault(); }); }); diff --git a/changedetectionio/templates/_common_fields.jinja b/changedetectionio/templates/_common_fields.jinja index 4c717835..a12f6dff 100644 --- a/changedetectionio/templates/_common_fields.jinja +++ b/changedetectionio/templates/_common_fields.jinja @@ -26,7 +26,6 @@ Notification debug logs
- Use defaults
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }} Title for all notifications diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index a52579f5..231c2016 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -146,6 +146,8 @@ User-Agent: wonderbra 1.0") }} There are system-wide notification URLs enabled, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications.
{% endif %} + Use system defaults + {{ render_common_settings_form(form, emailprefix, settings_application) }}
From 26e2f21a8075db06ec4d433ef7486c42a86276ee Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sat, 10 Sep 2022 15:29:39 +0200 Subject: [PATCH 05/27] Watch list & notification - Adding extra list batch operations for Mute, Unmute, Reset-to-default --- changedetectionio/__init__.py | 31 +++++++++++++++++-- .../templates/watch-overview.html | 3 ++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index bdfe8b9f..8c88d971 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -1189,7 +1189,7 @@ def changedetection_app(config=None, datastore_o=None): datastore.delete(uuid.strip()) flash("{} watches deleted".format(len(uuids))) - if (op == 'pause'): + elif (op == 'pause'): for uuid in uuids: uuid = uuid.strip() if datastore.data['watching'].get(uuid): @@ -1197,13 +1197,40 @@ def changedetection_app(config=None, datastore_o=None): flash("{} watches paused".format(len(uuids))) - if (op == 'unpause'): + elif (op == 'unpause'): for uuid in uuids: uuid = uuid.strip() if datastore.data['watching'].get(uuid): datastore.data['watching'][uuid.strip()]['paused'] = False flash("{} watches unpaused".format(len(uuids))) + elif (op == 'mute'): + for uuid in uuids: + uuid = uuid.strip() + if datastore.data['watching'].get(uuid): + datastore.data['watching'][uuid.strip()]['notification_muted'] = True + flash("{} watches muted".format(len(uuids))) + + elif (op == 'unmute'): + for uuid in uuids: + uuid = uuid.strip() + if datastore.data['watching'].get(uuid): + datastore.data['watching'][uuid.strip()]['notification_muted'] = False + flash("{} watches un-muted".format(len(uuids))) + + elif (op == 'notification-default'): + from changedetectionio.notification import ( + default_notification_format_for_watch + ) + for uuid in uuids: + uuid = uuid.strip() + if datastore.data['watching'].get(uuid): + datastore.data['watching'][uuid.strip()]['notification_title'] = None + datastore.data['watching'][uuid.strip()]['notification_body'] = None + datastore.data['watching'][uuid.strip()]['notification_urls'] = [] + datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch + flash("{} watches set to use default notification settings".format(len(uuids))) + return redirect(url_for('index')) @app.route("/api/share-url", methods=['GET']) diff --git a/changedetectionio/templates/watch-overview.html b/changedetectionio/templates/watch-overview.html index a9297f03..63dc44de 100644 --- a/changedetectionio/templates/watch-overview.html +++ b/changedetectionio/templates/watch-overview.html @@ -30,6 +30,9 @@
+ + +
From e3381776f255b6002933755935621c2c7e96d17f Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sun, 11 Sep 2022 09:08:13 +0200 Subject: [PATCH 06/27] Notification - code tidyup --- changedetectionio/forms.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 4f1bbd7e..279f7c7f 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -314,14 +314,14 @@ class quickWatchForm(Form): # Common to a single watch and the global settings class commonSettingsForm(Form): - notification_urls = StringListField('Notification URL list', validators=[validators.Optional(), ValidateAppRiseServers()]) - notification_title = StringField('Notification title', default=default_notification_title, validators=[validators.Optional(), ValidateTokensList()]) - notification_body = TextAreaField('Notification body', default=default_notification_body, validators=[validators.Optional(), ValidateTokensList()]) - notification_format = SelectField('Notification format', choices=valid_notification_formats.keys(), default=default_notification_format) + notification_title = StringField('Notification title', validators=[validators.Optional(), ValidateTokensList()]) + notification_body = TextAreaField('Notification body', validators=[validators.Optional(), ValidateTokensList()]) + notification_format = SelectField('Notification format', choices=valid_notification_formats.keys()) fetch_backend = RadioField(u'Fetch method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) extract_title_as_title = BooleanField('Extract from document and use as watch title', default=False) - webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")] ) + webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, + message="Should contain one or more seconds")]) class watchForm(commonSettingsForm): From 22638399c1b2955088d34a08140f54f421f956b8 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 11 Sep 2022 09:23:43 +0200 Subject: [PATCH 07/27] 0.39.19.1 --- changedetectionio/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 8c88d971..06c52d61 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -33,7 +33,7 @@ from flask_wtf import CSRFProtect from changedetectionio import html_tools from changedetectionio.api import api_v1 -__version__ = '0.39.19' +__version__ = '0.39.19.1' datastore = None From 58a52c1f601e03458dc4d5982a60b7eb5e22f01c Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Tue, 13 Sep 2022 15:29:05 +0200 Subject: [PATCH 08/27] Update README.md --- README.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f2e75672..bf6afdb0 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,14 @@ Know when important content changes, we support notifications via Discord, Teleg [**Don't have time? Let us host it for you! try our $6.99/month subscription - use our proxies and support!**](https://lemonade.changedetection.io/start) , _half the price of other website change monitoring services and comes with unlimited watches & checks!_ +- Chrome browser included. +- Super fast, no registration needed setup. +- Start watching and receiving change notifications instantly. -- Automatic Updates, Automatic Backups, No Heroku "paused application", don't miss a change! -- Javascript browser included -- Unlimited checks and watches! +Easily see what changed, examine by word, line, or individual character. + +<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference " title="Self-hosted web page change monitoring context difference " /> #### Example use cases @@ -54,12 +57,6 @@ _Need an actual Chrome runner with Javascript support? We support fetching via W ## Screenshots -### Examine differences in content. - -Easily see what changed, examine by word, line, or individual character. - -<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference " title="Self-hosted web page change monitoring context difference " /> - Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ ### Filter by elements using the Visual Selector tool. From 4c759490da3ea768528ae98a94379fa67e79eed2 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Thu, 15 Sep 2022 15:10:40 +0200 Subject: [PATCH 09/27] Upgrade Playwright to 1.25 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 03463647..4e797e5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN pip install --target=/dependencies -r /requirements.txt # Playwright is an alternative to Selenium # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing -RUN pip install --target=/dependencies playwright~=1.24 \ +RUN pip install --target=/dependencies playwright~=1.25 \ || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." # Final image stage From 2ba55bb47752870b61ac369a54f073be6e43f029 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Thu, 15 Sep 2022 15:25:23 +0200 Subject: [PATCH 10/27] Use proxies.json instead of proxies.txt - see wiki Proxies section (#945) --- changedetectionio/__init__.py | 27 ++++++++++++++++++++++++-- changedetectionio/fetch_site_status.py | 21 ++++++++++++-------- changedetectionio/store.py | 20 +++++-------------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 06c52d61..23693446 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -547,6 +547,7 @@ def changedetection_app(config=None, datastore_o=None): # Defaults for proxy choice if datastore.proxy_list is not None: # When enabled + # @todo # Radio needs '' not None, or incase that the chosen one no longer exists if default['proxy'] is None or not any(default['proxy'] in tup for tup in datastore.proxy_list): default['proxy'] = '' @@ -560,7 +561,10 @@ def changedetection_app(config=None, datastore_o=None): # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead del form.proxy else: - form.proxy.choices = [('', 'Default')] + datastore.proxy_list + form.proxy.choices = [('', 'Default')] + for p in datastore.proxy_list: + form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) + if request.method == 'POST' and form.validate(): extra_update_obj = {} @@ -1368,6 +1372,8 @@ def ticker_thread_check_time_launch_checks(): import random from changedetectionio import update_worker + proxy_last_called_time = {} + recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 20)) print("System env MINIMUM_SECONDS_RECHECK_TIME", recheck_time_minimum_seconds) @@ -1428,10 +1434,27 @@ def ticker_thread_check_time_launch_checks(): if watch.jitter_seconds == 0: watch.jitter_seconds = random.uniform(-abs(jitter), jitter) - seconds_since_last_recheck = now - watch['last_checked'] + if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds: if not uuid in running_uuids and uuid not in [q_uuid for p,q_uuid in update_q.queue]: + + # Proxies can be set to have a limit on seconds between which they can be called + watch_proxy = watch.get('proxy') + if watch_proxy and any([watch_proxy in p for p in datastore.proxy_list]): + # Proxy may also have some threshold minimum + proxy_list_reuse_time_minimum = int(datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0)) + if proxy_list_reuse_time_minimum: + proxy_last_used_time = proxy_last_called_time.get(watch_proxy, 0) + time_since_proxy_used = time.time() - proxy_last_used_time + if time_since_proxy_used < proxy_list_reuse_time_minimum: + # Not enough time difference reached, skip this watch + print("Skipped UUID {} on proxy {}, not enough time between proxy requests".format(uuid, watch_proxy)) + continue + else: + # Record the last used time + proxy_last_called_time[watch_proxy] = int(time.time()) + # Use Epoch time as priority, so we get a "sorted" PriorityQueue, but we can still push a priority 1 into it. priority = int(time.time()) print( diff --git a/changedetectionio/fetch_site_status.py b/changedetectionio/fetch_site_status.py index ed84c0fd..fdc4d7dd 100644 --- a/changedetectionio/fetch_site_status.py +++ b/changedetectionio/fetch_site_status.py @@ -21,6 +21,7 @@ class perform_site_check(): self.datastore = datastore # If there was a proxy list enabled, figure out what proxy_args/which proxy to use + # Returns the proxy as a URL # if watch.proxy use that # fetcher.proxy_override = watch.proxy or main config proxy # Allows override the proxy on a per-request basis @@ -33,18 +34,19 @@ class perform_site_check(): # If its a valid one if any([watch['proxy'] in p for p in self.datastore.proxy_list]): - proxy_args = watch['proxy'] + proxy_args = self.datastore.proxy_list.get(watch['proxy']).get('url') # not valid (including None), try the system one else: system_proxy = self.datastore.data['settings']['requests']['proxy'] # Is not None and exists - if any([system_proxy in p for p in self.datastore.proxy_list]): - proxy_args = system_proxy + if self.datastore.proxy_list.get(): + proxy_args = self.datastore.proxy_list.get(system_proxy).get('url') # Fallback - Did not resolve anything, use the first available if proxy_args is None: - proxy_args = self.datastore.proxy_list[0][0] + first_default = list(self.datastore.proxy_list)[0] + proxy_args = self.datastore.proxy_list.get(first_default).get('url') return proxy_args @@ -68,6 +70,8 @@ class perform_site_check(): stripped_text_from_html = "" watch = self.datastore.data['watching'].get(uuid) + if not watch: + return # Protect against file:// access if re.search(r'^file', watch['url'], re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): @@ -90,7 +94,7 @@ class perform_site_check(): if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') - timeout = self.datastore.data['settings']['requests']['timeout'] + timeout = self.datastore.data['settings']['requests'].get('timeout') url = watch.get('url') request_body = self.datastore.data['watching'][uuid].get('body') request_method = self.datastore.data['watching'][uuid].get('method') @@ -110,9 +114,10 @@ class perform_site_check(): # If the klass doesnt exist, just use a default klass = getattr(content_fetcher, "html_requests") - - proxy_args = self.set_proxy_from_list(watch) - fetcher = klass(proxy_override=proxy_args) + proxy_url = self.set_proxy_from_list(watch) + if proxy_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) diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 53c50b79..11f25283 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -113,9 +113,7 @@ class ChangeDetectionStore: self.__data['settings']['application']['api_access_token'] = secret # Proxy list support - available as a selection in settings when text file is imported - # CSV list - # "name, address", or just "name" - proxy_list_file = "{}/proxies.txt".format(self.datastore_path) + proxy_list_file = "{}/proxies.json".format(self.datastore_path) if path.isfile(proxy_list_file): self.import_proxy_list(proxy_list_file) @@ -437,18 +435,10 @@ class ChangeDetectionStore: unlink(item) def import_proxy_list(self, filename): - import csv - with open(filename, newline='') as f: - reader = csv.reader(f, skipinitialspace=True) - # @todo This loop can could be improved - l = [] - for row in reader: - if len(row): - if len(row)>=2: - l.append(tuple(row[:2])) - else: - l.append(tuple([row[0], row[0]])) - self.proxy_list = l if len(l) else None + with open(filename) as f: + self.proxy_list = json.load(f) + print ("Registered proxy list", list(self.proxy_list.keys())) + # Run all updates From 0e194aa4b4eb79a0ed6135ecedc3663543852e56 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Thu, 15 Sep 2022 16:58:23 +0200 Subject: [PATCH 11/27] Default proxy settings fixes --- changedetectionio/__init__.py | 19 ++++++++++++++----- changedetectionio/fetch_site_status.py | 4 ++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 23693446..de9c3903 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -661,15 +661,16 @@ def changedetection_app(config=None, datastore_o=None): default = deepcopy(datastore.data['settings']) if datastore.proxy_list is not None: + available_proxies = list(datastore.proxy_list.keys()) # When enabled system_proxy = datastore.data['settings']['requests']['proxy'] # In the case it doesnt exist anymore - if not any([system_proxy in tup for tup in datastore.proxy_list]): + if not system_proxy in available_proxies: system_proxy = None - default['requests']['proxy'] = system_proxy if system_proxy is not None else datastore.proxy_list[0][0] + default['requests']['proxy'] = system_proxy if system_proxy is not None else available_proxies[0] # Used by the form handler to keep or remove the proxy settings - default['proxy_list'] = datastore.proxy_list + default['proxy_list'] = available_proxies[0] # Don't use form.data on POST so that it doesnt overrid the checkbox status from the POST status @@ -684,7 +685,10 @@ def changedetection_app(config=None, datastore_o=None): # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead del form.requests.form.proxy else: - form.requests.form.proxy.choices = datastore.proxy_list + form.requests.form.proxy.choices = [] + for p in datastore.proxy_list: + form.requests.form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) + if request.method == 'POST': # Password unset is a GET, but we can lock the session to a salted env password to always need the password @@ -1441,7 +1445,12 @@ def ticker_thread_check_time_launch_checks(): # Proxies can be set to have a limit on seconds between which they can be called watch_proxy = watch.get('proxy') - if watch_proxy and any([watch_proxy in p for p in datastore.proxy_list]): + if not watch_proxy: + watch_proxy = datastore.data['settings']['requests']['proxy'] + if not watch_proxy: + watch_proxy = list(datastore.proxy_list.keys())[0] + + if watch_proxy and watch_proxy in list(datastore.proxy_list.keys()): # Proxy may also have some threshold minimum proxy_list_reuse_time_minimum = int(datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0)) if proxy_list_reuse_time_minimum: diff --git a/changedetectionio/fetch_site_status.py b/changedetectionio/fetch_site_status.py index fdc4d7dd..26113353 100644 --- a/changedetectionio/fetch_site_status.py +++ b/changedetectionio/fetch_site_status.py @@ -33,14 +33,14 @@ class perform_site_check(): return None # If its a valid one - if any([watch['proxy'] in p for p in self.datastore.proxy_list]): + if watch['proxy'] and watch['proxy'] in list(self.datastore.proxy_list.keys()): proxy_args = self.datastore.proxy_list.get(watch['proxy']).get('url') # not valid (including None), try the system one else: system_proxy = self.datastore.data['settings']['requests']['proxy'] # Is not None and exists - if self.datastore.proxy_list.get(): + if self.datastore.proxy_list.get(system_proxy): proxy_args = self.datastore.proxy_list.get(system_proxy).get('url') # Fallback - Did not resolve anything, use the first available From 77fdf59ae3bc14169b45dc42974c75c057b9a90d Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Thu, 15 Sep 2022 17:17:07 +0200 Subject: [PATCH 12/27] Improve Proxy minimum time debug output --- changedetectionio/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index de9c3903..5b8a38af 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -1455,10 +1455,13 @@ def ticker_thread_check_time_launch_checks(): proxy_list_reuse_time_minimum = int(datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0)) if proxy_list_reuse_time_minimum: proxy_last_used_time = proxy_last_called_time.get(watch_proxy, 0) - time_since_proxy_used = time.time() - proxy_last_used_time + time_since_proxy_used = int(time.time() - proxy_last_used_time) if time_since_proxy_used < proxy_list_reuse_time_minimum: # Not enough time difference reached, skip this watch - print("Skipped UUID {} on proxy {}, not enough time between proxy requests".format(uuid, watch_proxy)) + print("> Skipped UUID {} using proxy '{}', not enough time between proxy requests {}s/{}s".format(uuid, + watch_proxy, + time_since_proxy_used, + proxy_list_reuse_time_minimum)) continue else: # Record the last used time From 8567a83c47cea90cfe5be44dad0b503758db5c04 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Fri, 16 Sep 2022 13:21:01 +0200 Subject: [PATCH 13/27] Update README.md - Include BrightData suggestion --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bf6afdb0..0d08d129 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ _Need an actual Chrome runner with Javascript support? We support fetching via W - Execute JS before extracting text (Good for logging in, see examples in the UI!) - Override Request Headers, Specify `POST` or `GET` and other methods - Use the "Visual Selector" to help target specific elements +- Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration) +We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $100 using our signup link. ## Screenshots From d4715e2bc8cd4fc82589072d41d4ed535ebb25ff Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Mon, 19 Sep 2022 13:14:35 +0200 Subject: [PATCH 14/27] Tidy up proxies.json logic, adding tests (#955) --- changedetectionio/__init__.py | 7 +-- changedetectionio/fetch_site_status.py | 37 ++------------- changedetectionio/run_all_tests.sh | 46 ++++++++++++++++++- changedetectionio/store.py | 30 ++++++++++++ .../tests/proxy_list/__init__.py | 2 + .../tests/proxy_list/conftest.py | 14 ++++++ .../tests/proxy_list/proxies.json-example | 10 ++++ changedetectionio/tests/proxy_list/squid.conf | 41 +++++++++++++++++ .../tests/proxy_list/test_multiple_proxy.py | 38 +++++++++++++++ .../tests/proxy_list/test_proxy.py | 19 ++++++++ docker-compose.yml | 2 + 11 files changed, 207 insertions(+), 39 deletions(-) create mode 100644 changedetectionio/tests/proxy_list/__init__.py create mode 100644 changedetectionio/tests/proxy_list/conftest.py create mode 100644 changedetectionio/tests/proxy_list/proxies.json-example create mode 100644 changedetectionio/tests/proxy_list/squid.conf create mode 100644 changedetectionio/tests/proxy_list/test_multiple_proxy.py create mode 100644 changedetectionio/tests/proxy_list/test_proxy.py diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 5b8a38af..07242bf3 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -1444,12 +1444,7 @@ def ticker_thread_check_time_launch_checks(): if not uuid in running_uuids and uuid not in [q_uuid for p,q_uuid in update_q.queue]: # Proxies can be set to have a limit on seconds between which they can be called - watch_proxy = watch.get('proxy') - if not watch_proxy: - watch_proxy = datastore.data['settings']['requests']['proxy'] - if not watch_proxy: - watch_proxy = list(datastore.proxy_list.keys())[0] - + watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid) if watch_proxy and watch_proxy in list(datastore.proxy_list.keys()): # Proxy may also have some threshold minimum proxy_list_reuse_time_minimum = int(datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0)) diff --git a/changedetectionio/fetch_site_status.py b/changedetectionio/fetch_site_status.py index 26113353..79e282b5 100644 --- a/changedetectionio/fetch_site_status.py +++ b/changedetectionio/fetch_site_status.py @@ -20,36 +20,6 @@ class perform_site_check(): super().__init__(*args, **kwargs) self.datastore = datastore - # If there was a proxy list enabled, figure out what proxy_args/which proxy to use - # Returns the proxy as a URL - # if watch.proxy use that - # fetcher.proxy_override = watch.proxy or main config proxy - # Allows override the proxy on a per-request basis - # ALWAYS use the first one is nothing selected - - def set_proxy_from_list(self, watch): - proxy_args = None - if self.datastore.proxy_list is None: - return None - - # If its a valid one - if watch['proxy'] and watch['proxy'] in list(self.datastore.proxy_list.keys()): - proxy_args = self.datastore.proxy_list.get(watch['proxy']).get('url') - - # not valid (including None), try the system one - else: - system_proxy = self.datastore.data['settings']['requests']['proxy'] - # Is not None and exists - if self.datastore.proxy_list.get(system_proxy): - proxy_args = self.datastore.proxy_list.get(system_proxy).get('url') - - # Fallback - Did not resolve anything, use the first available - if proxy_args is None: - first_default = list(self.datastore.proxy_list)[0] - proxy_args = self.datastore.proxy_list.get(first_default).get('url') - - return proxy_args - # Doesn't look like python supports forward slash auto enclosure in re.findall # So convert it to inline flag "foobar(?i)" type configuration def forward_slash_enclosed_regex_to_options(self, regex): @@ -114,9 +84,12 @@ class perform_site_check(): # If the klass doesnt exist, just use a default klass = getattr(content_fetcher, "html_requests") - proxy_url = self.set_proxy_from_list(watch) - if proxy_url: + 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) diff --git a/changedetectionio/run_all_tests.sh b/changedetectionio/run_all_tests.sh index ce428f12..e4ea3bac 100755 --- a/changedetectionio/run_all_tests.sh +++ b/changedetectionio/run_all_tests.sh @@ -48,4 +48,48 @@ pytest tests/test_errorhandling.py pytest tests/visualselector/test_fetch_data.py unset PLAYWRIGHT_DRIVER_URL -docker kill $$-test_browserless \ No newline at end of file +docker kill $$-test_browserless + +# Test proxy list handling, starting two squids on different ports +# Each squid adds a different header to the response, which is the main thing we test for. +docker run -d --name $$-squid-one --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf -p 3128:3128 ubuntu/squid:4.13-21.10_edge +docker run -d --name $$-squid-two --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf -p 3129:3128 ubuntu/squid:4.13-21.10_edge + + +# So, basic HTTP as env var test +export HTTP_PROXY=http://localhost:3128 +export HTTPS_PROXY=http://localhost:3128 +pytest tests/proxy_list/test_proxy.py +docker logs $$-squid-one 2>/dev/null|grep one.changedetection.io +if [ $? -ne 0 ] +then + echo "Did not see a request to one.changedetection.io in the squid logs (while checking env vars HTTP_PROXY/HTTPS_PROXY)" +fi +unset HTTP_PROXY +unset HTTPS_PROXY + + +# 2nd test actually choose the preferred proxy from proxies.json +cp tests/proxy_list/proxies.json-example ./test-datastore/proxies.json +# Makes a watch use a preferred proxy +pytest tests/proxy_list/test_multiple_proxy.py + +# Should be a request in the default "first" squid +docker logs $$-squid-one 2>/dev/null|grep chosen.changedetection.io +if [ $? -ne 0 ] +then + echo "Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy)" +fi + +# And one in the 'second' squid (user selects this as preferred) +docker logs $$-squid-two 2>/dev/null|grep chosen.changedetection.io +if [ $? -ne 0 ] +then + echo "Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy)" +fi + +# @todo - test system override proxy selection and watch defaults, setup a 3rd squid? +docker kill $$-squid-one +docker kill $$-squid-two + + diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 11f25283..4eb5dcd0 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -440,6 +440,36 @@ class ChangeDetectionStore: print ("Registered proxy list", list(self.proxy_list.keys())) + def get_preferred_proxy_for_watch(self, uuid): + """ + Returns the preferred proxy by ID key + :param uuid: UUID + :return: proxy "key" id + """ + + proxy_id = None + if self.proxy_list is None: + return None + + # If its a valid one + watch = self.data['watching'].get(uuid) + + if watch.get('proxy') and watch.get('proxy') in list(self.proxy_list.keys()): + return watch.get('proxy') + + # not valid (including None), try the system one + else: + system_proxy_id = self.data['settings']['requests'].get('proxy') + # Is not None and exists + if self.proxy_list.get(system_proxy_id): + return system_proxy_id + + # Fallback - Did not resolve anything, use the first available + if system_proxy_id is None: + first_default = list(self.proxy_list)[0] + return first_default + + return None # Run all updates # IMPORTANT - Each update could be run even when they have a new install and the schema is correct diff --git a/changedetectionio/tests/proxy_list/__init__.py b/changedetectionio/tests/proxy_list/__init__.py new file mode 100644 index 00000000..085b3d78 --- /dev/null +++ b/changedetectionio/tests/proxy_list/__init__.py @@ -0,0 +1,2 @@ +"""Tests for the app.""" + diff --git a/changedetectionio/tests/proxy_list/conftest.py b/changedetectionio/tests/proxy_list/conftest.py new file mode 100644 index 00000000..95812e2e --- /dev/null +++ b/changedetectionio/tests/proxy_list/conftest.py @@ -0,0 +1,14 @@ +#!/usr/bin/python3 + +from .. import conftest + +#def pytest_addoption(parser): +# parser.addoption("--url_suffix", action="store", default="identifier for request") + + +#def pytest_generate_tests(metafunc): +# # This is called for every test. Only get/set command line arguments +# # if the argument is specified in the list of test "fixturenames". +# option_value = metafunc.config.option.url_suffix +# if 'url_suffix' in metafunc.fixturenames and option_value is not None: +# metafunc.parametrize("url_suffix", [option_value]) \ No newline at end of file diff --git a/changedetectionio/tests/proxy_list/proxies.json-example b/changedetectionio/tests/proxy_list/proxies.json-example new file mode 100644 index 00000000..0ae2178c --- /dev/null +++ b/changedetectionio/tests/proxy_list/proxies.json-example @@ -0,0 +1,10 @@ +{ + "proxy-one": { + "label": "One", + "url": "http://127.0.0.1:3128" + }, + "proxy-two": { + "label": "two", + "url": "http://127.0.0.1:3129" + } +} diff --git a/changedetectionio/tests/proxy_list/squid.conf b/changedetectionio/tests/proxy_list/squid.conf new file mode 100644 index 00000000..615b154d --- /dev/null +++ b/changedetectionio/tests/proxy_list/squid.conf @@ -0,0 +1,41 @@ +acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) +acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) +acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) +acl localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines +acl localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN) +acl localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN) +acl localnet src fc00::/7 # RFC 4193 local private network range +acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines +acl localnet src 159.65.224.174 +acl SSL_ports port 443 +acl Safe_ports port 80 # http +acl Safe_ports port 21 # ftp +acl Safe_ports port 443 # https +acl Safe_ports port 70 # gopher +acl Safe_ports port 210 # wais +acl Safe_ports port 1025-65535 # unregistered ports +acl Safe_ports port 280 # http-mgmt +acl Safe_ports port 488 # gss-http +acl Safe_ports port 591 # filemaker +acl Safe_ports port 777 # multiling http +acl CONNECT method CONNECT + +http_access deny !Safe_ports +http_access deny CONNECT !SSL_ports +http_access allow localhost manager +http_access deny manager +http_access allow localhost +http_access allow localnet +http_access deny all +http_port 3128 +coredump_dir /var/spool/squid +refresh_pattern ^ftp: 1440 20% 10080 +refresh_pattern ^gopher: 1440 0% 1440 +refresh_pattern -i (/cgi-bin/|\?) 0 0% 0 +refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims +refresh_pattern \/InRelease$ 0 0% 0 refresh-ims +refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern . 0 20% 4320 +logfile_rotate 0 + diff --git a/changedetectionio/tests/proxy_list/test_multiple_proxy.py b/changedetectionio/tests/proxy_list/test_multiple_proxy.py new file mode 100644 index 00000000..fcd286eb --- /dev/null +++ b/changedetectionio/tests/proxy_list/test_multiple_proxy.py @@ -0,0 +1,38 @@ +#!/usr/bin/python3 + +import time +from flask import url_for +from ..util import live_server_setup + +def test_preferred_proxy(client, live_server): + time.sleep(1) + live_server_setup(live_server) + time.sleep(1) + url = "http://chosen.changedetection.io" + + res = client.post( + url_for("import_page"), + # Because a URL wont show in squid/proxy logs due it being SSLed + # Use plain HTTP or a specific domain-name here + data={"urls": url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + time.sleep(2) + res = client.post( + url_for("edit_page", uuid="first"), + data={ + "css_filter": "", + "fetch_backend": "html_requests", + "headers": "", + "proxy": "proxy-two", + "tag": "", + "url": url, + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + time.sleep(2) + # Now the request should appear in the second-squid logs diff --git a/changedetectionio/tests/proxy_list/test_proxy.py b/changedetectionio/tests/proxy_list/test_proxy.py new file mode 100644 index 00000000..1f4c5ff4 --- /dev/null +++ b/changedetectionio/tests/proxy_list/test_proxy.py @@ -0,0 +1,19 @@ +#!/usr/bin/python3 + +import time +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client + +# just make a request, we will grep in the docker logs to see it actually got called +def test_check_basic_change_detection_functionality(client, live_server): + live_server_setup(live_server) + res = client.post( + url_for("import_page"), + # Because a URL wont show in squid/proxy logs due it being SSLed + # Use plain HTTP or a specific domain-name here + data={"urls": "http://one.changedetection.io"}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + time.sleep(3) diff --git a/docker-compose.yml b/docker-compose.yml index 696eb89b..65417ee7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: hostname: changedetection volumes: - changedetection-data:/datastore +# Configurable proxy list support, see https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#proxy-list-support +# - ./proxies.json:/datastore/proxies.json # environment: # Default listening port, can also be changed with the -p option From f7ea99412ffcf05e961f129b09da4e5cdd9bae1c Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Mon, 19 Sep 2022 14:02:32 +0200 Subject: [PATCH 15/27] Re #958 - remove change screensize, should be in 1280x720 default, was causing "Unable to retrieve content because the page is navigating and changing the content." on some sites --- changedetectionio/content_fetcher.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/changedetectionio/content_fetcher.py b/changedetectionio/content_fetcher.py index d831ce84..f3af6cb1 100644 --- a/changedetectionio/content_fetcher.py +++ b/changedetectionio/content_fetcher.py @@ -316,6 +316,7 @@ class base_html_playwright(Fetcher): import playwright._impl._api_types from playwright._impl._api_types import Error, TimeoutError response = None + with sync_playwright() as p: browser_type = getattr(p, self.browser_type) @@ -373,8 +374,11 @@ class base_html_playwright(Fetcher): print("response object was none") raise EmptyReply(url=url, status_code=None) - # Bug 2(?) Set the viewport size AFTER loading the page - page.set_viewport_size({"width": 1280, "height": 1024}) + + # Removed browser-set-size, seemed to be needed to make screenshots work reliably in older playwright versions + # Was causing exceptions like 'waiting for page but content is changing' etc + # https://www.browserstack.com/docs/automate/playwright/change-browser-window-size 1280x720 should be the default + extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay time.sleep(extra_wait) @@ -398,6 +402,8 @@ class base_html_playwright(Fetcher): raise JSActionExceptions(status_code=response.status, screenshot=error_screenshot, message=str(e), url=url) + page.wait_for_timeout(500) + self.content = page.content() self.status_code = response.status self.headers = response.all_headers() From 3705ce668156a5b206a46e0b24c789ca6e7bd483 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sat, 24 Sep 2022 23:48:03 +0200 Subject: [PATCH 16/27] Render Extract Configurable Delay Seconds should also apply after executing any JS #958 --- changedetectionio/content_fetcher.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/changedetectionio/content_fetcher.py b/changedetectionio/content_fetcher.py index f3af6cb1..c54f0a14 100644 --- a/changedetectionio/content_fetcher.py +++ b/changedetectionio/content_fetcher.py @@ -402,6 +402,11 @@ class base_html_playwright(Fetcher): raise JSActionExceptions(status_code=response.status, screenshot=error_screenshot, message=str(e), url=url) + else: + # JS eval was run, now we also wait some time if possible to let the page settle + if self.render_extract_delay: + page.wait_for_timeout(self.render_extract_delay * 1000) + page.wait_for_timeout(500) self.content = page.content() From ac98bc9144fac35d894739a782b350ff508af260 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sat, 24 Sep 2022 23:51:26 +0200 Subject: [PATCH 17/27] Upgrade Playwright to 1.26 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4e797e5c..8e528ace 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN pip install --target=/dependencies -r /requirements.txt # Playwright is an alternative to Selenium # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing -RUN pip install --target=/dependencies playwright~=1.25 \ +RUN pip install --target=/dependencies playwright~=1.26 \ || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." # Final image stage From 3ebb2ab9ba593bea346c8ca20364f8690568170b Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 25 Sep 2022 11:05:07 +0200 Subject: [PATCH 18/27] Selenium fetcher - screenshot should be taken after 'wait' time, not before #873 --- changedetectionio/content_fetcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changedetectionio/content_fetcher.py b/changedetectionio/content_fetcher.py index c54f0a14..6742f01c 100644 --- a/changedetectionio/content_fetcher.py +++ b/changedetectionio/content_fetcher.py @@ -525,8 +525,6 @@ class base_html_webdriver(Fetcher): # Selenium doesn't automatically wait for actions as good as Playwright, so wait again self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5))) - self.screenshot = self.driver.get_screenshot_as_png() - # @todo - how to check this? is it possible? self.status_code = 200 # @todo somehow we should try to get this working for WebDriver @@ -537,6 +535,8 @@ class base_html_webdriver(Fetcher): self.content = self.driver.page_source self.headers = {} + self.screenshot = self.driver.get_screenshot_as_png() + # Does the connection to the webdriver work? run a test connection. def is_ready(self): from selenium import webdriver From 738fcfe01c3e44f5860e6087e2075da4eb0bbbd5 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Sun, 9 Oct 2022 11:42:51 +0200 Subject: [PATCH 19/27] Notification library: Bump apprise to 1.1.0 (signal, opsgenie, pagerduty, bark and mailto fixes, adds support for BulkSMS and SMSEagle) (#1002) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8aaef292..15771dbc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ wtforms ~= 3.0 jsonpath-ng ~= 1.5.3 # Notification library -apprise ~= 1.0.0 +apprise ~= 1.1.0 # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 paho-mqtt From 71bc2fed82f51235aa63afe8713ced3c7151661c Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 9 Oct 2022 14:06:07 +0200 Subject: [PATCH 20/27] Remove quotationspage default watch --- changedetectionio/store.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 4eb5dcd0..a0326e41 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -81,8 +81,6 @@ class ChangeDetectionStore: except (FileNotFoundError, json.decoder.JSONDecodeError): if include_default_watches: print("Creating JSON store at", self.datastore_path) - - self.add_watch(url='http://www.quotationspage.com/random.php', tag='test') self.add_watch(url='https://news.ycombinator.com/', tag='Tech news') self.add_watch(url='https://changedetection.io/CHANGELOG.txt', tag='changedetection.io') From cd467df97ad0adbb5b2cd7192891e3cdf7954c44 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 9 Oct 2022 15:51:57 +0200 Subject: [PATCH 21/27] Adding link to BrightData Proxy info (#1003) --- changedetectionio/templates/edit.html | 1 + changedetectionio/templates/settings.html | 2 ++ docs/proxy-example.jpg | Bin 0 -> 46978 bytes 3 files changed, 3 insertions(+) create mode 100644 docs/proxy-example.jpg diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index 231c2016..64e9cee3 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -77,6 +77,7 @@ <span class="pure-form-message-inline"> <p>Use the <strong>Basic</strong> method (default) where your watched site doesn'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> + Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a> </span> </div> {% if form.proxy %} diff --git a/changedetectionio/templates/settings.html b/changedetectionio/templates/settings.html index 2db8c8b6..c912105e 100644 --- a/changedetectionio/templates/settings.html +++ b/changedetectionio/templates/settings.html @@ -99,6 +99,8 @@ <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> </span> + <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> </div> <fieldset class="pure-group" id="webdriver-override-options"> <div class="pure-form-message-inline"> diff --git a/docs/proxy-example.jpg b/docs/proxy-example.jpg new file mode 100644 index 0000000000000000000000000000000000000000..60b391b4ac877e729e40341dc36a3b0aa4caedc2 GIT binary patch literal 46978 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 V4iqZN)hKS9FVi}ei1@eqKLDX!*~$O_ literal 0 HcmV?d00001 From dfa7fc3a81aab616d095ff6716778f6ad219bc1f Mon Sep 17 00:00:00 2001 From: Yusef Ouda <YusefOuda@users.noreply.github.com> Date: Sun, 9 Oct 2022 09:12:45 -0500 Subject: [PATCH 22/27] Adds support for jq JSON path querying engine (#1001) --- README-pip.md | 2 +- README.md | 53 ++++++++++++++-- changedetectionio/fetch_site_status.py | 5 +- changedetectionio/forms.py | 15 +++++ changedetectionio/html_tools.py | 30 +++++---- changedetectionio/templates/edit.html | 10 ++- ...lector.py => test_jsonpath_jq_selector.py} | 61 ++++++++++++++----- requirements.txt | 1 + 8 files changed, 141 insertions(+), 36 deletions(-) rename changedetectionio/tests/{test_jsonpath_selector.py => test_jsonpath_jq_selector.py} (84%) diff --git a/README-pip.md b/README-pip.md index 746175db..b6a00d32 100644 --- a/README-pip.md +++ b/README-pip.md @@ -33,7 +33,7 @@ _Need an actual Chrome runner with Javascript support? We support fetching via W #### Key Features - Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions! -- Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JsonPath rules +- Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JSONPath or jq - Switch between fast non-JS and Chrome JS based "fetchers" - Easily specify how often a site should be checked - Execute JS before extracting text (Good for logging in, see examples in the UI!) diff --git a/README.md b/README.md index 0d08d129..797f8c56 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ _Need an actual Chrome runner with Javascript support? We support fetching via W #### Key Features - Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions! -- Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JsonPath rules +- Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JSONPath or jq - Switch between fast non-JS and Chrome JS based "fetchers" - Easily specify how often a site should be checked - Execute JS before extracting text (Good for logging in, see examples in the UI!) @@ -121,7 +121,7 @@ See the wiki for more information https://github.com/dgtlmoon/changedetection.io ## Filters -XPath, JSONPath and CSS support comes baked in! You can be as specific as you need, use XPath exported from various XPath element query creation tools. +XPath, JSONPath, jq, and CSS support comes baked in! You can be as specific as you need, use XPath exported from various XPath element query creation tools. (We support LXML `re:test`, `re:math` and `re:replace`.) @@ -151,7 +151,7 @@ Now you can also customise your notification content! ## JSON API Monitoring -Detect changes and monitor data in JSON API's by using the built-in JSONPath selectors as a filter / selector. +Detect changes and monitor data in JSON API's by using either JSONPath or jq to filter, parse, and restructure JSON as needed. ![image](https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/json-filter-field-example.png) @@ -159,9 +159,52 @@ This will re-parse the JSON and apply formatting to the text, making it super ea ![image](https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/json-diff-example.png) +### JSONPath or jq? + +For more complex parsing, filtering, and modifying of JSON data, jq is recommended due to the built-in operators and functions. Refer to the [documentation](https://stedolan.github.io/jq/manual/) for more information on jq. + +The example below adds the price in dollars to each item in the JSON data, and then filters to only show items that are greater than 10. + +#### Sample input data from API +``` +{ + "items": [ + { + "name": "Product A", + "priceInCents": 2500 + }, + { + "name": "Product B", + "priceInCents": 500 + }, + { + "name": "Product C", + "priceInCents": 2000 + } + ] +} +``` + +#### Sample jq +`jq:.items[] | . + { "priceInDollars": (.priceInCents / 100) } | select(.priceInDollars > 10)` + +#### Sample output data +``` +{ + "name": "Product A", + "priceInCents": 2500, + "priceInDollars": 25 +} +{ + "name": "Product C", + "priceInCents": 2000, + "priceInDollars": 20 +} +``` + ### Parse JSON embedded in HTML! -When you enable a `json:` filter, you can even automatically extract and parse embedded JSON inside a HTML page! Amazingly handy for sites that build content based on JSON, such as many e-commerce websites. +When you enable a `json:` or `jq:` filter, you can even automatically extract and parse embedded JSON inside a HTML page! Amazingly handy for sites that build content based on JSON, such as many e-commerce websites. ``` <html> @@ -171,7 +214,7 @@ When you enable a `json:` filter, you can even automatically extract and parse e </script> ``` -`json:$.price` would give `23.50`, or you can extract the whole structure +`json:$.price` or `jq:.price` would give `23.50`, or you can extract the whole structure ## Proxy configuration diff --git a/changedetectionio/fetch_site_status.py b/changedetectionio/fetch_site_status.py index 79e282b5..0f84da16 100644 --- a/changedetectionio/fetch_site_status.py +++ b/changedetectionio/fetch_site_status.py @@ -141,8 +141,9 @@ class perform_site_check(): has_filter_rule = True if has_filter_rule: - if 'json:' in css_filter_rule: - stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, jsonpath_filter=css_filter_rule) + json_filter_prefixes = ['json:', 'jq:'] + if any(prefix in css_filter_rule for prefix in json_filter_prefixes): + stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, json_filter=css_filter_rule) is_html = False if is_html or is_source: diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 279f7c7f..7fa17f90 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -304,6 +304,21 @@ class ValidateCSSJSONXPATHInput(object): # Re #265 - maybe in the future fetch the page and offer a # warning/notice that its possible the rule doesnt yet match anything? + if 'jq:' in line: + if not self.allow_json: + raise ValidationError("jq not permitted in this field!") + + import jq + input = line.replace('jq:', '') + + try: + jq.compile(input) + except (ValueError) as e: + message = field.gettext('\'%s\' is not a valid jq expression. (%s)') + raise ValidationError(message % (input, str(e))) + except: + raise ValidationError("A system-error occurred when validating your jq expression") + class quickWatchForm(Form): url = fields.URLField('URL', validators=[validateURL()]) diff --git a/changedetectionio/html_tools.py b/changedetectionio/html_tools.py index a851a4d6..6cc8e20a 100644 --- a/changedetectionio/html_tools.py +++ b/changedetectionio/html_tools.py @@ -3,6 +3,7 @@ from typing import List from bs4 import BeautifulSoup from jsonpath_ng.ext import parse +import jq import re from inscriptis import get_text from inscriptis.model.config import ParserConfig @@ -79,19 +80,26 @@ def extract_element(find='title', html_content=''): return element_text # -def _parse_json(json_data, jsonpath_filter): - s=[] - jsonpath_expression = parse(jsonpath_filter.replace('json:', '')) - match = jsonpath_expression.find(json_data) - +def _parse_json(json_data, json_filter): + if 'json:' in json_filter: + jsonpath_expression = parse(json_filter.replace('json:', '')) + match = jsonpath_expression.find(json_data) + return _get_stripped_text_from_json_match(match) + if 'jq:' in json_filter: + jq_expression = jq.compile(json_filter.replace('jq:', '')) + match = jq_expression.input(json_data).all() + return _get_stripped_text_from_json_match(match) + +def _get_stripped_text_from_json_match(match): + s = [] # More than one result, we will return it as a JSON list. if len(match) > 1: for i in match: - s.append(i.value) + s.append(i.value if hasattr(i, 'value') else i) # Single value, use just the value, as it could be later used in a token in notifications. if len(match) == 1: - s = match[0].value + s = match[0].value if hasattr(match[0], 'value') else match[0] # Re #257 - Better handling where it does not exist, in the case the original 's' value was False.. if not match: @@ -103,16 +111,16 @@ def _parse_json(json_data, jsonpath_filter): return stripped_text_from_html -def extract_json_as_string(content, jsonpath_filter): +def extract_json_as_string(content, json_filter): stripped_text_from_html = False # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded <script type=ldjson> try: - stripped_text_from_html = _parse_json(json.loads(content), jsonpath_filter) + stripped_text_from_html = _parse_json(json.loads(content), json_filter) except json.JSONDecodeError: - # Foreach <script json></script> blob.. just return the first that matches jsonpath_filter + # Foreach <script json></script> blob.. just return the first that matches json_filter s = [] soup = BeautifulSoup(content, 'html.parser') bs_result = soup.findAll('script') @@ -131,7 +139,7 @@ def extract_json_as_string(content, jsonpath_filter): # Just skip it continue else: - stripped_text_from_html = _parse_json(json_data, jsonpath_filter) + stripped_text_from_html = _parse_json(json_data, json_filter) if stripped_text_from_html: break diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index 64e9cee3..907894e1 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -184,8 +184,12 @@ User-Agent: wonderbra 1.0") }} <span class="pure-form-message-inline"> <ul> <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> - <li>JSON - Limit text to this JSON rule, using <a href="https://pypi.org/project/jsonpath-ng/">JSONPath</a>, prefix with <code>"json:"</code>, use <code>json:$</code> to force re-formatting if required, <a - href="https://jsonpath.com/" target="new">test your JSONPath here</a></li> + <li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a>. + <ul> + <li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li> + <li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>.</li> + </ul> + </li> <li>XPath - Limit text to this XPath rule, simply start with a forward-slash, <ul> <li>Example: <code>//*[contains(@class, 'sametext')]</code> or <code>xpath://*[contains(@class, 'sametext')]</code>, <a @@ -194,7 +198,7 @@ User-Agent: wonderbra 1.0") }} </ul> </li> </ul> - Please be sure that you thoroughly understand how to write CSS or JSONPath, XPath selector rules before filing an issue on GitHub! <a + Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath, or jq selector rules before filing an issue on GitHub! <a href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/> </span> </div> diff --git a/changedetectionio/tests/test_jsonpath_selector.py b/changedetectionio/tests/test_jsonpath_jq_selector.py similarity index 84% rename from changedetectionio/tests/test_jsonpath_selector.py rename to changedetectionio/tests/test_jsonpath_jq_selector.py index 729a201d..d0082122 100644 --- a/changedetectionio/tests/test_jsonpath_selector.py +++ b/changedetectionio/tests/test_jsonpath_jq_selector.py @@ -2,7 +2,7 @@ # coding=utf-8 import time -from flask import url_for +from flask import url_for, escape from . util import live_server_setup import pytest @@ -36,16 +36,26 @@ and it can also be repeated from .. import html_tools # See that we can find the second <script> one, which is not broken, and matches our filter - text = html_tools.extract_json_as_string(content, "$.offers.price") + text = html_tools.extract_json_as_string(content, "json:$.offers.price") assert text == "23.5" - text = html_tools.extract_json_as_string('{"id":5}', "$.id") + # also check for jq + text = html_tools.extract_json_as_string(content, "jq:.offers.price") + assert text == "23.5" + + text = html_tools.extract_json_as_string('{"id":5}', "json:$.id") + assert text == "5" + + text = html_tools.extract_json_as_string('{"id":5}', "jq:.id") assert text == "5" # When nothing at all is found, it should throw JSONNOTFound # Which is caught and shown to the user in the watch-overview table with pytest.raises(html_tools.JSONNotFound) as e_info: - html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "$.id") + html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "json:$.id") + + with pytest.raises(html_tools.JSONNotFound) as e_info: + html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "jq:.id") def set_original_ext_response(): data = """ @@ -66,6 +76,7 @@ def set_original_ext_response(): with open("test-datastore/endpoint-content.txt", "w") as f: f.write(data) + return None def set_modified_ext_response(): data = """ @@ -86,6 +97,7 @@ def set_modified_ext_response(): with open("test-datastore/endpoint-content.txt", "w") as f: f.write(data) + return None def set_original_response(): test_return_data = """ @@ -184,10 +196,10 @@ def test_check_json_without_filter(client, live_server): assert b'"<b>' in res.data assert res.data.count(b'{\n') >= 2 + res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data -def test_check_json_filter(client, live_server): - json_filter = 'json:boss.name' - +def check_json_filter(json_filter, client, live_server): set_original_response() # Give the endpoint time to spin up @@ -226,7 +238,7 @@ def test_check_json_filter(client, live_server): res = client.get( url_for("edit_page", uuid="first"), ) - assert bytes(json_filter.encode('utf-8')) in res.data + assert bytes(escape(json_filter).encode('utf-8')) in res.data # Trigger a check client.get(url_for("form_watch_checknow"), follow_redirects=True) @@ -252,10 +264,16 @@ def test_check_json_filter(client, live_server): # And #462 - check we see the proper utf-8 string there assert "Örnsköldsvik".encode('utf-8') in res.data + res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_check_jsonpath_filter(client, live_server): + check_json_filter('json:boss.name', client, live_server) -def test_check_json_filter_bool_val(client, live_server): - json_filter = "json:$['available']" +def test_check_jq_filter(client, live_server): + check_json_filter('jq:.boss.name', client, live_server) +def check_json_filter_bool_val(json_filter, client, live_server): set_original_response() # Give the endpoint time to spin up @@ -304,14 +322,21 @@ def test_check_json_filter_bool_val(client, live_server): # But the change should be there, tho its hard to test the change was detected because it will show old and new versions assert b'false' in res.data + res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_check_jsonpath_filter_bool_val(client, live_server): + check_json_filter_bool_val("json:$['available']", client, live_server) + +def test_check_jq_filter_bool_val(client, live_server): + check_json_filter_bool_val("jq:.available", client, live_server) + # Re #265 - Extended JSON selector test # Stuff to consider here # - Selector should be allowed to return empty when it doesnt match (people might wait for some condition) # - The 'diff' tab could show the old and new content # - Form should let us enter a selector that doesnt (yet) match anything -def test_check_json_ext_filter(client, live_server): - json_filter = 'json:$[?(@.status==Sold)]' - +def check_json_ext_filter(json_filter, client, live_server): set_original_ext_response() # Give the endpoint time to spin up @@ -350,7 +375,7 @@ def test_check_json_ext_filter(client, live_server): res = client.get( url_for("edit_page", uuid="first"), ) - assert bytes(json_filter.encode('utf-8')) in res.data + assert bytes(escape(json_filter).encode('utf-8')) in res.data # Trigger a check client.get(url_for("form_watch_checknow"), follow_redirects=True) @@ -376,3 +401,11 @@ def test_check_json_ext_filter(client, live_server): assert b'ForSale' not in res.data assert b'Sold' in res.data + res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_check_jsonpath_ext_filter(client, live_server): + check_json_ext_filter('json:$[?(@.status==Sold)]', client, live_server) + +def test_check_jq_ext_filter(client, live_server): + check_json_ext_filter('jq:.[] | select(.status | contains("Sold"))', client, live_server) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 15771dbc..26c53131 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ chardet > 2.3.0 wtforms ~= 3.0 jsonpath-ng ~= 1.5.3 +jq ~= 1.3.0 # Notification library apprise ~= 1.1.0 From 17d37fb626f6dce64658e90a8a2cb7a1adcd06b7 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 9 Oct 2022 16:13:32 +0200 Subject: [PATCH 23/27] 0.39.20 --- changedetectionio/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 07242bf3..bb0ed277 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -33,7 +33,7 @@ from flask_wtf import CSRFProtect from changedetectionio import html_tools from changedetectionio.api import api_v1 -__version__ = '0.39.19.1' +__version__ = '0.39.20' datastore = None From 669fd3ae0b8ac11da9b2d4cc7461ee92bd728ec9 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 9 Oct 2022 18:25:36 +0200 Subject: [PATCH 24/27] Dont use default Requests `user-agent` and `accept` headers in playwright+selenium requests, breaks sites such as united.com. (#1004) --- changedetectionio/content_fetcher.py | 5 +++++ changedetectionio/model/App.py | 4 ---- changedetectionio/store.py | 8 ++++++++ requirements.txt | 5 ++++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/changedetectionio/content_fetcher.py b/changedetectionio/content_fetcher.py index 6742f01c..416ed6df 100644 --- a/changedetectionio/content_fetcher.py +++ b/changedetectionio/content_fetcher.py @@ -575,6 +575,11 @@ class html_requests(Fetcher): ignore_status_codes=False, current_css_filter=None): + # Make requests use a more modern looking user-agent + if not 'User-Agent' in request_headers: + 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') + proxies = {} # Allows override the proxy on a per-request basis diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index c5f0e977..daedde1b 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -13,10 +13,6 @@ class model(dict): 'watching': {}, 'settings': { 'headers': { - 'User-Agent': 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'), - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', - 'Accept-Encoding': 'gzip, deflate', # No support for brolti in python requests yet. - 'Accept-Language': 'en-GB,en-US;q=0.9,en;' }, 'requests': { 'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")), # Default 45 seconds diff --git a/changedetectionio/store.py b/changedetectionio/store.py index a0326e41..bd86039a 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -575,3 +575,11 @@ class ChangeDetectionStore: continue return + + # We incorrectly used common header overrides that should only apply to Requests + # These are now handled in content_fetcher::html_requests and shouldnt be passed to Playwright/Selenium + def update_7(self): + # These were hard-coded in early versions + for v in ['User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language']: + if self.data['settings']['headers'].get(v): + del self.data['settings']['headers'][v] diff --git a/requirements.txt b/requirements.txt index 26c53131..68aabe9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,10 @@ flask_restful pytz # Set these versions together to avoid a RequestsDependencyWarning -requests[socks] ~= 2.26 +# >= 2.26 also adds Brotli support if brotli is installed +brotli ~= 1.0 +requests[socks] ~= 2.28 + urllib3 > 1.26 chardet > 2.3.0 From f6faa903407ba859bcbe5d77d316e8ff75238f0c Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 9 Oct 2022 22:29:18 +0200 Subject: [PATCH 25/27] Adding `make` to Dockerfile build as required by jq for ARM devices --- Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8e528ace..24d3490e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,13 +5,14 @@ FROM python:3.8-slim as builder ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 RUN apt-get update && apt-get install -y --no-install-recommends \ - libssl-dev \ - libffi-dev \ + g++ \ gcc \ libc-dev \ + libffi-dev \ + libssl-dev \ libxslt-dev \ - zlib1g-dev \ - g++ + make \ + zlib1g-dev RUN mkdir /install WORKDIR /install From 770b0faa458af0e882fdf91eab7f73099ac9aede Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 9 Oct 2022 22:58:01 +0200 Subject: [PATCH 26/27] Code - check containers build when Dockerfile or requirements.txt changes (#1005) --- .github/workflows/test-container-build.yml | 46 ++++++++++++++++++++++ .github/workflows/test-only.yml | 12 ++---- 2 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/test-container-build.yml diff --git a/.github/workflows/test-container-build.yml b/.github/workflows/test-container-build.yml new file mode 100644 index 00000000..dc6ab712 --- /dev/null +++ b/.github/workflows/test-container-build.yml @@ -0,0 +1,46 @@ +name: ChangeDetection.io Container Build Test + +# Triggers the workflow on push or pull request events +on: + push: + paths: + - requirements.txt + - Dockerfile + + # 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-container-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 + + # Just test that the build works, some libraries won't compile on ARM/rPi etc + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + with: + image: tonistiigi/binfmt:latest + platforms: all + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + with: + install: true + version: latest + driver-opts: image=moby/buildkit:master + + - name: Test that the docker containers can build + id: docker_build + uses: docker/build-push-action@v2 + # https://github.com/docker/build-push-action#customizing + with: + context: ./ + file: ./Dockerfile + platforms: linux/arm/v7,linux/arm/v6,linux/amd64,linux/arm64, + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/.github/workflows/test-only.yml b/.github/workflows/test-only.yml index baf1d178..aac97335 100644 --- a/.github/workflows/test-only.yml +++ b/.github/workflows/test-only.yml @@ -1,28 +1,25 @@ -name: ChangeDetection.io Test +name: ChangeDetection.io App Test # Triggers the workflow on push or pull request events on: [push, pull_request] jobs: - test-build: + test-application: 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: Show env vars - run: set - - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -39,7 +36,4 @@ jobs: # Each test is totally isolated and performs its own cleanup/reset cd changedetectionio; ./run_all_tests.sh - # 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 From 8fb146f3e4fcb4426b579cbadd3e6788158c1bd8 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Sun, 9 Oct 2022 23:05:35 +0200 Subject: [PATCH 27/27] 0.39.20.1 --- changedetectionio/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index bb0ed277..8f6d5a55 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -33,7 +33,7 @@ from flask_wtf import CSRFProtect from changedetectionio import html_tools from changedetectionio.api import api_v1 -__version__ = '0.39.20' +__version__ = '0.39.20.1' datastore = None