diff --git a/.dockerignore b/.dockerignore
index 320bd34f..2f88d7d3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,18 +1,31 @@
-.git
-.github
-changedetectionio/processors/__pycache__
-changedetectionio/api/__pycache__
-changedetectionio/model/__pycache__
-changedetectionio/blueprint/price_data_follower/__pycache__
-changedetectionio/blueprint/tags/__pycache__
-changedetectionio/blueprint/__pycache__
-changedetectionio/blueprint/browser_steps/__pycache__
-changedetectionio/fetchers/__pycache__
-changedetectionio/tests/visualselector/__pycache__
-changedetectionio/tests/restock/__pycache__
-changedetectionio/tests/__pycache__
-changedetectionio/tests/fetchers/__pycache__
-changedetectionio/tests/unit/__pycache__
-changedetectionio/tests/proxy_list/__pycache__
-changedetectionio/__pycache__
+# Git
+.git/
+.gitignore
+# GitHub
+.github/
+
+# Byte-compiled / optimized / DLL files
+**/__pycache__
+**/*.py[cod]
+
+# Caches
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+
+# Distribution / packaging
+build/
+dist/
+*.egg-info*
+
+# Virtual environment
+.env
+.venv/
+venv/
+
+# IntelliJ IDEA
+.idea/
+
+# Visual Studio
+.vscode/
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index c35dbd76..0bdf52f5 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -27,6 +27,10 @@ A clear and concise description of what the bug is.
**Version**
*Exact version* in the top right area: 0....
+**How did you install?**
+
+Docker, Pip, from source directly etc
+
**To Reproduce**
Steps to reproduce the behavior:
diff --git a/.github/workflows/test-only.yml b/.github/workflows/test-only.yml
index 69e42cba..3d61ca2a 100644
--- a/.github/workflows/test-only.yml
+++ b/.github/workflows/test-only.yml
@@ -37,3 +37,10 @@ jobs:
python-version: '3.12'
skip-pypuppeteer: true
+ test-application-3-13:
+ needs: lint-code
+ uses: ./.github/workflows/test-stack-reusable-workflow.yml
+ with:
+ python-version: '3.13'
+ skip-pypuppeteer: true
+
diff --git a/.gitignore b/.gitignore
index 39fc0dd0..835597c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,14 +1,29 @@
-__pycache__
-.idea
-*.pyc
-datastore/url-watches.json
-datastore/*
-__pycache__
-.pytest_cache
-build
-dist
-venv
-test-datastore/*
-test-datastore
+# Byte-compiled / optimized / DLL files
+**/__pycache__
+**/*.py[cod]
+
+# Caches
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+
+# Distribution / packaging
+build/
+dist/
*.egg-info*
+
+# Virtual environment
+.env
+.venv/
+venv/
+
+# IDEs
+.idea
.vscode/settings.json
+
+# Datastore files
+datastore/
+test-datastore/
+
+# Memory consumption log
+test-memory.log
diff --git a/COMMERCIAL_LICENCE.md b/COMMERCIAL_LICENCE.md
index 9ac72335..fa59b2ea 100644
--- a/COMMERCIAL_LICENCE.md
+++ b/COMMERCIAL_LICENCE.md
@@ -4,7 +4,7 @@ In any commercial activity involving 'Hosting' (as defined herein), whether in p
# Commercial License Agreement
-This Commercial License Agreement ("Agreement") is entered into by and between Mr Morresi (the original creator of this software) here-in ("Licensor") and (your company or personal name) _____________ ("Licensee"). This Agreement sets forth the terms and conditions under which Licensor provides its software ("Software") and services to Licensee for the purpose of reselling the software either in part or full, as part of any commercial activity where the activity involves a third party.
+This Commercial License Agreement ("Agreement") is entered into by and between Web Technologies s.r.o. here-in ("Licensor") and (your company or personal name) _____________ ("Licensee"). This Agreement sets forth the terms and conditions under which Licensor provides its software ("Software") and services to Licensee for the purpose of reselling the software either in part or full, as part of any commercial activity where the activity involves a third party.
### Definition of Hosting
diff --git a/Dockerfile b/Dockerfile
index 3c057d67..c993ab24 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -32,7 +32,7 @@ RUN pip install --extra-index-url https://www.piwheels.org/simple --target=/dep
# Playwright is an alternative to Selenium
# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing
# https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported)
-RUN pip install --target=/dependencies playwright~=1.41.2 \
+RUN pip install --target=/dependencies playwright~=1.48.0 \
|| echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
# Final image stage
diff --git a/README.md b/README.md
index 87451d24..12bcb507 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,15 @@ We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) glob
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
+### Schedule web page watches in any timezone, limit by day of week and time.
+
+Easily set a re-check schedule, for example you could limit the web page change detection to only operate during business hours.
+Or perhaps based on a foreign timezone (for example, you want to check for the latest news-headlines in a foreign country at 0900 AM),
+
+
+
+Includes quick short-cut buttons to setup a schedule for **business hours only**, or **weekends**.
+
### We have a Chrome extension!
Easily add the current web page to your changedetection.io tool, simply install the extension and click "Sync" to connect it to your existing changedetection.io install.
diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py
index 781c848b..f8c2c161 100644
--- a/changedetectionio/__init__.py
+++ b/changedetectionio/__init__.py
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
-__version__ = '0.47.03'
+__version__ = '0.48.05'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
@@ -160,11 +160,10 @@ def main():
)
# Monitored websites will not receive a Referer header when a user clicks on an outgoing link.
- # @Note: Incompatible with password login (and maybe other features) for now, submit a PR!
@app.after_request
def hide_referrer(response):
if strtobool(os.getenv("HIDE_REFERER", 'false')):
- response.headers["Referrer-Policy"] = "no-referrer"
+ response.headers["Referrer-Policy"] = "same-origin"
return response
diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py
index ecca929f..cbee31eb 100644
--- a/changedetectionio/apprise_plugin/__init__.py
+++ b/changedetectionio/apprise_plugin/__init__.py
@@ -13,6 +13,7 @@ from loguru import logger
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
import requests
import json
+ from urllib.parse import unquote_plus
from apprise.utils import parse_url as apprise_parse_url
from apprise import URLBase
@@ -47,7 +48,7 @@ def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
if results:
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set and tidy entries by unquoting them
- headers = {URLBase.unquote(x): URLBase.unquote(y)
+ headers = {unquote_plus(x): unquote_plus(y)
for x, y in results['qsd+'].items()}
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
@@ -55,14 +56,14 @@ def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
# but here we are making straight requests, so we need todo convert this against apprise's logic
for k, v in results['qsd'].items():
if not k.strip('+-') in results['qsd+'].keys():
- params[URLBase.unquote(k)] = URLBase.unquote(v)
+ params[unquote_plus(k)] = unquote_plus(v)
# Determine Authentication
auth = ''
if results.get('user') and results.get('password'):
- auth = (URLBase.unquote(results.get('user')), URLBase.unquote(results.get('user')))
+ auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user')))
elif results.get('user'):
- auth = (URLBase.unquote(results.get('user')))
+ auth = (unquote_plus(results.get('user')))
# Try to auto-guess if it's JSON
h = 'application/json; charset=utf-8'
diff --git a/changedetectionio/blueprint/backups/__init__.py b/changedetectionio/blueprint/backups/__init__.py
new file mode 100644
index 00000000..add44308
--- /dev/null
+++ b/changedetectionio/blueprint/backups/__init__.py
@@ -0,0 +1,164 @@
+import datetime
+import glob
+import threading
+
+from flask import Blueprint, render_template, send_from_directory, flash, url_for, redirect, abort
+import os
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.flask_app import login_optionally_required
+from loguru import logger
+
+BACKUP_FILENAME_FORMAT = "changedetection-backup-{}.zip"
+
+
+def create_backup(datastore_path, watches: dict):
+ logger.debug("Creating backup...")
+ import zipfile
+ from pathlib import Path
+
+ # create a ZipFile object
+ timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+ backupname = BACKUP_FILENAME_FORMAT.format(timestamp)
+ backup_filepath = os.path.join(datastore_path, backupname)
+
+ with zipfile.ZipFile(backup_filepath.replace('.zip', '.tmp'), "w",
+ compression=zipfile.ZIP_DEFLATED,
+ compresslevel=8) as zipObj:
+
+ # Add the index
+ zipObj.write(os.path.join(datastore_path, "url-watches.json"), arcname="url-watches.json")
+
+ # Add the flask app secret
+ zipObj.write(os.path.join(datastore_path, "secret.txt"), arcname="secret.txt")
+
+ # Add any data in the watch data directory.
+ for uuid, w in watches.items():
+ for f in Path(w.watch_data_dir).glob('*'):
+ zipObj.write(f,
+ # Use the full path to access the file, but make the file 'relative' in the Zip.
+ arcname=os.path.join(f.parts[-2], f.parts[-1]),
+ compress_type=zipfile.ZIP_DEFLATED,
+ compresslevel=8)
+
+ # Create a list file with just the URLs, so it's easier to port somewhere else in the future
+ list_file = "url-list.txt"
+ with open(os.path.join(datastore_path, list_file), "w") as f:
+ for uuid in watches:
+ url = watches[uuid]["url"]
+ f.write("{}\r\n".format(url))
+ list_with_tags_file = "url-list-with-tags.txt"
+ with open(
+ os.path.join(datastore_path, list_with_tags_file), "w"
+ ) as f:
+ for uuid in watches:
+ url = watches[uuid].get('url')
+ tag = watches[uuid].get('tags', {})
+ f.write("{} {}\r\n".format(url, tag))
+
+ # Add it to the Zip
+ zipObj.write(
+ os.path.join(datastore_path, list_file),
+ arcname=list_file,
+ compress_type=zipfile.ZIP_DEFLATED,
+ compresslevel=8,
+ )
+ zipObj.write(
+ os.path.join(datastore_path, list_with_tags_file),
+ arcname=list_with_tags_file,
+ compress_type=zipfile.ZIP_DEFLATED,
+ compresslevel=8,
+ )
+
+ # Now it's done, rename it so it shows up finally and its completed being written.
+ os.rename(backup_filepath.replace('.zip', '.tmp'), backup_filepath.replace('.tmp', '.zip'))
+
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ backups_blueprint = Blueprint('backups', __name__, template_folder="templates")
+ backup_threads = []
+
+ @login_optionally_required
+ @backups_blueprint.route("/request-backup", methods=['GET'])
+ def request_backup():
+ if any(thread.is_alive() for thread in backup_threads):
+ flash("A backup is already running, check back in a few minutes", "error")
+ return redirect(url_for('backups.index'))
+
+ if len(find_backups()) > int(os.getenv("MAX_NUMBER_BACKUPS", 100)):
+ flash("Maximum number of backups reached, please remove some", "error")
+ return redirect(url_for('backups.index'))
+
+ # Be sure we're written fresh
+ datastore.sync_to_json()
+ zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching")))
+ zip_thread.start()
+ backup_threads.append(zip_thread)
+ flash("Backup building in background, check back in a few minutes.")
+
+ return redirect(url_for('backups.index'))
+
+ def find_backups():
+ backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
+ backups = glob.glob(backup_filepath)
+ backup_info = []
+
+ for backup in backups:
+ size = os.path.getsize(backup) / (1024 * 1024)
+ creation_time = os.path.getctime(backup)
+ backup_info.append({
+ 'filename': os.path.basename(backup),
+ 'filesize': f"{size:.2f}",
+ 'creation_time': creation_time
+ })
+
+ backup_info.sort(key=lambda x: x['creation_time'], reverse=True)
+
+ return backup_info
+
+ @login_optionally_required
+ @backups_blueprint.route("/download/", methods=['GET'])
+ def download_backup(filename):
+ import re
+ filename = filename.strip()
+ backup_filename_regex = BACKUP_FILENAME_FORMAT.format("\d+")
+
+ full_path = os.path.join(os.path.abspath(datastore.datastore_path), filename)
+ if not full_path.startswith(os.path.abspath(datastore.datastore_path)):
+ abort(404)
+
+ if filename == 'latest':
+ backups = find_backups()
+ filename = backups[0]['filename']
+
+ if not re.match(r"^" + backup_filename_regex + "$", filename):
+ abort(400) # Bad Request if the filename doesn't match the pattern
+
+ logger.debug(f"Backup download request for '{full_path}'")
+ return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
+
+ @login_optionally_required
+ @backups_blueprint.route("/", methods=['GET'])
+ def index():
+ backups = find_backups()
+ output = render_template("overview.html",
+ available_backups=backups,
+ backup_running=any(thread.is_alive() for thread in backup_threads)
+ )
+
+ return output
+
+ @login_optionally_required
+ @backups_blueprint.route("/remove-backups", methods=['GET'])
+ def remove_backups():
+
+ backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
+ backups = glob.glob(backup_filepath)
+ for backup in backups:
+ os.unlink(backup)
+
+ flash("Backups were deleted.")
+
+ return redirect(url_for('backups.index'))
+
+ return backups_blueprint
diff --git a/changedetectionio/blueprint/backups/templates/overview.html b/changedetectionio/blueprint/backups/templates/overview.html
new file mode 100644
index 00000000..b07be4bd
--- /dev/null
+++ b/changedetectionio/blueprint/backups/templates/overview.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+{% block content %}
+ {% from '_helpers.html' import render_simple_field, render_field %}
+
+
+
Backups
+ {% if backup_running %}
+
+ A backup is running!
+
+ {% endif %}
+
+ Here you can download and request a new backup, when a backup is completed you will see it listed below.
+
{{ render_field(form.requests.form.time_between_check, class="time-check-widget") }}
Default recheck time for all watches, current system minimum is {{min_system_recheck_seconds}} seconds (more info).
+