Compare commits

..

25 Commits

Author SHA1 Message Date
dgtlmoon
6a5e6c7c8b Refactor watch datatype 2025-06-20 15:29:02 +02:00
dgtlmoon
4f362385e1 Better path cross-platform file handling (#3265)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-06-16 17:49:50 +02:00
dgtlmoon
a01d6169d2 0.50.3
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-06-11 17:19:01 +02:00
dgtlmoon
9beda3911d Realtime UI - Prefer websocket then fallback to 'polling' mode, increase reconnecting retries. 2025-06-11 13:30:51 +02:00
dgtlmoon
5ed596bfa9 UI - Fixing Watch 'set viewed' by tag #3253 (#3258) 2025-06-11 13:06:45 +02:00
dgtlmoon
99ca8787ab UI - Always unset 'unviewed' state when '[History]' button is pressed from watch overview list #3243 2025-06-11 12:12:48 +02:00
dgtlmoon
8f1a6feb90 UI - Tweak UI option text description for 'Open history page in new tab' setting 2025-06-11 12:09:19 +02:00
dgtlmoon
c0e229201b UI/Application listening on IPv6 - Please use -h :: to listen on all IPv6 interfaces, -p is removed (#3257) 2025-06-11 12:03:12 +02:00
dgtlmoon
66bc7fbc04 Realtime UI - Delete watch should update in realtime ( #3255 )
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-06-11 11:23:05 +02:00
dgtlmoon
530bd40ca5 UI - Quick watch add form color fix 2025-06-11 10:34:29 +02:00
dgtlmoon
36004cf74b Application via HTTPS support - Adding SSL setup and automated test (#3247) (#3252) 2025-06-11 09:44:58 +02:00
dgtlmoon
c7374245e1 Browser Steps - Fix for source: URLs fix (#3254) 2025-06-11 09:43:48 +02:00
dgtlmoon
59df59e9cd UI - Restyle of "quick watch add form" above watchlist 2025-06-11 09:43:03 +02:00
Denilson Sá Maia
c0c2898b91 UI - Don't restrict page content box to 80% width (#3251) 2025-06-11 08:51:54 +02:00
dgtlmoon
abac660bac UI - #3236 fix duplicate icon in watchlist
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-06-06 18:46:13 +02:00
dgtlmoon
26de64d873 Data store - use original formatted data write
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-06-06 17:24:27 +02:00
dgtlmoon
79d9a8ca28 Realtime UI - Ability to notify browser/client if there was a notification event (#3235)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-06-04 12:03:11 +02:00
dgtlmoon
5c391fbcad UI - Realtime - Add realtime warning to page if server goes offline 2025-06-04 10:46:32 +02:00
dgtlmoon
d7e24f64a5 Browser Steps - Better support for sites that redirect on click/login etc 2025-06-04 09:43:27 +02:00
dgtlmoon
d6427d823f Restock detector - Update texts (#3234) 2025-06-04 09:42:41 +02:00
dgtlmoon
47eb874f47 Puppeteer fetcher - Issue a Page.sendStop on frame load incase the browser is waiting for other data (#3232) 2025-06-04 08:53:48 +02:00
dgtlmoon
37019355fd Build test - Build test for platforms in parallel (#3229)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
2025-06-04 07:46:07 +02:00
dgtlmoon
a8e7f8236e BrowserSteps - remove unsupported exception class 2025-06-04 07:33:09 +02:00
dgtlmoon
2414b61fcb 0.50.2
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / test-container-build (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-06-03 16:28:22 +02:00
dgtlmoon
a63ffa89b1 0.50.1 2025-06-03 16:20:51 +02:00
31 changed files with 879 additions and 179 deletions

View File

@@ -103,6 +103,13 @@ jobs:
# provenance: false
# A new tagged release is required, which builds :tag and :latest
- name: Debug release info
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
run: |
echo "Release tag: ${{ github.event.release.tag_name }}"
echo "Github ref: ${{ github.ref }}"
echo "Github ref name: ${{ github.ref_name }}"
- name: Docker meta :tag
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
uses: docker/metadata-action@v5
@@ -112,9 +119,10 @@ jobs:
${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io
ghcr.io/dgtlmoon/changedetection.io
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{version}},value=${{ github.event.release.tag_name }}
type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }}
type=semver,pattern={{major}},value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push :tag
id: docker_build_tag_release

View File

@@ -23,8 +23,28 @@ on:
# Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing
# @todo: some kind of path filter for requirements.txt and Dockerfile
jobs:
test-container-build:
builder:
name: Build ${{ matrix.platform }} (${{ matrix.dockerfile == './Dockerfile' && 'main' || 'alpine' }})
runs-on: ubuntu-latest
strategy:
matrix:
include:
# Main Dockerfile platforms
- platform: linux/amd64
dockerfile: ./Dockerfile
- platform: linux/arm64
dockerfile: ./Dockerfile
- platform: linux/arm/v7
dockerfile: ./Dockerfile
- platform: linux/arm/v8
dockerfile: ./Dockerfile
- platform: linux/arm64/v8
dockerfile: ./Dockerfile
# Alpine Dockerfile platforms (musl via alpine check)
- platform: linux/amd64
dockerfile: ./.github/test/Dockerfile-alpine
- platform: linux/arm64
dockerfile: ./.github/test/Dockerfile-alpine
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
@@ -47,26 +67,14 @@ jobs:
version: latest
driver-opts: image=moby/buildkit:master
# https://github.com/dgtlmoon/changedetection.io/pull/1067
# Check we can still build under alpine/musl
- name: Test that the docker containers can build (musl via alpine check)
id: docker_build_musl
uses: docker/build-push-action@v6
with:
context: ./
file: ./.github/test/Dockerfile-alpine
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test that the docker containers can build
- name: Test that the docker containers can build (${{ matrix.platform }} - ${{ matrix.dockerfile }})
id: docker_build
uses: docker/build-push-action@v6
# https://github.com/docker/build-push-action#customizing
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -71,6 +71,7 @@ jobs:
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_update_watch_deep_merge'
- name: Test built container with Pytest (generally as requests/plaintext fetching)
run: |
@@ -179,6 +180,26 @@ jobs:
docker kill test-changedetectionio
- name: Test HTTPS SSL mode
run: |
openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
docker run --name test-changedetectionio-ssl --rm -e SSL_CERT_FILE=cert.pem -e SSL_PRIVKEY_FILE=privkey.pem -p 5000:5000 -v ./cert.pem:/app/cert.pem -v ./privkey.pem:/app/privkey.pem -d test-changedetectionio
sleep 3
# Should return 0 (no error) when grep finds it
# -k because its self-signed
curl --retry-connrefused --retry 6 -k https://localhost:5000 -v|grep -q checkbox-uuid
docker kill test-changedetectionio-ssl
- name: Test IPv6 Mode
run: |
# IPv6 - :: bind to all interfaces inside container (like 0.0.0.0), ::1 would be localhost only
docker run --name test-changedetectionio-ipv6 --rm -p 5000:5000 -e LISTEN_HOST=:: -d test-changedetectionio
sleep 3
# Should return 0 (no error) when grep finds it on localhost
curl --retry-connrefused --retry 6 http://[::1]:5000 -v|grep -q checkbox-uuid
docker kill test-changedetectionio-ipv6
- name: Test changedetection.io SIGTERM and SIGINT signal shutdown
run: |

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.50.01'
__version__ = '0.50.3'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
@@ -65,8 +65,7 @@ def main():
datastore_path = None
do_cleanup = False
host = "0.0.0.0"
ipv6_enabled = False
host = os.environ.get("LISTEN_HOST", "0.0.0.0").strip()
port = int(os.environ.get('PORT', 5000))
ssl_mode = False
@@ -108,10 +107,6 @@ def main():
if opt == '-d':
datastore_path = arg
if opt == '-6':
logger.success("Enabling IPv6 listen support")
ipv6_enabled = True
# Cleanup (remove text files that arent in the index)
if opt == '-c':
do_cleanup = True
@@ -123,6 +118,20 @@ def main():
if opt == '-l':
logger_level = int(arg) if arg.isdigit() else arg.upper()
logger.success(f"changedetection.io version {get_version()} starting.")
# Launch using SocketIO run method for proper integration (if enabled)
ssl_cert_file = os.getenv("SSL_CERT_FILE", 'cert.pem')
ssl_privkey_file = os.getenv("SSL_PRIVKEY_FILE", 'privkey.pem')
if os.getenv("SSL_CERT_FILE") and os.getenv("SSL_PRIVKEY_FILE"):
ssl_mode = True
# SSL mode could have been set by -s too, therefor fallback to default values
if ssl_mode:
if not os.path.isfile(ssl_cert_file) or not os.path.isfile(ssl_privkey_file):
logger.critical(f"Cannot start SSL/HTTPS mode, Please be sure that {ssl_cert_file}' and '{ssl_privkey_file}' exist in in {os.getcwd()}")
os._exit(2)
# Without this, a logger will be duplicated
logger.remove()
try:
@@ -222,19 +231,19 @@ def main():
# SocketIO instance is already initialized in flask_app.py
# Launch using SocketIO run method for proper integration (if enabled)
if socketio_server:
if ssl_mode:
socketio.run(app, host=host, port=int(port), debug=False,
certfile='cert.pem', keyfile='privkey.pem', allow_unsafe_werkzeug=True)
logger.success(f"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}")
socketio.run(app, host=host, port=int(port), debug=False,
ssl_context=(ssl_cert_file, ssl_privkey_file), allow_unsafe_werkzeug=True)
else:
socketio.run(app, host=host, port=int(port), debug=False, allow_unsafe_werkzeug=True)
else:
# Run Flask app without Socket.IO if disabled
logger.info("Starting Flask app without Socket.IO server")
if ssl_mode:
app.run(host=host, port=int(port), debug=False,
ssl_context=('cert.pem', 'privkey.pem'))
logger.success(f"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}")
app.run(host=host, port=int(port), debug=False,
ssl_context=(ssl_cert_file, ssl_privkey_file))
else:
app.run(host=host, port=int(port), debug=False)

View File

@@ -228,10 +228,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
watch.save_screenshot(screenshot=screenshot)
watch.save_xpath_data(data=xpath_data)
except playwright._impl._api_types.Error as e:
return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401)
except Exception as e:
return make_response("Error fetching screenshot and element data - " + str(e), 401)
return make_response(f"Error fetching screenshot and element data - {str(e)}", 401)
# SEND THIS BACK TO THE BROWSER
output = {

View File

@@ -1,8 +1,6 @@
import os
import time
import re
import sys
import traceback
from random import randint
from loguru import logger
@@ -92,8 +90,32 @@ class steppable_browser_interface():
if optional_value and ('{%' in optional_value or '{{' in optional_value):
optional_value = jinja_render(template_str=optional_value)
# Trigger click and cautiously handle potential navigation
# This means the page redirects/reloads/changes JS etc etc
if call_action_name.startswith('click_'):
try:
# Set up navigation expectation before the click (like sync version)
async with self.page.expect_event("framenavigated", timeout=3000) as navigation_info:
await action_handler(selector, optional_value)
# Check if navigation actually occurred
try:
await navigation_info.value # This waits for the navigation promise
logger.debug(f"Navigation occurred on {call_action_name}.")
except Exception:
logger.debug(f"No navigation occurred within timeout when calling {call_action_name}, that's OK, continuing.")
except Exception as e:
# If expect_event itself times out, that means no navigation occurred - that's OK
if "framenavigated" in str(e) and "exceeded" in str(e):
logger.debug(f"No navigation occurred within timeout when calling {call_action_name}, that's OK, continuing.")
else:
raise e
else:
# Some other action that probably a navigation is not expected
await action_handler(selector, optional_value)
await action_handler(selector, optional_value)
# Safely wait for timeout
await self.page.wait_for_timeout(1.5 * 1000)
logger.debug(f"Call action done in {time.time()-now:.2f}s")
@@ -110,7 +132,7 @@ class steppable_browser_interface():
# Incase they request to go back to the start
async def action_goto_site(self, selector=None, value=None):
return await self.action_goto_url(value=self.start_url)
return await self.action_goto_url(value=re.sub(r'^source:', '', self.start_url, flags=re.IGNORECASE))
async def action_click_element_containing_text(self, selector=None, value=''):
logger.debug("Clicking element containing text")
@@ -428,6 +450,9 @@ class browsersteps_live_ui(steppable_browser_interface):
try:
# Get screenshot first
screenshot = await capture_full_page_async(page=self.page)
if not screenshot:
logger.error("No screenshot was retrieved :((")
logger.debug(f"Time to get screenshot from browser {time.time() - now:.2f}s")
# Then get interactive elements
@@ -450,6 +475,12 @@ class browsersteps_live_ui(steppable_browser_interface):
except Exception as e:
logger.error(f"Error getting current state: {str(e)}")
# If the page has navigated (common with logins) then the context is destroyed on navigation, continue
# I'm not sure that this is required anymore because we have the "expect navigation wrapper" at the top
if "Execution context was destroyed" in str(e):
logger.debug("Execution context was destroyed, most likely because of navigation, continuing...")
pass
# Attempt recovery - force garbage collection
try:
await self.page.request_gc()

View File

@@ -10,7 +10,7 @@
<legend>Add a new organisational tag</legend>
<div id="watch-add-wrapper-zone">
<div>
{{ render_simple_field(form.name, placeholder="watch label / tag") }}
{{ render_simple_field(form.name, placeholder="Watch group / tag") }}
</div>
<div>
{{ render_simple_field(form.save_button, title="Save" ) }}

View File

@@ -159,12 +159,20 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
def mark_all_viewed():
# Save the current newest history as the most recently viewed
with_errors = request.args.get('with_errors') == "1"
tag_limit = request.args.get('tag')
logger.debug(f"Limiting to tag {tag_limit}")
now = int(time.time())
for watch_uuid, watch in datastore.data['watching'].items():
if with_errors and not watch.get('last_error'):
continue
datastore.set_last_viewed(watch_uuid, int(time.time()))
return redirect(url_for('watchlist.index'))
if tag_limit and ( not watch.get('tags') or tag_limit not in watch['tags'] ):
logger.debug(f"Skipping watch {watch_uuid}")
continue
datastore.set_last_viewed(watch_uuid, now)
return redirect(url_for('watchlist.index', tag=tag_limit))
@ui_blueprint.route("/delete", methods=['GET'])
@login_optionally_required
@@ -295,7 +303,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
watch['ignore_text'] += datastore.data['settings']['application']['global_ignore_text']
watch['subtractive_selectors'] += datastore.data['settings']['application']['global_subtractive_selectors']
watch_json = json.dumps(watch)
watch_json = json.dumps(dict(watch))
try:
r = requests.request(method="POST",

View File

@@ -18,19 +18,20 @@ document.addEventListener('DOMContentLoaded', function() {
transition: background-size 0.9s ease
}
</style>
<div class="box">
<div class="box" id="form-quick-watch-add">
<form class="pure-form" action="{{ url_for('ui.ui_views.form_quick_watch_add', tag=active_tag_uuid) }}" method="POST" id="new-watch-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
<fieldset>
<legend>Add a new change detection watch</legend>
<legend>Add a new web page change detection watch</legend>
<div id="watch-add-wrapper-zone">
{{ render_nolabel_field(form.url, placeholder="https://...", required=true) }}
{{ render_nolabel_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="watch label / tag") }}
{{ render_nolabel_field(form.watch_submit_button, title="Watch this URL!" ) }}
{{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
</div>
<div id="watch-group-tag">
{{ render_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="Watch group / tag", class="transparent-field") }}
</div>
<div id="quick-watch-processor-type">
{{ render_simple_field(form.processor) }}
</div>
@@ -38,7 +39,8 @@ document.addEventListener('DOMContentLoaded', function() {
</fieldset>
<span style="color:#eee; font-size: 80%;"><img alt="Create a shareable link" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></span>
</form>
</div>
<div class="box">
<form class="pure-form" action="{{ url_for('ui.form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
<input type="hidden" id="op_extradata" name="op_extradata" value="" >
@@ -212,9 +214,14 @@ document.addEventListener('DOMContentLoaded', function() {
<li id="post-list-mark-views" class="{%- if has_unviewed -%}has-unviewed{%- endif -%}" style="display: none;" >
<a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed</a>
</li>
{%- if active_tag_uuid -%}
<li id="post-list-mark-views-tag">
<a href="{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed in '{{active_tag.title}}'</a>
</li>
{%- endif -%}
<li>
<a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag" id="recheck-all">Recheck
all {%- if active_tag_uuid-%} in "{{active_tag.title}}"{%endif%}</a>
all {% if active_tag_uuid %} in '{{active_tag.title}}'{%endif%}</a>
</li>
<li>
<a href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>

View File

@@ -51,7 +51,15 @@ async def capture_full_page(page):
await page.setViewport({'width': page.viewport['width'], 'height': step_size})
while y < min(page_height, SCREENSHOT_MAX_TOTAL_HEIGHT):
await page.evaluate(f"window.scrollTo(0, {y})")
# better than scrollTo incase they override it in the page
await page.evaluate(
"""(y) => {
document.documentElement.scrollTop = y;
document.body.scrollTop = y;
}""",
y
)
screenshot_chunks.append(await page.screenshot(type_='jpeg',
fullPage=False,
quality=int(os.getenv("SCREENSHOT_QUALITY", 72))))
@@ -149,7 +157,11 @@ class fetcher(Fetcher):
):
import re
self.delete_browser_steps_screenshots()
extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
n = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
extra_wait = min(n, 15)
logger.debug(f"Extra wait set to {extra_wait}s, requested was {n}s.")
from pyppeteer import Pyppeteer
pyppeteer_instance = Pyppeteer()
@@ -165,7 +177,7 @@ class fetcher(Fetcher):
except websockets.exceptions.InvalidURI:
raise BrowserConnectError(msg=f"Error connecting to the browser, check your browser connection address (should be ws:// or wss://")
except Exception as e:
raise BrowserConnectError(msg=f"Error connecting to the browser {str(e)}")
raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'")
# Better is to launch chrome with the URL as arg
# non-headless - newPage() will launch an extra tab/window, .browser should already contain 1 page/tab
@@ -227,13 +239,35 @@ class fetcher(Fetcher):
# browsersteps_interface = steppable_browser_interface()
# browsersteps_interface.page = self.page
response = await self.page.goto(url, waitUntil="load")
async def handle_frame_navigation(event):
logger.debug(f"Frame navigated: {event}")
w = extra_wait - 2 if extra_wait > 4 else 2
logger.debug(f"Waiting {w} seconds before calling Page.stopLoading...")
await asyncio.sleep(w)
logger.debug("Issuing stopLoading command...")
await self.page._client.send('Page.stopLoading')
logger.debug("stopLoading command sent!")
if response is None:
await self.page.close()
await browser.close()
logger.warning("Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content)")
raise EmptyReply(url=url, status_code=None)
self.page._client.on('Page.frameStartedNavigating', lambda event: asyncio.create_task(handle_frame_navigation(event)))
self.page._client.on('Page.frameStartedLoading', lambda event: asyncio.create_task(handle_frame_navigation(event)))
self.page._client.on('Page.frameStoppedLoading', lambda event: logger.debug(f"Frame stopped loading: {event}"))
response = None
attempt=0
while not response:
logger.debug(f"Attempting page fetch {url} attempt {attempt}")
response = await self.page.goto(url)
await asyncio.sleep(1 + extra_wait)
if response:
break
if not response:
logger.warning("Page did not fetch! trying again!")
if response is None and attempt>=2:
await self.page.close()
await browser.close()
logger.warning(f"Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content) exiting attmpt {attempt}")
raise EmptyReply(url=url, status_code=None)
attempt+=1
self.headers = response.headers
@@ -276,7 +310,6 @@ class fetcher(Fetcher):
# if self.browser_steps_get_valid_steps():
# self.iterate_browser_steps()
await asyncio.sleep(1 + extra_wait)
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
# Setup the xPath/VisualSelector scraper

View File

@@ -18,6 +18,7 @@ async () => {
'back-order or out of stock',
'backordered',
'benachrichtigt mich', // notify me
'binnenkort leverbaar', // coming soon
'brak na stanie',
'brak w magazynie',
'coming soon',
@@ -85,6 +86,7 @@ async () => {
'tidak tersedia',
'tijdelijk uitverkocht',
'tiket tidak tersedia',
'to subscribe to back in stock',
'tükendi',
'unavailable nearby',
'unavailable tickets',
@@ -119,8 +121,7 @@ async () => {
return text.toLowerCase().trim();
}
const negateOutOfStockRegex = new RegExp('^([0-9] in stock|add to cart|in stock)', 'ig');
const negateOutOfStockRegex = new RegExp('^([0-9] in stock|add to cart|in stock|arrives approximately)', 'ig');
// The out-of-stock or in-stock-text is generally always above-the-fold
// and often below-the-fold is a list of related products that may or may not contain trigger text
// so it's good to filter to just the 'above the fold' elements

View File

@@ -3,6 +3,41 @@ import asyncio
from blinker import signal
from loguru import logger
class NotificationQueue(queue.Queue):
"""
Extended Queue that sends a 'notification_event' signal when notifications are added.
This class extends the standard Queue and adds a signal emission after a notification
is put into the queue. The signal includes the watch UUID if available.
"""
def __init__(self, maxsize=0):
super().__init__(maxsize)
try:
self.notification_event_signal = signal('notification_event')
except Exception as e:
logger.critical(f"Exception creating notification_event signal: {e}")
def put(self, item, block=True, timeout=None):
# Call the parent's put method first
super().put(item, block, timeout)
# After putting the notification in the queue, emit signal with watch UUID
try:
if self.notification_event_signal and isinstance(item, dict):
watch_uuid = item.get('uuid')
if watch_uuid:
# Send the notification_event signal with the watch UUID
self.notification_event_signal.send(watch_uuid=watch_uuid)
logger.trace(f"NotificationQueue: Emitted notification_event signal for watch UUID {watch_uuid}")
else:
# Send signal without UUID for system notifications
self.notification_event_signal.send()
logger.trace("NotificationQueue: Emitted notification_event signal for system notification")
except Exception as e:
logger.error(f"Exception emitting notification_event signal: {e}")
class SignalPriorityQueue(queue.PriorityQueue):
"""
Extended PriorityQueue that sends a signal when items with a UUID are added.

View File

@@ -12,7 +12,7 @@ from blinker import signal
from changedetectionio.strtobool import strtobool
from threading import Event
from changedetectionio.custom_queue import SignalPriorityQueue, AsyncSignalPriorityQueue
from changedetectionio.custom_queue import SignalPriorityQueue, AsyncSignalPriorityQueue, NotificationQueue
from changedetectionio import worker_handler
from flask import (
@@ -52,7 +52,7 @@ extra_stylesheets = []
# Use async queue by default, keep sync for backward compatibility
update_q = AsyncSignalPriorityQueue() if worker_handler.USE_ASYNC_WORKERS else SignalPriorityQueue()
notification_q = queue.Queue()
notification_q = NotificationQueue()
MAX_QUEUE_SIZE = 2000
app = Flask(__name__,
@@ -100,7 +100,7 @@ watch_api = Api(app, decorators=[csrf.exempt])
def init_app_secret(datastore_path):
secret = ""
path = "{}/secret.txt".format(datastore_path)
path = os.path.join(datastore_path, "secret.txt")
try:
with open(path, "r") as f:

View File

@@ -738,7 +738,7 @@ class globalSettingsRequestForm(Form):
return False
class globalSettingsApplicationUIForm(Form):
open_diff_in_new_tab = BooleanField('Open diff page in a new tab', default=True, validators=[validators.Optional()])
open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()])
socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()])
# datastore.data['settings']['application']..

View File

@@ -43,14 +43,14 @@ class model(watch_base):
self.__datastore_path = kw.get('datastore_path')
if kw.get('datastore_path'):
del kw['datastore_path']
# Save default before passing to parent, since parent will delete it
default_values = kw.get('default')
super(model, self).__init__(*arg, **kw)
if kw.get('default'):
self.update(kw['default'])
del kw['default']
if self.get('default'):
del self['default']
if default_values:
self.update(default_values)
# Be sure the cached timestamp is ready
bump = self.history
@@ -227,8 +227,8 @@ class model(watch_base):
@property
def has_history(self):
fname = os.path.join(self.watch_data_dir, "history.txt")
return os.path.isfile(fname)
fname = self._get_data_file_path("history.txt")
return fname and os.path.isfile(fname)
@property
def has_browser_steps(self):
@@ -405,16 +405,16 @@ class model(watch_base):
return not local_lines.issubset(existing_history)
def get_screenshot(self):
fname = os.path.join(self.watch_data_dir, "last-screenshot.png")
if os.path.isfile(fname):
fname = self._get_data_file_path("last-screenshot.png")
if fname and os.path.isfile(fname):
return fname
# False is not an option for AppRise, must be type None
return None
def __get_file_ctime(self, filename):
fname = os.path.join(self.watch_data_dir, filename)
if os.path.isfile(fname):
fname = self._get_data_file_path(filename)
if fname and os.path.isfile(fname):
return int(os.path.getmtime(fname))
return False
@@ -441,20 +441,28 @@ class model(watch_base):
@property
def watch_data_dir(self):
# The base dir of the watch data
return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None
if self.__datastore_path and self.get('uuid'):
return os.path.join(self.__datastore_path, self['uuid'])
return None
def _get_data_file_path(self, filename):
"""Safely get the full path to a data file, returns None if watch_data_dir is None"""
if self.watch_data_dir:
return os.path.join(self.watch_data_dir, filename)
return None
def get_error_text(self):
"""Return the text saved from a previous request that resulted in a non-200 error"""
fname = os.path.join(self.watch_data_dir, "last-error.txt")
if os.path.isfile(fname):
fname = self._get_data_file_path("last-error.txt")
if fname and os.path.isfile(fname):
with open(fname, 'r') as f:
return f.read()
return False
def get_error_snapshot(self):
"""Return path to the screenshot that resulted in a non-200 error"""
fname = os.path.join(self.watch_data_dir, "last-error-screenshot.png")
if os.path.isfile(fname):
fname = self._get_data_file_path("last-error-screenshot.png")
if fname and os.path.isfile(fname):
return fname
return False

View File

@@ -1,13 +1,14 @@
import os
import uuid
import json
from changedetectionio import strtobool
default_notification_format_for_watch = 'System default'
class watch_base(dict):
class watch_base:
def __init__(self, *arg, **kw):
self.update({
self.__data = {
# Custom notification content
# Re #110, so then if this is set to None, we know to use the default value instead
# Requires setting to None on submit if it's the same as the default
@@ -128,9 +129,78 @@ class watch_base(dict):
'uuid': str(uuid.uuid4()),
'webdriver_delay': None,
'webdriver_js_execute_code': None, # Run before change-detection
})
}
if len(arg) == 1 and (isinstance(arg[0], dict) or hasattr(arg[0], 'keys')):
self.__data.update(arg[0])
if kw:
self.__data.update(kw)
super(watch_base, self).__init__(*arg, **kw)
if self.__data.get('default'):
del self.__data['default']
if self.get('default'):
del self['default']
def __getitem__(self, key):
return self.__data[key]
def __setitem__(self, key, value):
self.__data[key] = value
def __delitem__(self, key):
del self.__data[key]
def __iter__(self):
return iter(self.__data)
def __len__(self):
return len(self.__data)
def __contains__(self, key):
return key in self.__data
def __repr__(self):
return repr(self.__data)
def __str__(self):
return str(self.__data)
def keys(self):
return self.__data.keys()
def values(self):
return self.__data.values()
def items(self):
return self.__data.items()
def get(self, key, default=None):
return self.__data.get(key, default)
def pop(self, key, *args):
return self.__data.pop(key, *args)
def popitem(self):
return self.__data.popitem()
def clear(self):
self.__data.clear()
def update(self, *args, **kwargs):
self.__data.update(*args, **kwargs)
def setdefault(self, key, default=None):
return self.__data.setdefault(key, default)
def copy(self):
return self.__data.copy()
def __deepcopy__(self, memo):
from copy import deepcopy
new_instance = self.__class__()
new_instance.__data = deepcopy(self.__data, memo)
return new_instance
def __reduce__(self):
return (self.__class__, (self.__data,))
def to_dict(self):
return dict(self.__data)

View File

@@ -42,10 +42,10 @@ class Restock(dict):
# Update with any provided positional arguments (dictionaries)
if args:
if len(args) == 1 and isinstance(args[0], dict):
if len(args) == 1 and (isinstance(args[0], dict) or hasattr(args[0], 'keys')):
self.update(args[0])
else:
raise ValueError("Only one positional argument of type 'dict' is allowed")
raise ValueError("Only one positional argument of type 'dict' or dict-like is allowed")
def __setitem__(self, key, value):
# Custom logic to handle setting price and original_price

View File

@@ -29,6 +29,11 @@ class SignalHandler:
watch_delete_signal = signal('watch_deleted')
watch_delete_signal.connect(self.handle_deleted_signal, weak=False)
# Connect to the notification_event signal
notification_event_signal = signal('notification_event')
notification_event_signal.connect(self.handle_notification_event, weak=False)
logger.info("SignalHandler: Connected to notification_event signal")
# Create and start the queue update thread using standard threading
import threading
self.polling_emitter_thread = threading.Thread(
@@ -89,6 +94,23 @@ class SignalHandler:
except Exception as e:
logger.error(f"Socket.IO error in handle_queue_length: {str(e)}")
def handle_notification_event(self, *args, **kwargs):
"""Handle notification_event signal and emit to all clients"""
try:
watch_uuid = kwargs.get('watch_uuid')
logger.debug(f"SignalHandler: Notification event received for watch UUID: {watch_uuid}")
# Emit the notification event to all connected clients
self.socketio_instance.emit("notification_event", {
"watch_uuid": watch_uuid,
"event_timestamp": time.time()
})
logger.trace(f"Socket.IO: Emitted notification_event for watch UUID {watch_uuid}")
except Exception as e:
logger.error(f"Socket.IO error in handle_notification_event: {str(e)}")
def polling_emit_running_or_queued_watches_threaded(self):
"""Threading version of polling for Windows compatibility"""

View File

@@ -48,13 +48,14 @@ $(document).ready(function () {
// Connect to Socket.IO on the same host/port, with path from template
const socket = io({
path: socketio_url, // This will be the path prefix like "/app/socket.io" from the template
transports: ['polling', 'websocket'], // Try WebSocket but fall back to polling
reconnectionDelay: 1000,
reconnectionAttempts: 15
transports: ['websocket', 'polling'],
reconnectionDelay: 3000,
reconnectionAttempts: 25
});
// Connection status logging
socket.on('connect', function () {
$('#realtime-conn-error').hide();
console.log('Socket.IO connected with path:', socketio_url);
console.log('Socket transport:', socket.io.engine.transport.name);
bindSocketHandlerButtonsEvents(socket);
@@ -74,7 +75,8 @@ $(document).ready(function () {
socket.on('disconnect', function (reason) {
console.log('Socket.IO disconnected, reason:', reason);
$('.ajax-op').off('.socketHandlerNamespace')
$('.ajax-op').off('.socketHandlerNamespace');
$('#realtime-conn-error').show();
});
socket.on('queue_size', function (data) {
@@ -92,6 +94,16 @@ $(document).ready(function () {
}
});
socket.on('notification_event', function (data) {
console.log(`Stub handler for notification_event ${data.watch_uuid}`)
});
socket.on('watch_deleted', function (data) {
$('tr[data-watch-uuid="' + data.uuid + '"] td').fadeOut(500, function () {
$(this).closest('tr').remove();
});
});
// Listen for periodically emitted watch data
console.log('Adding watch_update event listener');

View File

@@ -16,6 +16,12 @@ $(function () {
$('#op_extradata').val(prompt("Enter a tag name"));
});
$('.history-link').click(function (e) {
// Incase they click 'back' in the browser, it should be removed.
$(this).closest('tr').removeClass('unviewed');
});
$('.with-share-link > *').click(function () {
$("#copied-clipboard").remove();

View File

@@ -71,6 +71,7 @@
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
--color-background-new-watch-input: var(--color-white);
--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);
--color-text-new-watch-input: var(--color-text);
--color-border-input: var(--color-grey-500);
--color-shadow-input: var(--color-grey-400);
@@ -97,6 +98,7 @@ html[data-darkmode="true"] {
--color-background-gradient-second: #1e316c;
--color-background-gradient-third: #4d2c64;
--color-background-new-watch-input: var(--color-grey-100);
--color-background-new-watch-input-transparent: var(--color-grey-100);
--color-text-new-watch-input: var(--color-text);
--color-background-table-thead: var(--color-grey-200);
--color-table-background: var(--color-grey-300);

View File

@@ -78,6 +78,7 @@
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
--color-background-new-watch-input: var(--color-white);
--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);
--color-text-new-watch-input: var(--color-text);
--color-border-input: var(--color-grey-500);
@@ -112,6 +113,7 @@ html[data-darkmode="true"] {
--color-background-gradient-third: #4d2c64;
--color-background-new-watch-input: var(--color-grey-100);
--color-background-new-watch-input-transparent: var(--color-grey-100);
--color-text-new-watch-input: var(--color-text);
--color-background-table-thead: var(--color-grey-200);
--color-table-background: var(--color-grey-300);

View File

@@ -17,11 +17,13 @@
&.title-col {
word-break: break-all;
white-space: normal;
a::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px;
}
}
a.external::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px;
}
}

View File

@@ -185,7 +185,8 @@ code {
}
.box {
max-width: 80%;
max-width: 100%;
margin: 0 1em;
flex-direction: column;
display: flex;
justify-content: center;
@@ -279,7 +280,7 @@ a.pure-button-selected {
font-size: 65%;
border-bottom-left-radius: initial;
border-bottom-right-radius: initial;
margin-right: 4px;
&.active {
background: var(--color-background-button-tag-active);
font-weight: bold;
@@ -372,11 +373,32 @@ label {
}
}
// Some field colouring for transperant field
.pure-form input[type=text].transparent-field {
background-color: var(--color-background-new-watch-input-transparent) !important;
color: var(--color-white) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: none !important;
-webkit-box-shadow: none !important;
&::placeholder {
opacity: 0.5;
color: rgba(255, 255, 255, 0.7);
font-weight: lighter;
}
}
#new-watch-form {
background: var(--color-background-new-watch-form);
padding: 1em;
border-radius: 10px;
margin-bottom: 1em;
max-width: 100%;
#url {
&::placeholder {
font-weight: bold;
}
}
input {
display: inline-block;
@@ -397,12 +419,13 @@ label {
font-weight: bold;
}
#watch-add-wrapper-zone {
#watch-add-wrapper-zone {
@media only screen and (min-width: 760px) {
display: flex;
gap: 0.3rem;
flex-direction: row;
min-width: 70vw;
}
/* URL field grows always, other stay static in width */
> span {
@@ -424,6 +447,22 @@ label {
}
}
}
#watch-group-tag {
font-size: 0.9rem;
padding: 0.3rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-white);
label, input {
margin: 0;
}
input {
flex: 1;
}
}
}
@@ -620,10 +659,6 @@ footer {
@media only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.box {
max-width: 95%
}
.edit-form {
padding: 0.5em;
margin: 0;
@@ -1143,16 +1178,14 @@ ul {
color: #fff;
ul {
padding: 0.3rem;
li {
list-style: none;
font-size: 0.8rem;
font-size: 0.9rem;
> * {
display: inline-block;
}
}
}
}
.restock-label {
@@ -1190,3 +1223,12 @@ ul {
vertical-align: middle;
}
#realtime-conn-error {
position: absolute;
bottom: 0;
left: 30px;
background: var(--color-warning);
padding: 10px;
font-size: 0.8rem;
color: #fff;
}

View File

@@ -322,6 +322,7 @@ ul#requests-extra_browsers {
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
--color-background-new-watch-input: var(--color-white);
--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);
--color-text-new-watch-input: var(--color-text);
--color-border-input: var(--color-grey-500);
--color-shadow-input: var(--color-grey-400);
@@ -348,6 +349,7 @@ html[data-darkmode="true"] {
--color-background-gradient-second: #1e316c;
--color-background-gradient-third: #4d2c64;
--color-background-new-watch-input: var(--color-grey-100);
--color-background-new-watch-input-transparent: var(--color-grey-100);
--color-text-new-watch-input: var(--color-text);
--color-background-table-thead: var(--color-grey-200);
--color-table-background: var(--color-grey-300);
@@ -537,9 +539,9 @@ body.preview-text-enabled {
.watch-table td.title-col {
word-break: break-all;
white-space: normal; }
.watch-table td.title-col a::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px; }
.watch-table td a.external::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px; }
.watch-table th {
white-space: nowrap; }
.watch-table th a {
@@ -826,7 +828,8 @@ code {
background: var(--color-text-watch-tag-list); }
.box {
max-width: 80%;
max-width: 100%;
margin: 0 1em;
flex-direction: column;
display: flex;
justify-content: center; }
@@ -899,7 +902,8 @@ a.pure-button-selected {
color: var(--color-text-button);
font-size: 65%;
border-bottom-left-radius: initial;
border-bottom-right-radius: initial; }
border-bottom-right-radius: initial;
margin-right: 4px; }
.button-tag.active {
background: var(--color-background-button-tag-active);
font-weight: bold; }
@@ -962,11 +966,25 @@ label:hover {
#token-table.pure-table th {
font-size: 80%; }
.pure-form input[type=text].transparent-field {
background-color: var(--color-background-new-watch-input-transparent) !important;
color: var(--color-white) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: none !important;
-webkit-box-shadow: none !important; }
.pure-form input[type=text].transparent-field::placeholder {
opacity: 0.5;
color: rgba(255, 255, 255, 0.7);
font-weight: lighter; }
#new-watch-form {
background: var(--color-background-new-watch-form);
padding: 1em;
border-radius: 10px;
margin-bottom: 1em; }
margin-bottom: 1em;
max-width: 100%; }
#new-watch-form #url::placeholder {
font-weight: bold; }
#new-watch-form input {
display: inline-block;
margin-bottom: 5px; }
@@ -984,7 +1002,8 @@ label:hover {
#new-watch-form #watch-add-wrapper-zone {
display: flex;
gap: 0.3rem;
flex-direction: row; } }
flex-direction: row;
min-width: 70vw; } }
#new-watch-form #watch-add-wrapper-zone > span {
flex-grow: 0; }
#new-watch-form #watch-add-wrapper-zone > span input {
@@ -995,6 +1014,17 @@ label:hover {
@media only screen and (max-width: 760px) {
#new-watch-form #watch-add-wrapper-zone #url {
width: 100%; } }
#new-watch-form #watch-group-tag {
font-size: 0.9rem;
padding: 0.3rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-white); }
#new-watch-form #watch-group-tag label, #new-watch-form #watch-group-tag input {
margin: 0; }
#new-watch-form #watch-group-tag input {
flex: 1; }
#diff-col {
padding-left: 40px; }
@@ -1129,8 +1159,6 @@ footer {
gap: 1em; }
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
.box {
max-width: 95%; }
.edit-form {
padding: 0.5em;
margin: 0; }
@@ -1506,7 +1534,7 @@ ul {
padding: 0.3rem; }
#quick-watch-processor-type ul li {
list-style: none;
font-size: 0.8rem; }
font-size: 0.9rem; }
#quick-watch-processor-type ul li > * {
display: inline-block; }
@@ -1535,3 +1563,12 @@ ul {
height: 21px;
padding: 2px;
vertical-align: middle; }
#realtime-conn-error {
position: absolute;
bottom: 0;
left: 30px;
background: var(--color-warning);
padding: 10px;
font-size: 0.8rem;
color: #fff; }

View File

@@ -13,6 +13,7 @@ import json
import os
import re
import secrets
import sys
import threading
import time
import uuid as uuid_builder
@@ -22,6 +23,13 @@ from blinker import signal
from .processors import get_custom_watch_obj_for_processor
from .processors.restock_diff import Restock
class WatchEncoder(json.JSONEncoder):
def default(self, obj):
from .model import watch_base
if isinstance(obj, watch_base):
return dict(obj)
return super().default(obj)
# Because the server will run as a daemon and wont know the URL for notification links when firing off a notification
BASE_URL_NOT_SET_TEXT = '("Base URL" not set - see settings - notifications)'
@@ -45,14 +53,11 @@ class ChangeDetectionStore:
# logging.basicConfig(filename='/dev/stdout', level=logging.INFO)
self.__data = App.model()
self.datastore_path = datastore_path
self.json_store_path = "{}/url-watches.json".format(self.datastore_path)
self.json_store_path = os.path.join(self.datastore_path, "url-watches.json")
logger.info(f"Datastore path is '{self.json_store_path}'")
self.needs_write = False
self.start_time = time.time()
self.stop_thread = False
# Base definition for all watchers
# deepcopy part of #569 - not sure why its needed exactly
self.generic_definition = deepcopy(Watch.model(datastore_path = datastore_path, default={}))
if path.isfile('changedetectionio/source.txt'):
with open('changedetectionio/source.txt') as f:
@@ -118,14 +123,12 @@ class ChangeDetectionStore:
test_list = self.proxy_list
# Helper to remove password protection
password_reset_lockfile = "{}/removepassword.lock".format(self.datastore_path)
password_reset_lockfile = os.path.join(self.datastore_path, "removepassword.lock")
if path.isfile(password_reset_lockfile):
self.__data['settings']['application']['password'] = False
unlink(password_reset_lockfile)
if not 'app_guid' in self.__data:
import os
import sys
if "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ:
self.__data['app_guid'] = "test-" + str(uuid_builder.uuid4())
else:
@@ -175,6 +178,14 @@ class ChangeDetectionStore:
self.__data['settings']['application']['password'] = False
self.needs_write = True
def _deep_merge(self, target, source):
"""Recursively merge source dict into target dict"""
for key, value in source.items():
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
self._deep_merge(target[key], value)
else:
target[key] = value
def update_watch(self, uuid, update_obj):
# It's possible that the watch could be deleted before update
@@ -182,15 +193,8 @@ class ChangeDetectionStore:
return
with self.lock:
# In python 3.9 we have the |= dict operator, but that still will lose data on nested structures...
for dict_key, d in self.generic_definition.items():
if isinstance(d, dict):
if update_obj is not None and dict_key in update_obj:
self.__data['watching'][uuid][dict_key].update(update_obj[dict_key])
del (update_obj[dict_key])
self.__data['watching'][uuid].update(update_obj)
# Use recursive merge to handle nested dictionaries properly
self._deep_merge(self.__data['watching'][uuid], update_obj)
self.needs_write = True
@property
@@ -386,14 +390,59 @@ class ChangeDetectionStore:
return new_uuid
def visualselector_data_is_ready(self, watch_uuid):
output_path = "{}/{}".format(self.datastore_path, watch_uuid)
screenshot_filename = "{}/last-screenshot.png".format(output_path)
elements_index_filename = "{}/elements.deflate".format(output_path)
output_path = os.path.join(self.datastore_path, watch_uuid)
screenshot_filename = os.path.join(output_path, "last-screenshot.png")
elements_index_filename = os.path.join(output_path, "elements.deflate")
if path.isfile(screenshot_filename) and path.isfile(elements_index_filename) :
return True
return False
import json
import os
import tempfile
from pathlib import Path # just for nicer paths
JSON_INDENT = 2 # or None in production
ENCODER = WatchEncoder # your custom encoder
def save_json_atomic(self, save_path: str | os.PathLike, data) -> None:
"""
Atomically (re)write *path* with *data* encoded as JSON.
The original file is left untouched if anything fails.
"""
import tempfile
from pathlib import Path # just for nicer paths
JSON_INDENT = 2 # or None in production
ENCODER = WatchEncoder # your custom encoder
datapath = Path(save_path)
directory = datapath.parent
# 1. create a unique temp file in the same directory
fd, tmp_name = tempfile.mkstemp(
dir=directory,
prefix=f"{datapath.name}.",
suffix=".tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
json.dump(data, tmp, indent=JSON_INDENT, cls=ENCODER)
if os.getenv('JSON_SAVE_FORCE_FLUSH'):
tmp.flush() # push Python buffers
os.fsync(tmp.fileno()) # force kernel to write to disk
os.replace(tmp_name, datapath)
except Exception as e:
logger.critical(f"Failed to write JSON to {datapath} - {str(e)}")
# if anything above blew up, ensure we don't leave junk lying around
try:
os.unlink(tmp_name)
finally:
raise
def sync_to_json(self):
logger.info("Saving JSON..")
try:
@@ -405,22 +454,7 @@ class ChangeDetectionStore:
self.sync_to_json()
return
else:
try:
# Re #286 - First write to a temp file, then confirm it looks OK and rename it
# This is a fairly basic strategy to deal with the case that the file is corrupted,
# system was out of memory, out of RAM etc
with open(self.json_store_path+".tmp", 'w') as json_file:
# Use compact JSON in production for better performance
debug_mode = os.environ.get('CHANGEDETECTION_DEBUG', 'false').lower() == 'true'
if debug_mode:
json.dump(data, json_file, indent=4)
else:
json.dump(data, json_file, separators=(',', ':'))
os.replace(self.json_store_path+".tmp", self.json_store_path)
except Exception as e:
logger.error(f"Error writing JSON!! (Main JSON file save was skipped) : {str(e)}")
self.save_json_atomic(save_path = self.json_store_path, data =data)
self.needs_write = False
self.needs_write_urgent = False
@@ -478,7 +512,7 @@ class ChangeDetectionStore:
# Load from external config file
if path.isfile(proxy_list_file):
with open("{}/proxies.json".format(self.datastore_path)) as f:
with open(os.path.join(self.datastore_path, "proxies.json")) as f:
proxy_list = json.load(f)
# Mapping from UI config if available
@@ -736,10 +770,10 @@ class ChangeDetectionStore:
logger.critical(f"Applying update_{update_n}")
# Wont exist on fresh installs
if os.path.exists(self.json_store_path):
shutil.copyfile(self.json_store_path, self.datastore_path+"/url-watches-before-{}.json".format(update_n))
shutil.copyfile(self.json_store_path, os.path.join(self.datastore_path, f"url-watches-before-{update_n}.json"))
try:
update_method = getattr(self, "update_{}".format(update_n))()
update_method = getattr(self, f"update_{update_n}")()
except Exception as e:
logger.error(f"Error while trying update_{update_n}")
logger.error(e)

View File

@@ -74,7 +74,7 @@
</tr>
<tr>
<td><code>{{ '{{watch_tag}}' }}</code></td>
<td>The watch label / tag</td>
<td>The watch group / tag</td>
</tr>
<tr>
<td><code>{{ '{{preview_url}}' }}</code></td>

View File

@@ -236,6 +236,7 @@
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span>&nbsp;Checking now</span></div>
<div id="realtime-conn-error" style="display:none">Offline</div>
</body>
</html>

View File

@@ -236,39 +236,41 @@ def test_group_tag_notification(client, live_server, measure_memory_usage):
assert b'Deleted' in res.data
def test_limit_tag_ui(client, live_server, measure_memory_usage):
test_url = url_for('test_endpoint', _external=True)
urls=[]
test_url = url_for('test_random_content_endpoint', _external=True)
for i in range(20):
urls.append(test_url+"?x="+str(i)+" test-tag")
for i in range(20):
urls.append(test_url+"?non-grouped="+str(i))
res = client.post(
# A space can label the tag, only the first one will have a tag
client.post(
url_for("imports.import_page"),
data={"urls": "\r\n".join(urls)},
data={"urls": f"{test_url} test-tag\r\n{test_url}"},
follow_redirects=True
)
assert b"40 Imported" in res.data
tag_uuid = get_UUID_for_tag_name(client, name="test-tag")
assert tag_uuid
res = client.get(url_for("watchlist.index"))
assert b'test-tag' in res.data
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# All should be here
assert res.data.count(b'processor-text_json_diff') == 40
# Should be both unviewed
res = client.get(url_for("watchlist.index"))
assert res.data.count(b' unviewed ') == 2
tag_uuid = get_UUID_for_tag_name(client, name="test-tag")
res = client.get(url_for("watchlist.index", tag=tag_uuid))
# Now we recheck only the tag
client.get(url_for('ui.mark_all_viewed', tag=tag_uuid), follow_redirects=True)
wait_for_all_checks(client)
with open('/tmp/fuck.html', 'wb') as f:
f.write(res.data)
# Should be only 1 unviewed
res = client.get(url_for("watchlist.index"))
assert res.data.count(b' unviewed ') == 1
# Just a subset should be here
assert b'test-tag' in res.data
assert res.data.count(b'processor-text_json_diff') == 20
assert b"object at" not in res.data
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data
res = client.get(url_for("tags.delete_all"), follow_redirects=True)

View File

@@ -0,0 +1,291 @@
#!/usr/bin/env python3
# run from dir above changedetectionio/ dir
# python3 -m unittest changedetectionio.tests.unit.test_update_watch_deep_merge
import unittest
import os
import tempfile
import shutil
from unittest.mock import patch
from changedetectionio import store
class TestUpdateWatchDeepMerge(unittest.TestCase):
def setUp(self):
# Create a temporary directory for test data
self.test_datastore_path = tempfile.mkdtemp()
self.datastore = store.ChangeDetectionStore(datastore_path=self.test_datastore_path, include_default_watches=False)
# Create a test watch with known nested structure
self.test_uuid = self.datastore.add_watch(url='http://example.com')
# Set up known initial nested structure
initial_data = {
'time_between_check': {'weeks': None, 'days': 1, 'hours': 6, 'minutes': 30, 'seconds': None},
'headers': {'user-agent': 'test-browser', 'accept': 'text/html'},
'time_schedule_limit': {
'enabled': True,
'monday': {
'enabled': True,
'start_time': '09:00',
'duration': {'hours': '8', 'minutes': '00'}
},
'tuesday': {
'enabled': False,
'start_time': '10:00',
'duration': {'hours': '6', 'minutes': '30'}
}
}
}
self.datastore.update_watch(self.test_uuid, initial_data)
def tearDown(self):
self.datastore.stop_thread = True
# Clean up the temporary directory
shutil.rmtree(self.test_datastore_path, ignore_errors=True)
def test_simple_flat_update(self):
"""Test that simple flat updates work as before"""
update_obj = {'url': 'http://newexample.com', 'paused': True}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
self.assertEqual(watch['url'], 'http://newexample.com')
self.assertEqual(watch['paused'], True)
def test_time_between_check_partial_update(self):
"""Test partial update of time_between_check preserves existing keys"""
# Update only hours, should preserve other existing values
update_obj = {'time_between_check': {'hours': 2}}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
time_check = watch['time_between_check']
# Updated value
self.assertEqual(time_check['hours'], 2)
# Preserved existing values
self.assertEqual(time_check['days'], 1)
self.assertEqual(time_check['minutes'], 30)
self.assertEqual(time_check['weeks'], None)
self.assertEqual(time_check['seconds'], None)
def test_time_between_check_multiple_partial_updates(self):
"""Test multiple partial updates to time_between_check"""
# First update
update_obj1 = {'time_between_check': {'minutes': 45}}
self.datastore.update_watch(self.test_uuid, update_obj1)
# Second update
update_obj2 = {'time_between_check': {'seconds': 15}}
self.datastore.update_watch(self.test_uuid, update_obj2)
watch = self.datastore.data['watching'][self.test_uuid]
time_check = watch['time_between_check']
# Both updates should be preserved
self.assertEqual(time_check['minutes'], 45)
self.assertEqual(time_check['seconds'], 15)
# Original values should be preserved
self.assertEqual(time_check['days'], 1)
self.assertEqual(time_check['hours'], 6)
def test_headers_partial_update(self):
"""Test partial update of headers preserves existing headers"""
update_obj = {'headers': {'authorization': 'Bearer token123'}}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
headers = watch['headers']
# New header added
self.assertEqual(headers['authorization'], 'Bearer token123')
# Existing headers preserved
self.assertEqual(headers['user-agent'], 'test-browser')
self.assertEqual(headers['accept'], 'text/html')
def test_headers_update_existing_key(self):
"""Test updating an existing header key"""
update_obj = {'headers': {'user-agent': 'new-browser'}}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
headers = watch['headers']
# Updated existing header
self.assertEqual(headers['user-agent'], 'new-browser')
# Other headers preserved
self.assertEqual(headers['accept'], 'text/html')
def test_time_schedule_limit_deep_nested_update(self):
"""Test deep nested update of time_schedule_limit structure"""
update_obj = {
'time_schedule_limit': {
'monday': {
'duration': {'hours': '10'} # Only update hours, preserve minutes
}
}
}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
schedule = watch['time_schedule_limit']
# Deep nested update applied
self.assertEqual(schedule['monday']['duration']['hours'], '10')
# Existing nested values preserved
self.assertEqual(schedule['monday']['duration']['minutes'], '00')
self.assertEqual(schedule['monday']['start_time'], '09:00')
self.assertEqual(schedule['monday']['enabled'], True)
# Other days preserved
self.assertEqual(schedule['tuesday']['enabled'], False)
self.assertEqual(schedule['enabled'], True)
def test_mixed_flat_and_nested_update(self):
"""Test update with both flat and nested properties"""
update_obj = {
'url': 'http://mixed-update.com',
'paused': False,
'time_between_check': {'days': 2, 'minutes': 15},
'headers': {'cookie': 'session=abc123'}
}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
# Flat updates
self.assertEqual(watch['url'], 'http://mixed-update.com')
self.assertEqual(watch['paused'], False)
# Nested updates
time_check = watch['time_between_check']
self.assertEqual(time_check['days'], 2)
self.assertEqual(time_check['minutes'], 15)
self.assertEqual(time_check['hours'], 6) # preserved
headers = watch['headers']
self.assertEqual(headers['cookie'], 'session=abc123')
self.assertEqual(headers['user-agent'], 'test-browser') # preserved
def test_overwrite_nested_with_flat(self):
"""Test that providing a non-dict value overwrites the entire nested structure"""
update_obj = {'time_between_check': 'invalid_value'}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
# Should completely replace the nested dict with the string
self.assertEqual(watch['time_between_check'], 'invalid_value')
def test_add_new_nested_structure(self):
"""Test adding a completely new nested dictionary"""
update_obj = {
'custom_config': {
'option1': 'value1',
'nested': {
'suboption': 'subvalue'
}
}
}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
self.assertEqual(watch['custom_config']['option1'], 'value1')
self.assertEqual(watch['custom_config']['nested']['suboption'], 'subvalue')
def test_empty_dict_update(self):
"""Test updating with empty dictionaries"""
update_obj = {'headers': {}}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
# Empty dict should preserve existing headers (no keys to merge)
self.assertEqual(watch['headers']['user-agent'], 'test-browser')
self.assertEqual(watch['headers']['accept'], 'text/html')
def test_none_values_in_nested_update(self):
"""Test handling None values in nested updates"""
update_obj = {
'time_between_check': {
'hours': None,
'days': 3
}
}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
time_check = watch['time_between_check']
self.assertEqual(time_check['hours'], None)
self.assertEqual(time_check['days'], 3)
self.assertEqual(time_check['minutes'], 30) # preserved
def test_real_world_api_update_scenario(self):
"""Test a real-world API update scenario from the codebase analysis"""
# Based on actual API call patterns found in the codebase
update_obj = {
"title": "Updated API Watch",
'time_between_check': {'minutes': 60},
'headers': {'authorization': 'Bearer api-token', 'user-agent': 'api-client'},
'notification_urls': ['https://webhook.example.com']
}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
# Verify all updates
self.assertEqual(watch['title'], 'Updated API Watch')
self.assertEqual(watch['time_between_check']['minutes'], 60)
self.assertEqual(watch['time_between_check']['days'], 1) # preserved
self.assertEqual(watch['headers']['authorization'], 'Bearer api-token')
self.assertEqual(watch['headers']['user-agent'], 'api-client') # overwrote existing
self.assertEqual(watch['headers']['accept'], 'text/html') # preserved
self.assertEqual(watch['notification_urls'], ['https://webhook.example.com'])
def test_watch_not_found(self):
"""Test update_watch with non-existent UUID"""
# Should not raise an error, just return silently
fake_uuid = 'non-existent-uuid'
update_obj = {'url': 'http://should-not-update.com'}
# Should not raise an exception
self.datastore.update_watch(fake_uuid, update_obj)
# Verify no changes were made to existing watch
watch = self.datastore.data['watching'][self.test_uuid]
self.assertNotEqual(watch['url'], 'http://should-not-update.com')
def test_processor_style_update(self):
"""Test the type of updates made by processors during check operations"""
# Based on async_update_worker.py patterns
update_obj = {
'last_notification_error': False,
'last_error': False,
'previous_md5': 'abc123def456',
'content-type': 'application/json',
'consecutive_filter_failures': 0,
'fetch_time': 1.234,
'check_count': 42
}
self.datastore.update_watch(self.test_uuid, update_obj)
watch = self.datastore.data['watching'][self.test_uuid]
# Verify processor updates
self.assertEqual(watch['last_notification_error'], False)
self.assertEqual(watch['last_error'], False)
self.assertEqual(watch['previous_md5'], 'abc123def456')
self.assertEqual(watch['content-type'], 'application/json')
self.assertEqual(watch['consecutive_filter_failures'], 0)
self.assertEqual(watch['fetch_time'], 1.234)
self.assertEqual(watch['check_count'], 42)
# Verify nested structures weren't affected
self.assertEqual(watch['time_between_check']['days'], 1)
self.assertEqual(watch['headers']['user-agent'], 'test-browser')
if __name__ == '__main__':
unittest.main()

View File

@@ -69,6 +69,16 @@ services:
# Maximum height of screenshots, default is 16000 px, screenshots will be clipped to this if exceeded.
# RAM usage will be higher if you increase this.
# - SCREENSHOT_MAX_HEIGHT=16000
#
# HTTPS SSL Mode for webserver, unset both of these, you may need to volume mount these files also.
# ./cert.pem:/app/cert.pem and ./privkey.pem:/app/privkey.pem
# - SSL_CERT_FILE=cert.pem
# - SSL_PRIVKEY_FILE=privkey.pem
#
# LISTEN_HOST / "host", Same as -h
# - LISTEN_HOST=::
# - LISTEN_HOST=0.0.0.0
# Comment out ports: when using behind a reverse proxy , enable networks: etc.
ports: