-[](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fdgtlmoon%2Fchangedetection.io%2Ftree%2Fmaster)
-Read the [Heroku notes and limitations wiki page first](https://github.com/dgtlmoon/changedetection.io/wiki/Heroku-notes)
+**Get your own instance now on Lemonade!**
+
+[](https://lemonade.changedetection.io/start)
+
+- Automatic Updates, Automatic Backups, No Heroku "paused application", don't miss a change!
+- Javascript browser included
+- Pay with Bitcoin
#### Example use cases
@@ -37,10 +42,6 @@ Read the [Heroku notes and limitations wiki page first](https://github.com/dgtlm
_Need an actual Chrome runner with Javascript support? We support fetching via WebDriver!_
-**Get monitoring now! super simple.**
-
-Deploy to Heroku for free, Run this python directly, or with docker and/or docker-compose
-
## Screenshots
Examining differences in content.
@@ -91,10 +92,14 @@ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/dat
```bash
docker-compose pull && docker-compose up -d
```
-### Filters
+
+See the wiki for more information https://github.com/dgtlmoon/changedetection.io/wiki
+
+
+## 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.
-### Notifications
+## Notifications
ChangeDetection.io supports a massive amount of notifications (including email, office365, custom APIs, etc) when a web-page has a change detected thanks to the apprise library.
Simply set one or more notification URL's in the _[edit]_ tab of that watch.
@@ -118,7 +123,7 @@ Just some examples
Now you can also customise your notification content!
-### JSON API Monitoring
+## JSON API Monitoring
Detect changes and monitor data in JSON API's by using the built-in JSONPath selectors as a filter / selector.
@@ -128,7 +133,7 @@ This will re-parse the JSON and apply formatting to the text, making it super ea

-#### Parse JSON embedded in HTML!
+### 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.
@@ -142,19 +147,19 @@ When you enable a `json:` filter, you can even automatically extract and parse e
`json:$.price` would give `23.50`, or you can extract the whole structure
-### Proxy configuration
+## Proxy configuration
See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration
-### Raspberry Pi support?
+## Raspberry Pi support?
-Raspberry Pi and linux/arm/v6 linux/arm/v7 arm64 devices are supported!
+Raspberry Pi and linux/arm/v6 linux/arm/v7 arm64 devices are supported! See the wiki for [details](https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver)
-### Windows native support?
+## Windows native support?
Sorry not yet :( https://github.com/dgtlmoon/changedetection.io/labels/windows
-### Support us
+## Support us
Do you use changedetection.io to make money? does it save you time or money? Does it make your life easier? less stressful? Remember, we write this software when we should be doing actual paid work, we have to buy food and pay rent just like you.
@@ -164,12 +169,12 @@ BTC `1PLFN327GyUarpJd7nVe7Reqg9qHx5frNn`
-### Commercial Support
+## Commercial Support
I offer commercial support, this software is depended on by network security, aerospace , data-science and data-journalist professionals just to name a few, please reach out at dgtlmoon@gmail.com for any enquiries, I am more than glad to work with your organisation to further the possibilities of what can be done with changedetection.io
-[release-shield]: https://img.shields.io/github/v/release/dgtlmoon/changedetection.io?style=for-the-badge
+[release-shield]: https://img.shields.io:/github/v/release/dgtlmoon/changedetection.io?style=for-the-badge
[docker-pulls]: https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io?style=for-the-badge
[test-shield]: https://github.com/dgtlmoon/changedetection.io/actions/workflows/test-only.yml/badge.svg?branch=master
diff --git a/changedetection.py b/changedetection.py
index ffb31015e..909460896 100755
--- a/changedetection.py
+++ b/changedetection.py
@@ -14,6 +14,7 @@ from changedetectionio import store
def main():
ssl_mode = False
+ host = ''
port = os.environ.get('PORT') or 5000
do_cleanup = False
@@ -21,9 +22,9 @@ def main():
datastore_path = os.path.join(os.getcwd(), "datastore")
try:
- opts, args = getopt.getopt(sys.argv[1:], "Ccsd:p:", "port")
+ opts, args = getopt.getopt(sys.argv[1:], "Ccsd:h:p:", "port")
except getopt.GetoptError:
- print('backend.py -s SSL enable -p [port] -d [datastore path]')
+ print('backend.py -s SSL enable -h [host] -p [port] -d [datastore path]')
sys.exit(2)
create_datastore_dir = False
@@ -37,6 +38,9 @@ def main():
if opt == '-s':
ssl_mode = True
+ if opt == '-h':
+ host = arg
+
if opt == '-p':
port = int(arg)
@@ -59,7 +63,7 @@ def main():
os.mkdir(app_config['datastore_path'])
else:
print ("ERROR: Directory path for the datastore '{}' does not exist, cannot start, please make sure the directory exists.\n"
- "Alternatively, use the -d parameter.".format(app_config['datastore_path']),file=sys.stderr)
+ "Alternatively, use the -C parameter.".format(app_config['datastore_path']),file=sys.stderr)
sys.exit(2)
datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=changedetectionio.__version__)
@@ -93,13 +97,13 @@ def main():
if ssl_mode:
# @todo finalise SSL config, but this should get you in the right direction if you need it.
- eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen(('', port)),
+ eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen((host, port)),
certfile='cert.pem',
keyfile='privkey.pem',
server_side=True), app)
else:
- eventlet.wsgi.server(eventlet.listen(('', int(port))), app)
+ eventlet.wsgi.server(eventlet.listen((host, int(port))), app)
if __name__ == '__main__':
diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py
index e0393686f..7366b7349 100644
--- a/changedetectionio/__init__.py
+++ b/changedetectionio/__init__.py
@@ -11,24 +11,30 @@
# proxy per check
# - flask_cors, itsdangerous,MarkupSafe
-import time
+import datetime
import os
-import timeago
-import flask_login
-from flask_login import login_required
-
+import queue
import threading
+import time
+from copy import deepcopy
from threading import Event
-import queue
-
-from flask import Flask, render_template, request, send_from_directory, abort, redirect, url_for, flash
-
-from feedgen.feed import FeedGenerator
-from flask import make_response
-import datetime
+import flask_login
import pytz
-from copy import deepcopy
+import timeago
+from feedgen.feed import FeedGenerator
+from flask import (
+ Flask,
+ abort,
+ flash,
+ make_response,
+ redirect,
+ render_template,
+ request,
+ send_from_directory,
+ url_for,
+)
+from flask_login import login_required
__version__ = '0.39.7'
@@ -64,6 +70,7 @@ app.config['LOGIN_DISABLED'] = False
# Disables caching of the templates
app.config['TEMPLATES_AUTO_RELOAD'] = True
+notification_debug_log=[]
def init_app_secret(datastore_path):
secret = ""
@@ -137,13 +144,21 @@ class User(flask_login.UserMixin):
def get_id(self):
return str(self.id)
+ # Compare given password against JSON store or Env var
def check_password(self, password):
- import hashlib
import base64
+ import hashlib
+
+ # Can be stored in env (for deployments) or in the general configs
+ raw_salt_pass = os.getenv("SALTED_PASS", False)
+
+ if not raw_salt_pass:
+ raw_salt_pass = datastore.data['settings']['application']['password']
+
+ raw_salt_pass = base64.b64decode(raw_salt_pass)
+
- # Getting the values back out
- raw_salt_pass = base64.b64decode(datastore.data['settings']['application']['password'])
salt_from_storage = raw_salt_pass[:32] # 32 is the length of the salt
# Use the exact same setup you used to generate the key, but this time put in the password to check
@@ -194,7 +209,7 @@ def changedetection_app(config=None, datastore_o=None):
@app.route('/login', methods=['GET', 'POST'])
def login():
- if not datastore.data['settings']['application']['password']:
+ if not datastore.data['settings']['application']['password'] and not os.getenv("SALTED_PASS", False):
flash("Login not required, no password enabled.", "notice")
return redirect(url_for('index'))
@@ -221,8 +236,10 @@ def changedetection_app(config=None, datastore_o=None):
@app.before_request
def do_something_whenever_a_request_comes_in():
- # Disable password loginif there is not one set
- app.config['LOGIN_DISABLED'] = datastore.data['settings']['application']['password'] == False
+
+ # Disable password login if there is not one set
+ # (No password in settings or env var)
+ app.config['LOGIN_DISABLED'] = datastore.data['settings']['application']['password'] == False and os.getenv("SALTED_PASS", False) == False
# For the RSS path, allow access via a token
if request.path == '/rss' and request.args.get('token'):
@@ -408,6 +425,7 @@ def changedetection_app(config=None, datastore_o=None):
def get_current_checksum_include_ignore_text(uuid):
import hashlib
+
from changedetectionio import fetch_site_status
# Get the most recent one
@@ -520,6 +538,7 @@ def changedetection_app(config=None, datastore_o=None):
'notification_title': form.notification_title.data,
'notification_body': form.notification_body.data,
'notification_format': form.notification_format.data,
+ 'uuid': uuid
}
notification_q.put(n_object)
flash('Test notification queued.')
@@ -556,8 +575,7 @@ def changedetection_app(config=None, datastore_o=None):
@login_required
def settings_page():
- from changedetectionio import forms
- from changedetectionio import content_fetcher
+ from changedetectionio import content_fetcher, forms
form = forms.globalSettingsForm(request.form)
@@ -573,8 +591,8 @@ def changedetection_app(config=None, datastore_o=None):
form.notification_format.data = datastore.data['settings']['application']['notification_format']
form.base_url.data = datastore.data['settings']['application']['base_url']
- # Password unset is a GET
- if request.values.get('removepassword') == 'yes':
+ # Password unset is a GET, but we can lock the session to always need the password
+ if not os.getenv("SALTED_PASS", False) and request.values.get('removepassword') == 'yes':
from pathlib import Path
datastore.data['settings']['application']['password'] = False
flash("Password protection removed.", 'notice')
@@ -608,7 +626,7 @@ def changedetection_app(config=None, datastore_o=None):
else:
flash('No notification URLs set, cannot send test.', 'error')
- if form.password.encrypted_password:
+ if not os.getenv("SALTED_PASS", False) and form.password.encrypted_password:
datastore.data['settings']['application']['password'] = form.password.encrypted_password
flash("Password protection enabled.", 'notice')
flask_login.logout_user()
@@ -620,7 +638,10 @@ def changedetection_app(config=None, datastore_o=None):
if request.method == 'POST' and not form.validate():
flash("An error occurred, please see below.", "error")
- output = render_template("settings.html", form=form, current_base_url = datastore.data['settings']['application']['base_url'])
+ output = render_template("settings.html",
+ form=form,
+ current_base_url = datastore.data['settings']['application']['base_url'],
+ hide_remove_pass=os.getenv("SALTED_PASS", False))
return output
@@ -635,10 +656,11 @@ def changedetection_app(config=None, datastore_o=None):
if request.method == 'POST':
urls = request.values.get('urls').split("\n")
for url in urls:
- url = url.strip()
+ url, *tags = url.split(" ")
+
# Flask wtform validators wont work with basic auth, use validators package
if len(url) and validators.url(url):
- new_uuid = datastore.add_watch(url=url.strip(), tag="")
+ new_uuid = datastore.add_watch(url=url.strip(), tag=" ".join(tags))
# Straight into the queue.
update_q.put(new_uuid)
good += 1
@@ -871,6 +893,15 @@ def changedetection_app(config=None, datastore_o=None):
uuid=uuid)
return output
+ @app.route("/settings/notification-logs", methods=['GET'])
+ @login_required
+ def notification_logs():
+ global notification_debug_log
+ output = render_template("notification-log.html",
+ logs=notification_debug_log if len(notification_debug_log) else ["No errors or warnings detected"])
+
+ return output
+
@app.route("/api/
- {% for row in content %}{{row}}{% endfor %}
+ {% for row in content %}{{row}}{% endfor %}
|