mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 22:57:18 +00:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			catch-exce
			...
			playwright
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | d0146475b6 | 
| @@ -1,48 +1,45 @@ | ||||
| ## Web Site Change Detection, Monitoring and Notification. | ||||
| #  changedetection.io | ||||
|  | ||||
| <a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub"> | ||||
|   <img src="https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io" alt="Docker Pulls"/> | ||||
| </a> | ||||
| <a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub"> | ||||
|   <img src="https://img.shields.io/github/v/release/dgtlmoon/changedetection.io" alt="Change detection latest tag version"/>  | ||||
| </a> | ||||
|  | ||||
| Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more | ||||
| ## Self-hosted open source change monitoring of web pages. | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  />](https://lemonade.changedetection.io/start) | ||||
| _Know when web pages change! Stay ontop of new information!_  | ||||
|  | ||||
| Live your data-life *pro-actively* instead of *re-actively*, do not rely on manipulative social media for consuming important information. | ||||
|  | ||||
|  | ||||
| [**Don't have time? Let us host it for you! try our extremely affordable subscription use our proxies and support!**](https://lemonade.changedetection.io/start)  | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  /> | ||||
|  | ||||
|  | ||||
| **Get your own private instance now! Let us host it for you!** | ||||
|  | ||||
| [**Try our $6.99/month subscription - unlimited checks, watches and notifications!**](https://lemonade.changedetection.io/start), choose from different geographical locations, let us handle everything for you.  | ||||
|  | ||||
|  | ||||
|  | ||||
| #### Example use cases | ||||
|  | ||||
| - Products and services have a change in pricing | ||||
| - _Out of stock notification_ and _Back In stock notification_ | ||||
| - Governmental department updates (changes are often only on their websites) | ||||
| Know when ... | ||||
|  | ||||
| - Government department updates (changes are often only on their websites) | ||||
| - Local government news (changes are often only on their websites) | ||||
| - New software releases, security advisories when you're not on their mailing list. | ||||
| - Festivals with changes | ||||
| - Realestate listing changes | ||||
| - Know when your favourite whiskey is on sale, or other special deals are announced before anyone else | ||||
| - COVID related news from government websites | ||||
| - University/organisation news from their website | ||||
| - Detect and monitor changes in JSON API responses  | ||||
| - JSON API monitoring and alerting | ||||
| - Changes in legal and other documents | ||||
| - Trigger API calls via notifications when text appears on a website | ||||
| - Glue together APIs using the JSON filter and JSON notifications | ||||
| - Create RSS feeds based on changes in web content | ||||
| - Monitor HTML source code for unexpected changes, strengthen your PCI compliance | ||||
| - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) | ||||
|  | ||||
| _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ | ||||
|  | ||||
| #### 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 | ||||
| - 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!) | ||||
| - Override Request Headers, Specify `POST` or `GET` and other methods | ||||
| - Use the "Visual Selector" to help target specific elements | ||||
| - API monitoring and alerting | ||||
|  | ||||
| **Get monitoring now!** | ||||
|  | ||||
| ```bash | ||||
| $ pip3 install changedetection.io | ||||
| $ pip3 install changedetection.io    | ||||
| ``` | ||||
|  | ||||
| Specify a target for the *datastore path* with `-d` (required) and a *listening port* with `-p` (defaults to `5000`) | ||||
| @@ -54,5 +51,17 @@ $ changedetection.io -d /path/to/empty/data/dir -p 5000 | ||||
|  | ||||
| Then visit http://127.0.0.1:5000 , You should now be able to access the UI. | ||||
|  | ||||
| ### Features | ||||
| - Website monitoring | ||||
| - Change detection of content and analyses | ||||
| - Filters on change (Select by CSS or JSON) | ||||
| - Triggers (Wait for text, wait for regex) | ||||
| - Notification support | ||||
| - JSON API Monitoring | ||||
| - Parse JSON embedded in HTML | ||||
| - (Reverse) Proxy support | ||||
| - Javascript support via WebDriver | ||||
| - RaspberriPi (arm v6/v7/64 support) | ||||
|  | ||||
| See https://github.com/dgtlmoon/changedetection.io for more information. | ||||
|  | ||||
|   | ||||
| @@ -503,7 +503,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         from changedetectionio import fetch_site_status | ||||
|  | ||||
|         # Get the most recent one | ||||
|         newest_history_key = datastore.data['watching'][uuid].get('newest_history_key') | ||||
|         newest_history_key = datastore.get_val(uuid, 'newest_history_key') | ||||
|  | ||||
|         # 0 means that theres only one, so that there should be no 'unviewed' history available | ||||
|         if newest_history_key == 0: | ||||
|   | ||||
| @@ -407,7 +407,8 @@ class base_html_playwright(Fetcher): | ||||
|             else: | ||||
|                 page.evaluate("var css_filter=''") | ||||
|  | ||||
|             self.xpath_data = page.evaluate("async () => {" + self.xpath_element_js + "}") | ||||
|             # str() here must create a dereferenced copy, which allows the GC to release correctly | ||||
|             self.xpath_data = str(page.evaluate("async () => {" + self.xpath_element_js + "}")) | ||||
|  | ||||
|             # Bug 3 in Playwright screenshot handling | ||||
|             # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it | ||||
|   | ||||
| @@ -63,11 +63,13 @@ class perform_site_check(): | ||||
|  | ||||
|  | ||||
|     def run(self, uuid): | ||||
|         timestamp = int(time.time())  # used for storage etc too | ||||
|  | ||||
|         changed_detected = False | ||||
|         screenshot = False  # as bytes | ||||
|         stripped_text_from_html = "" | ||||
|  | ||||
|         watch = self.datastore.data['watching'].get(uuid) | ||||
|         watch = self.datastore.data['watching'][uuid] | ||||
|  | ||||
|         # Protect against file:// access | ||||
|         if re.search(r'^file', watch['url'], re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): | ||||
| @@ -78,7 +80,7 @@ class perform_site_check(): | ||||
|         # Unset any existing notification error | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False} | ||||
|  | ||||
|         extra_headers =self.datastore.data['watching'][uuid].get('headers') | ||||
|         extra_headers = self.datastore.get_val(uuid, 'headers') | ||||
|  | ||||
|         # Tweak the base config with the per-watch ones | ||||
|         request_headers = self.datastore.data['settings']['headers'].copy() | ||||
| @@ -91,9 +93,9 @@ class perform_site_check(): | ||||
|             request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') | ||||
|  | ||||
|         timeout = self.datastore.data['settings']['requests']['timeout'] | ||||
|         url = watch.get('url') | ||||
|         request_body = self.datastore.data['watching'][uuid].get('body') | ||||
|         request_method = self.datastore.data['watching'][uuid].get('method') | ||||
|         url = self.datastore.get_val(uuid, 'url') | ||||
|         request_body = self.datastore.get_val(uuid, 'body') | ||||
|         request_method = self.datastore.get_val(uuid, 'method') | ||||
|         ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False) | ||||
|  | ||||
|         # source: support | ||||
|   | ||||
| @@ -355,8 +355,6 @@ class watchForm(commonSettingsForm): | ||||
|     filter_failure_notification_send = BooleanField( | ||||
|         'Send a notification when the filter can no longer be found on the page', default=False) | ||||
|  | ||||
|     notification_use_default = BooleanField('Use default/system notification settings', default=True) | ||||
|  | ||||
|     def validate(self, **kwargs): | ||||
|         if not super().validate(): | ||||
|             return False | ||||
|   | ||||
| @@ -35,7 +35,6 @@ class model(dict): | ||||
|             'notification_title': default_notification_title, | ||||
|             'notification_body': default_notification_body, | ||||
|             'notification_format': default_notification_format, | ||||
|             'notification_use_default': True, # Use default for new | ||||
|             'notification_muted': False, | ||||
|             'css_filter': '', | ||||
|             'last_error': False, | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| $(document).ready(function () { | ||||
|     function toggle_fetch_backend() { | ||||
| $(document).ready(function() { | ||||
|     function toggle() { | ||||
|         if ($('input[name="fetch_backend"]:checked').val() == 'html_webdriver') { | ||||
|             if (playwright_enabled) { | ||||
|             if(playwright_enabled) { | ||||
|                 // playwright supports headers, so hide everything else | ||||
|                 // See #664 | ||||
|                 $('#requests-override-options #request-method').hide(); | ||||
| @@ -13,8 +13,12 @@ $(document).ready(function () { | ||||
|                 // selenium/webdriver doesnt support anything afaik, hide it all | ||||
|                 $('#requests-override-options').hide(); | ||||
|             } | ||||
|  | ||||
|  | ||||
|             $('#webdriver-override-options').show(); | ||||
|  | ||||
|         } else { | ||||
|  | ||||
|             $('#requests-override-options').show(); | ||||
|             $('#requests-override-options *:hidden').show(); | ||||
|             $('#webdriver-override-options').hide(); | ||||
| @@ -22,27 +26,8 @@ $(document).ready(function () { | ||||
|     } | ||||
|  | ||||
|     $('input[name="fetch_backend"]').click(function (e) { | ||||
|         toggle_fetch_backend(); | ||||
|         toggle(); | ||||
|     }); | ||||
|     toggle_fetch_backend(); | ||||
|     toggle(); | ||||
|  | ||||
|     function toggle_default_notifications() { | ||||
|         var n=$('#notification_urls, #notification_title, #notification_body, #notification_format'); | ||||
|         if ($('#notification_use_default').is(':checked')) { | ||||
|             $('#notification-field-group').fadeOut(); | ||||
|             $(n).each(function (e) { | ||||
|                 $(this).attr('readonly', true); | ||||
|             }); | ||||
|         } else { | ||||
|             $('#notification-field-group').show(); | ||||
|             $(n).each(function (e) { | ||||
|                 $(this).attr('readonly', false); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     $('#notification_use_default').click(function (e) { | ||||
|         toggle_default_notifications(); | ||||
|     }); | ||||
|     toggle_default_notifications(); | ||||
| }); | ||||
|   | ||||
| @@ -244,6 +244,10 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def get_val(self, uuid, val): | ||||
|         # Probably their should be dict... | ||||
|         return self.data['watching'][uuid].get(val) | ||||
|  | ||||
|     # Remove a watchs data but keep the entry (URL etc) | ||||
|     def clear_watch_history(self, uuid): | ||||
|         import pathlib | ||||
| @@ -535,28 +539,4 @@ class ChangeDetectionStore: | ||||
|                 del(watch['last_changed']) | ||||
|             except: | ||||
|                 continue | ||||
|         return | ||||
|  | ||||
|  | ||||
|     def update_5(self): | ||||
|  | ||||
|         from changedetectionio.notification import ( | ||||
|             default_notification_body, | ||||
|             default_notification_format, | ||||
|             default_notification_title, | ||||
|         ) | ||||
|  | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|             try: | ||||
|                 # If it's all the same to the system settings, then prefer system notification settings | ||||
|                 # include \r\n -> \n incase they already hit submit and the browser put \r in | ||||
|                 if watch.get('notification_body').replace('\r\n', '\n') == default_notification_body.replace('\r\n', '\n') and \ | ||||
|                         watch.get('notification_format') == default_notification_format and \ | ||||
|                         watch.get('notification_title').replace('\r\n', '\n') == default_notification_title.replace('\r\n', '\n') and \ | ||||
|                         watch.get('notification_urls') == self.__data['settings']['application']['notification_urls']: | ||||
|                         watch['notification_use_default'] = True | ||||
|                 else: | ||||
|                     watch['notification_use_default'] = False | ||||
|             except: | ||||
|                 continue | ||||
|         return | ||||
| @@ -135,11 +135,9 @@ User-Agent: wonderbra 1.0") }} | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <strong>Note: <i>These settings override the global settings for this watch.</i></strong> | ||||
|                 <fieldset> | ||||
|                     <div  class="pure-control-group inline-radio"> | ||||
|                       {{ render_checkbox_field(form.notification_use_default) }} | ||||
|                     </div> | ||||
|                     <div class="field-group" id="notification-field-group"> | ||||
|                     <div class="field-group"> | ||||
|                         {{ render_common_settings_form(form, current_base_url, emailprefix) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|   | ||||
| @@ -71,7 +71,6 @@ def test_check_notification(client, live_server): | ||||
|         "url": test_url, | ||||
|         "tag": "my tag", | ||||
|         "title": "my title", | ||||
|         # No 'notification_use_default' here, so it's effectively False/off | ||||
|         "headers": "", | ||||
|         "fetch_backend": "html_requests"}) | ||||
|  | ||||
| @@ -216,82 +215,3 @@ def test_notification_validation(client, live_server): | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| # Check that the default VS watch specific notification is hit | ||||
| def test_check_notification_use_default(client, live_server): | ||||
|     set_original_response() | ||||
|     notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tag": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added" in res.data | ||||
|  | ||||
|     ## Setup the local one and enable it | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"notification_urls": notification_url, | ||||
|               "notification_title": "watch-notification", | ||||
|               "notification_body": "watch-body", | ||||
|               'notification_use_default': "True", | ||||
|               "notification_format": "Text", | ||||
|               "url": test_url, | ||||
|               "tag": "my tag", | ||||
|               "title": "my title", | ||||
|               "headers": "", | ||||
|               "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"application-notification_title": "global-notifications-title", | ||||
|               "application-notification_body": "global-notifications-body\n", | ||||
|               "application-notification_format": "Text", | ||||
|               "application-notification_urls": notification_url, | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               "fetch_backend": "html_requests" | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # A change should by default trigger a notification of the global-notifications | ||||
|     time.sleep(1) | ||||
|     set_modified_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(2) | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         assert 'global-notifications-title' in f.read() | ||||
|  | ||||
|     ## Setup the local one and enable it | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"notification_urls": notification_url, | ||||
|               "notification_title": "watch-notification", | ||||
|               "notification_body": "watch-body", | ||||
|               # No 'notification_use_default' here, so it's effectively False/off = "dont use default, use this one" | ||||
|               "notification_format": "Text", | ||||
|               "url": test_url, | ||||
|               "tag": "my tag", | ||||
|               "title": "my title", | ||||
|               "headers": "", | ||||
|               "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     set_original_response() | ||||
|  | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(2) | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         assert 'watch-notification' in f.read() | ||||
|  | ||||
|  | ||||
|     # cleanup for the next | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -7,7 +7,6 @@ from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_cli | ||||
| # Add a site in paused mode, add an invalid filter, we should still have visual selector data ready | ||||
| def test_visual_selector_content_ready(client, live_server): | ||||
|     import os | ||||
|     import json | ||||
|  | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|     live_server_setup(live_server) | ||||
| @@ -34,7 +33,3 @@ def test_visual_selector_content_ready(client, live_server): | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     assert os.path.isfile(os.path.join('test-datastore', uuid, 'last-screenshot.png')), "last-screenshot.png should exist" | ||||
|     assert os.path.isfile(os.path.join('test-datastore', uuid, 'elements.json')), "xpath elements.json data should exist" | ||||
|  | ||||
|     # Open it and see if it roughly looks correct | ||||
|     with open(os.path.join('test-datastore', uuid, 'elements.json'), 'r') as f: | ||||
|         json.load(f) | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class update_worker(threading.Thread): | ||||
|             ) | ||||
|  | ||||
|         # Did it have any notification alerts to hit? | ||||
|         if not watch.get('notification_use_default') and len(watch['notification_urls']): | ||||
|         if len(watch['notification_urls']): | ||||
|             print(">>> Notifications queued for UUID from watch {}".format(watch_uuid)) | ||||
|             n_object['notification_urls'] = watch['notification_urls'] | ||||
|             n_object['notification_title'] = watch['notification_title'] | ||||
| @@ -49,7 +49,7 @@ class update_worker(threading.Thread): | ||||
|             n_object['notification_format'] = watch['notification_format'] | ||||
|  | ||||
|         # No? maybe theres a global setting, queue them all | ||||
|         elif watch.get('notification_use_default') and len(self.datastore.data['settings']['application']['notification_urls']): | ||||
|         elif len(self.datastore.data['settings']['application']['notification_urls']): | ||||
|             print(">>> Watch notification URLs were empty, using GLOBAL notifications for UUID: {}".format(watch_uuid)) | ||||
|             n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls'] | ||||
|             n_object['notification_title'] = self.datastore.data['settings']['application']['notification_title'] | ||||
| @@ -183,9 +183,6 @@ class update_worker(threading.Thread): | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                     except FilterNotFoundInResponse as e: | ||||
|                         if not self.datastore.data['watching'].get(uuid): | ||||
|                             continue | ||||
|  | ||||
|                         err_text = "Warning, filter '{}' not found".format(str(e)) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            # So that we get a trigger when the content is added again | ||||
|   | ||||
		Reference in New Issue
	
	Block a user