diff --git a/README-pip.md b/README-pip.md index 2ed19d73..746175db 100644 --- a/README-pip.md +++ b/README-pip.md @@ -1,45 +1,48 @@ -# changedetection.io -![changedetection.io](https://github.com/dgtlmoon/changedetection.io/actions/workflows/test-only.yml/badge.svg?branch=master) - - Docker Pulls - - - Change detection latest tag version - +## Web Site Change Detection, Monitoring and Notification. -## Self-hosted open source change monitoring of web pages. +Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more -_Know when web pages change! Stay ontop of new information!_ +[Self-hosted web page change monitoring](https://lemonade.changedetection.io/start?src=pip) -Live your data-life *pro-actively* instead of *re-actively*, do not rely on manipulative social media for consuming important information. - - -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. +[**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) #### Example use cases -Know when ... - -- Government department updates (changes are often only on their websites) -- Local government news (changes are often only on their websites) +- 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) - 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 -- API monitoring and alerting +- 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!_ + +#### 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 -**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`) @@ -51,17 +54,5 @@ $ 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. diff --git a/README.md b/README.md index 6139888b..f2e75672 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more -[Self-hosted web page change monitoring](https://lemonade.changedetection.io/start) +[Self-hosted web page change monitoring](https://lemonade.changedetection.io/start?src=github) [![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index d75f6a73..c9177403 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -1,16 +1,5 @@ #!/usr/bin/python3 - -# @todo logging -# @todo extra options for url like , verify=False etc. -# @todo enable https://urllib3.readthedocs.io/en/latest/user-guide.html#ssl as option? -# @todo option for interval day/6 hour/etc -# @todo on change detected, config for calling some API -# @todo fetch title into json -# https://distill.io/features -# proxy per check -# - flask_cors, itsdangerous,MarkupSafe - import datetime import os import queue @@ -44,7 +33,7 @@ from flask_wtf import CSRFProtect from changedetectionio import html_tools from changedetectionio.api import api_v1 -__version__ = '0.39.18' +__version__ = '0.39.19.1' datastore = None @@ -552,10 +541,6 @@ def changedetection_app(config=None, datastore_o=None): # be sure we update with a copy instead of accidently editing the live object by reference default = deepcopy(datastore.data['watching'][uuid]) - # Show system wide default if nothing configured - if datastore.data['watching'][uuid]['fetch_backend'] is None: - default['fetch_backend'] = datastore.data['settings']['application']['fetch_backend'] - # Show system wide default if nothing configured if all(value == 0 or value == None for value in datastore.data['watching'][uuid]['time_between_check'].values()): default['time_between_check'] = deepcopy(datastore.data['settings']['requests']['time_between_check']) @@ -598,10 +583,8 @@ def changedetection_app(config=None, datastore_o=None): if form.fetch_backend.data == datastore.data['settings']['application']['fetch_backend']: extra_update_obj['fetch_backend'] = None - # Notification URLs - datastore.data['watching'][uuid]['notification_urls'] = form.notification_urls.data - # Ignore text + # Ignore text form_ignore_text = form.ignore_text.data datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text @@ -655,9 +638,11 @@ def changedetection_app(config=None, datastore_o=None): watch=datastore.data['watching'][uuid], form=form, has_empty_checktime=using_default_check_time, + has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False, using_global_webdriver_wait=default['webdriver_delay'] is None, current_base_url=datastore.data['settings']['application']['base_url'], emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), + settings_application=datastore.data['settings']['application'], visualselector_data_is_ready=visualselector_data_is_ready, visualselector_enabled=visualselector_enabled, playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False) @@ -687,6 +672,10 @@ def changedetection_app(config=None, datastore_o=None): form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None, data=default ) + + # Remove the last option 'System default' + form.application.form.notification_format.choices.pop() + if datastore.proxy_list is None: # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead del form.requests.form.proxy @@ -732,7 +721,8 @@ def changedetection_app(config=None, datastore_o=None): current_base_url = datastore.data['settings']['application']['base_url'], hide_remove_pass=os.getenv("SALTED_PASS", False), api_key=datastore.data['settings']['application'].get('api_access_token'), - emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False)) + emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), + settings_application=datastore.data['settings']['application']) return output @@ -1199,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): @@ -1207,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/forms.py b/changedetectionio/forms.py index 13d576a4..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(), ValidateNotificationBodyAndTitleWhenURLisSet(), 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_urls = StringListField('Notification URL list', validators=[validators.Optional(), ValidateAppRiseServers()]) + 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): @@ -355,7 +355,7 @@ 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) + notification_muted = BooleanField('Notifications Muted / Off', default=False) def validate(self, **kwargs): if not super().validate(): diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py index 0efa9976..a168aabd 100644 --- a/changedetectionio/model/Watch.py +++ b/changedetectionio/model/Watch.py @@ -6,9 +6,7 @@ minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60) mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} from changedetectionio.notification import ( - default_notification_body, - default_notification_format, - default_notification_title, + default_notification_format_for_watch ) @@ -32,10 +30,9 @@ class model(dict): 'ignore_text': [], # List of text to ignore when calculating the comparison checksum # Custom notification content 'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise) - 'notification_title': default_notification_title, - 'notification_body': default_notification_body, - 'notification_format': default_notification_format, - 'notification_use_default': True, # Use default for new + 'notification_title': None, + 'notification_body': None, + 'notification_format': default_notification_format_for_watch, 'notification_muted': False, 'css_filter': '', 'last_error': False, diff --git a/changedetectionio/notification.py b/changedetectionio/notification.py index ba27c94a..55364fcd 100644 --- a/changedetectionio/notification.py +++ b/changedetectionio/notification.py @@ -14,16 +14,19 @@ valid_tokens = { 'current_snapshot': '' } +default_notification_format_for_watch = 'System default' +default_notification_format = 'Text' +default_notification_body = '{watch_url} had a change.\n---\n{diff}\n---\n' +default_notification_title = 'ChangeDetection.io Notification - {watch_url}' + valid_notification_formats = { 'Text': NotifyFormat.TEXT, 'Markdown': NotifyFormat.MARKDOWN, 'HTML': NotifyFormat.HTML, + # Used only for editing a watch (not for global) + default_notification_format_for_watch: default_notification_format_for_watch } -default_notification_format = 'Text' -default_notification_body = '{watch_url} had a change.\n---\n{diff}\n---\n' -default_notification_title = 'ChangeDetection.io Notification - {watch_url}' - def process_notification(n_object, datastore): # Get the notification body from datastore diff --git a/changedetectionio/static/images/notice.svg b/changedetectionio/static/images/notice.svg new file mode 100644 index 00000000..8a7060b2 --- /dev/null +++ b/changedetectionio/static/images/notice.svg @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="20.108334mm" + height="21.43125mm" + viewBox="0 0 20.108334 21.43125" + version="1.1" + id="svg5" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs2" /> + <g + id="layer1" + transform="translate(-141.05873,-76.816635)"> + <image + width="20.108334" + height="21.43125" + preserveAspectRatio="none" + style="image-rendering:optimizeQuality" + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEwAAABRCAYAAAB430BuAAAABHNCSVQICAgIfAhkiAAABLxJREFU +eJztnN2Z2jgUhl8Z7petIGwF0WMXsFBBoIKwFWS2gmQryKSCJRXsTAUDBTDRVBCmgkAB9tkLexh+ +bIONLGwP7xU2RjafpaOjoyNBCxHNQAJEfG5sl+3ZLrAWeAyST5/sF91mFH3bRbZbsAq4ClaQq2B7 +iKYnmg9Z318F20ICRnj8pMOd6E3HscNVsATxmQD/oeghPCnDLO26q2AkYin+TQ7XREyyrn3zgu2J +BSEjZTBZ179pwQ7EEv7KaoovvFnBUsV6ZHrsd+0WTHhKPV1SLGivYEsA1KEtEs2grFitRjQ65VxP +fH5JgEjAKsvXupKwFfYxaYJeSeHcWqVSCuwD7/HQQD8lRHLWDStBWG3slbAElkTc5/lTZdkIJhpN +h6/UUZDyzAgZK8PKVoEKErE8HlD0bBVcI2ZqwdBWYbFgAT+g1UZwrBbcvRyIpofHJ1Sh1rQCZt1k +lN5msQAm8CoYoFF8KVHOsFtQ5aayExBUhpnopJl6J/3/FREGWCrxmaH40/4z1oyQ320Yf5dDozXC +P4QMCRkCY4S5w/tbMTtd4L2Ngo6wJmSQ4hfdScAU+OjgGazgOXEl8oJyof3Z6Spx0iTzgnLKsMoK +w9SRuoR3rHniVVMXwRpDXQR7d+kHOJV6CFZB0khVOBGsTcE6VzWsNVGQizfJptU+N4LlD3AbVfsu +XsOahhvB8nrB08IrtcGNYNIct+EYl2+S6mr0D8kLUMrV6BfFRTzOGs4Ey8p1aNrUnssaliaMO/vV +sfNi3AmW5j54DgUTO/dyJ1hab9iwHhLcNskP23ZMND0kewFBXek6vZvHg/hMiUPSN00z+OBasFig +y8wSRfnZ0adSBz+sUVwFK4jbJhnPP06To1ETczpcCnavHhltHd82LU0AXDbJMGXBU8PSBAA8Jxk0 +wnNaqlGSJuAyg+dsXIV38iZqXU3iWsmodhetSNlDQgJGriZxbWVSe1hS/gQ+S/C6j4QEfES21vxU +icXsoC4vC5mqJvbybyXgduucG/YWaYmmj+IdHvpoxFdt8ltRP5h3iZjRqfBh60C4t1rNY7rxAU95 +aYnhEp+/u8pgxGfeRCfyJIR5SkLfFOHYXMMzu63PEDF9WQnSo8MUmhduyUWYEzGyvnRmU3683ugG +GAG/2bqJU4RnFDNCpsfWb5chswUnwb5Xg+hxiyo9w7MGJoSVpmYulam+A8scS+5nPYtf+s9mpZw7 +J1nayDnCVuu4Ck+E6DqIBYDHHR1+is/n8kVUhfBExMBFMzm4taafkXcWL9BSfBG/nNN8sutYcE3S +d7XI3o6lSpIe/xcAIX/svzDxMVu22BAyLNKL2q9hwrdLiZWwXbP6B99GDLaGSpoOD6JPn4yxK1i8 +B0StY1zKsCJiQNxzQ0HRbAm2BsZN2TBDGVaE5USzIVjsNix2VrzWHmUwB6J5fD32uyKCzQ7OxG5D +vzZuQ0E2osXjRlBMjvWe5WtYPE4b2BynXQJlMEToTUegmEiwM1mzQ1nBvqvH5ov1wlZHcA+AZHdc +xQW7vNuQS9kBtzKs1IIRMM7b0q/YvGTzto4qbFutdV5FnLtLk2x3JVWUfXKTbIu9Opc2J6Osj19S +HLfJKO64r6rg/wFBX3+2ZapW8wAAAABJRU5ErkJggg== +" + id="image832" + x="141.05873" + y="76.816635" /> + </g> +</svg> diff --git a/changedetectionio/static/images/play.svg b/changedetectionio/static/images/play.svg new file mode 100644 index 00000000..6b41c63d --- /dev/null +++ b/changedetectionio/static/images/play.svg @@ -0,0 +1,122 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + version="1.1" + id="Capa_1" + x="0px" + y="0px" + viewBox="0 0 15 14.998326" + xml:space="preserve" + width="15" + height="14.998326" + sodipodi:docname="play.svg" + inkscape:version="1.1.1 (1:1.1+202109281949+c3084ef5ed)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview + id="namedview21" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + inkscape:zoom="45.47174" + inkscape:cx="7.4991632" + inkscape:cy="7.4991632" + inkscape:window-width="1554" + inkscape:window-height="896" + inkscape:window-x="3048" + inkscape:window-y="227" + inkscape:window-maximized="0" + inkscape:current-layer="Capa_1" /><metadata + id="metadata39"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs + id="defs37" /> +<path + id="path2" + style="fill:#1b98f8;fill-opacity:1;stroke-width:0.0292893" + d="M 7.4980469,0 C 4.5496028,-0.04093755 1.7047721,1.8547661 0.58789062,4.5800781 -0.57819305,7.2574082 0.02636631,10.583252 2.0703125,12.671875 4.0368718,14.788335 7.2754393,15.560096 9.9882812,14.572266 12.800219,13.617028 14.874915,10.855516 14.986328,7.8847656 15.172991,4.9968456 13.497714,2.109448 10.910156,0.8203125 9.858961,0.28011352 8.6796569,-0.00179908 7.4980469,0 Z" + sodipodi:nodetypes="ccccccc" /> +<g + id="g4" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g6" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g8" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g10" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g12" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g14" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g16" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g18" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g20" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g22" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g24" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g26" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g28" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g30" + transform="translate(-0.01903604,0.02221043)"> +</g> +<g + id="g32" + transform="translate(-0.01903604,0.02221043)"> +</g> +<path + sodipodi:type="star" + style="fill:#ffffff;fill-opacity:1;stroke-width:37.7953;paint-order:stroke fill markers" + id="path1203" + inkscape:flatsided="false" + sodipodi:sides="3" + sodipodi:cx="7.2964563" + sodipodi:cy="7.3240671" + sodipodi:r1="3.805218" + sodipodi:r2="1.9026089" + sodipodi:arg1="-0.0017436774" + sodipodi:arg2="1.0454539" + inkscape:rounded="0" + inkscape:randomized="0" + d="M 11.101669,7.317432 8.2506324,8.9701135 5.3995964,10.622795 5.3938504,7.3273846 5.3881041,4.0319742 8.2448863,5.6747033 Z" + inkscape:transform-center-x="-0.94843001" + inkscape:transform-center-y="0.0033175346" /></svg> diff --git a/changedetectionio/static/js/watch-settings.js b/changedetectionio/static/js/watch-settings.js index 249f2e4c..902e12f4 100644 --- a/changedetectionio/static/js/watch-settings.js +++ b/changedetectionio/static/js/watch-settings.js @@ -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,15 @@ $(document).ready(function () { } $('input[name="fetch_backend"]').click(function (e) { - toggle_fetch_backend(); + toggle(); }); - toggle_fetch_backend(); - - 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); - }); - } - } + toggle(); - $('#notification_use_default').click(function (e) { - toggle_default_notifications(); + $('#notification-setting-reset-to-default').click(function (e) { + $('#notification_title').val(''); + $('#notification_body').val(''); + $('#notification_format').val('System default'); + $('#notification_urls').val(''); + e.preventDefault(); }); - toggle_default_notifications(); }); diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css index 0444da7a..9835c9b0 100644 --- a/changedetectionio/static/styles/styles.css +++ b/changedetectionio/static/styles/styles.css @@ -565,3 +565,16 @@ ul { .checkbox-uuid > * { vertical-align: middle; } + +.inline-warning { + border: 1px solid #ff3300; + padding: 0.5rem; + border-radius: 5px; + color: #ff3300; } + .inline-warning > span { + display: inline-block; + vertical-align: middle; } + .inline-warning img.inline-warning-icon { + display: inline; + height: 26px; + vertical-align: middle; } diff --git a/changedetectionio/static/styles/styles.scss b/changedetectionio/static/styles/styles.scss index 4839aec1..e8b4a6ab 100644 --- a/changedetectionio/static/styles/styles.scss +++ b/changedetectionio/static/styles/styles.scss @@ -786,3 +786,21 @@ ul { vertical-align: middle; } } + +.inline-warning { + > span { + display: inline-block; + vertical-align: middle; + } + + img.inline-warning-icon { + display: inline; + height: 26px; + vertical-align: middle; + } + + border: 1px solid #ff3300; + padding: 0.5rem; + border-radius: 5px; + color: #ff3300; +} \ No newline at end of file diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 01b40b4d..53c50b79 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -537,26 +537,23 @@ class ChangeDetectionStore: continue return - def update_5(self): - - from changedetectionio.notification import ( - default_notification_body, - default_notification_format, - default_notification_title, - ) - + # 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: - # 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: + 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 \ No newline at end of file + return + diff --git a/changedetectionio/templates/_common_fields.jinja b/changedetectionio/templates/_common_fields.jinja index e8f1bd5a..a12f6dff 100644 --- a/changedetectionio/templates/_common_fields.jinja +++ b/changedetectionio/templates/_common_fields.jinja @@ -1,13 +1,14 @@ {% from '_helpers.jinja' import render_field %} -{% macro render_common_settings_form(form, current_base_url, emailprefix) %} +{% macro render_common_settings_form(form, emailprefix, settings_application) %} <div class="pure-control-group"> {{ render_field(form.notification_urls, rows=5, placeholder="Examples: Gitter - gitter://token/room Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo - SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com", class="notification-urls") + SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com", + class="notification-urls" ) }} <div class="pure-form-message-inline"> <ul> @@ -26,15 +27,16 @@ </div> <div id="notification-customisation" class="pure-control-group"> <div class="pure-control-group"> - {{ render_field(form.notification_title, class="m-d notification-title") }} + {{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }} <span class="pure-form-message-inline">Title for all notifications</span> </div> <div class="pure-control-group"> - {{ render_field(form.notification_body , rows=5, class="notification-body") }} + {{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }} <span class="pure-form-message-inline">Body for all notifications</span> </div> <div class="pure-control-group"> - {{ render_field(form.notification_format , rows=5, class="notification-format") }} + <!-- unsure --> + {{ render_field(form.notification_format , class="notification-format") }} <span class="pure-form-message-inline">Format for all notifications</span> </div> <div class="pure-controls"> @@ -94,7 +96,7 @@ </table> <br/> URLs generated by changedetection.io (such as <code>{diff_url}</code>) require the <code>BASE_URL</code> environment variable set.<br/> - Your <code>BASE_URL</code> var is currently "{{current_base_url}}" + Your <code>BASE_URL</code> var is currently "{{settings_application['current_base_url']}}" </span> </div> </div> diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index 47cb7815..231c2016 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -137,10 +137,18 @@ User-Agent: wonderbra 1.0") }} <div class="tab-pane-inner" id="notifications"> <fieldset> <div class="pure-control-group inline-radio"> - {{ render_checkbox_field(form.notification_use_default) }} + {{ render_checkbox_field(form.notification_muted) }} </div> <div class="field-group" id="notification-field-group"> - {{ render_common_settings_form(form, current_base_url, emailprefix) }} + {% if has_default_notification_urls %} + <div class="inline-warning"> + <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!"/> + There are <a href="{{ url_for('settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. + </div> + {% endif %} + <a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a> + + {{ render_common_settings_form(form, emailprefix, settings_application) }} </div> </fieldset> </div> diff --git a/changedetectionio/templates/settings.html b/changedetectionio/templates/settings.html index 0518eb9b..2db8c8b6 100644 --- a/changedetectionio/templates/settings.html +++ b/changedetectionio/templates/settings.html @@ -60,7 +60,7 @@ {{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/", class="m-d") }} <span class="pure-form-message-inline"> - Base URL used for the {base_url} token in notifications and RSS links.<br/>Default value is the ENV var 'BASE_URL' (Currently "{{current_base_url}}"), + Base URL used for the <code>{base_url}</code> token in notifications and RSS links.<br/>Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"), <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. </span> </div> @@ -87,7 +87,7 @@ <div class="tab-pane-inner" id="notifications"> <fieldset> <div class="field-group"> - {{ render_common_settings_form(form.application.form, current_base_url, emailprefix) }} + {{ render_common_settings_form(form.application.form, emailprefix, settings_application) }} </div> </fieldset> </div> diff --git a/changedetectionio/templates/watch-overview.html b/changedetectionio/templates/watch-overview.html index 373124e4..63dc44de 100644 --- a/changedetectionio/templates/watch-overview.html +++ b/changedetectionio/templates/watch-overview.html @@ -30,6 +30,9 @@ <div id="checkbox-operations"> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="pause">Pause</button> <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="unpause">UnPause</button> + <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="mute">Mute</button> + <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="unmute">UnMute</button> + <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="notification-default">Use default notification</button> <button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button> </div> <div> @@ -76,7 +79,11 @@ {% if watch.uuid in queued_uuids %}queued{% endif %}"> <td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td> <td class="inline watch-controls"> - <a class="state-{{'on' if watch.paused }}" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks"/></a> + {% if not watch.paused %} + <a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks"/></a> + {% else %} + <a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks"/></a> + {% endif %} <a class="state-{{'on' if watch.notification_muted}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications"/></a> </td> <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} diff --git a/changedetectionio/tests/test_notification.py b/changedetectionio/tests/test_notification.py index 2a620e50..6b534f62 100644 --- a/changedetectionio/tests/test_notification.py +++ b/changedetectionio/tests/test_notification.py @@ -4,7 +4,13 @@ import re from flask import url_for from . util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup import logging -from changedetectionio.notification import default_notification_body, default_notification_title + +from changedetectionio.notification import ( + default_notification_body, + default_notification_format, + default_notification_title, + valid_notification_formats, +) def test_setup(live_server): live_server_setup(live_server) @@ -20,9 +26,26 @@ def test_check_notification(client, live_server): # Re 360 - new install should have defaults set res = client.get(url_for("settings_page")) + notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') + assert default_notification_body.encode() in res.data assert default_notification_title.encode() in res.data + ##################### + # Set this up for when we remove the notification from the watch, it should fallback with these details + res = client.post( + url_for("settings_page"), + data={"application-notification_urls": notification_url, + "application-notification_title": "fallback-title "+default_notification_title, + "application-notification_body": "fallback-body "+default_notification_body, + "application-notification_format": default_notification_format, + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + # When test mode is in BASE_URL env mode, we should see this already configured env_base_url = os.getenv('BASE_URL', '').strip() if len(env_base_url): @@ -47,8 +70,6 @@ def test_check_notification(client, live_server): # Goto the edit page, add our ignore text # Add our URL to the import page - url = url_for('test_notification_endpoint', _external=True) - notification_url = url.replace('http', 'json') print (">>>> Notification URL: "+notification_url) @@ -71,7 +92,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"}) @@ -159,6 +179,30 @@ def test_check_notification(client, live_server): # be sure we see it in the output log assert b'New ChangeDetection.io Notification - ' + test_url.encode('utf-8') in res.data + set_original_response() + res = client.post( + url_for("edit_page", uuid="first"), + data={ + "url": test_url, + "tag": "my tag", + "title": "my title", + "notification_urls": '', + "notification_title": '', + "notification_body": '', + "notification_format": default_notification_format, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + time.sleep(2) + + # Verify what was sent as a notification, this file should exist + with open("test-datastore/notification.txt", "r") as f: + notification_submission = f.read() + assert "fallback-title" in notification_submission + assert "fallback-body" in notification_submission + # cleanup for the next client.get( url_for("form_delete", uuid="all"), @@ -181,20 +225,20 @@ def test_notification_validation(client, live_server): assert b"Watch added" in res.data # Re #360 some validation - res = client.post( - url_for("edit_page", uuid="first"), - data={"notification_urls": 'json://localhost/foobar', - "notification_title": "", - "notification_body": "", - "notification_format": "Text", - "url": test_url, - "tag": "my tag", - "title": "my title", - "headers": "", - "fetch_backend": "html_requests"}, - follow_redirects=True - ) - assert b"Notification Body and Title is required when a Notification URL is used" in res.data +# res = client.post( +# url_for("edit_page", uuid="first"), +# data={"notification_urls": 'json://localhost/foobar', +# "notification_title": "", +# "notification_body": "", +# "notification_format": "Text", +# "url": test_url, +# "tag": "my tag", +# "title": "my title", +# "headers": "", +# "fetch_backend": "html_requests"}, +# follow_redirects=True +# ) +# assert b"Notification Body and Title is required when a Notification URL is used" in res.data # Now adding a wrong token should give us an error res = client.post( @@ -217,81 +261,4 @@ def test_notification_validation(client, live_server): 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 - ) \ No newline at end of file diff --git a/changedetectionio/update_worker.py b/changedetectionio/update_worker.py index 49a6a9bc..17060230 100644 --- a/changedetectionio/update_worker.py +++ b/changedetectionio/update_worker.py @@ -11,11 +11,14 @@ from changedetectionio.html_tools import FilterNotFoundInResponse # Requests for checking on a single site(watch) from a queue of watches # (another process inserts watches into the queue that are time-ready for checking) +import logging +import sys class update_worker(threading.Thread): current_uuid = None def __init__(self, q, notification_q, app, datastore, *args, **kwargs): + logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) self.q = q self.app = app self.notification_q = notification_q @@ -26,6 +29,10 @@ class update_worker(threading.Thread): from changedetectionio import diff + from changedetectionio.notification import ( + default_notification_format_for_watch + ) + n_object = {} watch = self.datastore.data['watching'].get(watch_uuid, False) if not watch: @@ -40,33 +47,27 @@ class update_worker(threading.Thread): "History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?" ) - # Did it have any notification alerts to hit? - if not watch.get('notification_use_default') and 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'] - n_object['notification_body'] = watch['notification_body'] - n_object['notification_format'] = watch['notification_format'] + n_object['notification_urls'] = watch['notification_urls'] if len(watch['notification_urls']) else \ + self.datastore.data['settings']['application']['notification_urls'] + + n_object['notification_title'] = watch['notification_title'] if watch['notification_title'] else \ + self.datastore.data['settings']['application']['notification_title'] + + n_object['notification_body'] = watch['notification_body'] if watch['notification_body'] else \ + self.datastore.data['settings']['application']['notification_body'] + + n_object['notification_format'] = watch['notification_format'] if watch['notification_format'] != default_notification_format_for_watch else \ + self.datastore.data['settings']['application']['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']): - 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'] - n_object['notification_body'] = self.datastore.data['settings']['application']['notification_body'] - n_object['notification_format'] = self.datastore.data['settings']['application']['notification_format'] - else: - print(">>> NO notifications queued, watch and global notification URLs were empty.") # Only prepare to notify if the rules above matched - if 'notification_urls' in n_object: + if 'notification_urls' in n_object and n_object['notification_urls']: # HTML needs linebreak, but MarkDown and Text can use a linefeed if n_object['notification_format'] == 'HTML': line_feed_sep = "</br>" else: line_feed_sep = "\n" - snapshot_contents = '' with open(watch_history[dates[-1]], 'rb') as f: snapshot_contents = f.read() @@ -77,8 +78,10 @@ class update_worker(threading.Thread): 'diff': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], line_feed_sep=line_feed_sep), 'diff_full': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], True, line_feed_sep=line_feed_sep) }) - + logging.info (">> SENDING NOTIFICATION") self.notification_q.put(n_object) + else: + logging.info (">> NO Notification sent, notification_url was empty in both watch and system") def send_filter_failure_notification(self, watch_uuid): @@ -182,6 +185,9 @@ 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 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 #