Compare commits

..

49 Commits

Author SHA1 Message Date
dgtlmoon 4fae34cd28 Fixes for blocking 2026-01-19 18:02:58 +01:00
dgtlmoon 280f423cb3 API validation 2026-01-19 17:56:43 +01:00
dgtlmoon a9c19d062b More API error handling 2026-01-19 17:01:41 +01:00
dgtlmoon bac4022047 API - Improving URL validation 2026-01-19 17:00:53 +01:00
dgtlmoon 9e2acadb7e 0.52.7
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
2026-01-19 09:37:01 +01:00
吾爱分享 48da93b4ec Fix zh PO duplicates and complete new translations. (#3773) 2026-01-19 09:35:52 +01:00
dgtlmoon 0c1adc8906 Lots of translation updates (#3772)
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
2026-01-17 22:24:23 +01:00
dgtlmoon 9e5a0a0209 UI - Global "mute" and "pause" buttons on main menu, move "Backups" to "Settings" (#3769)
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
2026-01-17 18:20:29 +01:00
dgtlmoon 9b96689072 API & UI - Recheck all - Dont requeue existing queued or processing watches. (#3770) 2026-01-17 18:20:22 +01:00
dgtlmoon 5e5674f48d Non blocking improvements (#3767)
* Non blocking improvements

* Test fix

* Background thread re-queue

* Nonblockimg improvements, run tasks in background, add warning about CPU cores

* Misc fixes
2026-01-17 17:25:18 +01:00
dgtlmoon 272e68ad2e Improvements to deterministic fix (false triggers) (#3766) 2026-01-17 16:12:32 +01:00
dgtlmoon 01e06979d8 Run "clear all history" in background thread to prevent blocking (#3765) 2026-01-17 15:34:21 +01:00
dgtlmoon e45c77d51d Test - Adding missing test 2026-01-17 15:33:34 +01:00
dgtlmoon bee1130c6e Important fix for possible wrong detection of changes under high-concurrency setups (many many fetch workers) 2026-01-17 14:45:23 +01:00
dgtlmoon 5f8448d0e2 Language updates (#3764) 2026-01-17 14:11:57 +01:00
dgtlmoon 9438d38dc6 Queues and Scheduler - No need to add imported items to the check queue, the scheduler will do this #3762 (#3763), CPU usage improvements.
* No need to add imported items to the check queue, the scheduler will do this #3762

* Tests - Faster recheck/reschedule loop under pytest environment

* More wait time under test

* Bunch up some tests a little

* fix typo

* woops

* If they want to queue one thats already running, thats up to them.

* WIP

* Fixing queue limit size

* Increase max queue size and many CPU performance fixes
2026-01-17 13:43:24 +01:00
dgtlmoon d0c66758c2 UI - Fixing link to scheduler help/tutorial page.
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
2026-01-16 19:14:29 +01:00
dgtlmoon 9e8a9d5907 Manual update of DE language (and recompile all languages) 2026-01-16 18:47:01 +01:00
dgtlmoon 7449be39fb Recompile CSS 2026-01-16 18:40:16 +01:00
dgtlmoon e9f3d0bce4 UI - Mobile - Empty page watches message and layout improvements (#3760) 2026-01-16 17:59:52 +01:00
dgtlmoon 2abc8aa9b4 UI - CSS - Give dark-mode switching a soft transition 2026-01-16 17:45:33 +01:00
dgtlmoon 69b70a2a07 Edit - More reliable fetch of watch on test (usually affects tests) 2026-01-16 16:52:35 +01:00
吾爱分享 0c42bcb8d6 Manual polish for several translations in the zh locale. (#3757)
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
2026-01-16 10:50:31 +01:00
dgtlmoon 091c708a28 Fix for old selenium 3 (#3748 #3756), however be sure to use selenium 4. 2026-01-16 10:30:26 +01:00
dgtlmoon 084be9c990 Languages - Recompile languages, small fix for 'de'. 2026-01-16 09:50:15 +01:00
dependabot[bot] 6db1085337 Bump elementpath from 5.0.4 to 5.1.0 (#3754) 2026-01-16 09:22:10 +01:00
吾爱分享 66553e106d Update zh translations with improved, consistent Simplified Chinese UI copy. (#3752) 2026-01-16 09:21:29 +01:00
dependabot[bot] 5b01dbd9f8 Bump apprise from 1.9.5 to 1.9.6 (#3753) 2026-01-16 09:09:02 +01:00
dgtlmoon c86f214fc3 0.52.6
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
2026-01-15 22:28:58 +01:00
dgtlmoon 32149640d9 Selenium fetcher - Small fix for #3748 RGB error on transparent screenshots or similar (#3749) 2026-01-15 20:56:53 +01:00
dgtlmoon 15f16455fc UI - Show queue size above watch table in realtime
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
2026-01-15 17:28:09 +01:00
dgtlmoon 15cdfac9d9 0.52.5 2026-01-15 14:07:09 +01:00
dgtlmoon 04de397916 Revert sub-process brotli saving because it could fork-bomb/use up too many system resources (#3747) 2026-01-15 13:56:08 +01:00
dgtlmoon 4643082c5b i18n: Recompile zh_Hant_TW/LC_MESSAGES/messages.mo 2026-01-15 13:21:49 +01:00
滅ü 3b2b74e62d i18n: Update zh_Hant_TW translations (#3745) 2026-01-15 13:12:25 +01:00
dependabot[bot] 68354cf53d Update jsonschema requirement from ~=4.25 to ~=4.26 (#3743) 2026-01-15 13:03:16 +01:00
dgtlmoon 3e364e0eba Translations - ZH_Hant_TW - Fixing timeago string handling #3737
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 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
2026-01-15 12:24:53 +01:00
dgtlmoon 06ea29bfc7 Translations - Fixing zh_TW to zh_Hant_TW , adding tests #3737 (#3744) 2026-01-15 12:01:12 +01:00
dependabot[bot] f4e178955c Bump pyppeteer-ng from 2.0.0rc10 to 2.0.0rc11 (#3742) 2026-01-15 10:31:42 +01:00
dgtlmoon 51d531d732 0.52.4
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
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-14 13:26:23 +01:00
dgtlmoon e40c4ca97d Fixing Traditional Chinese locale mapping #3737 (#3738) 2026-01-14 13:26:07 +01:00
dgtlmoon b8ede70f3a Languages - Pypi/pip package was missing translations 2026-01-14 13:09:23 +01:00
dgtlmoon 50b349b464 0.52.3
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
2026-01-14 12:00:54 +01:00
dgtlmoon 67d097cca7 UI - Groups - Adding 'Recheck' button from groups overview page 2026-01-14 11:59:42 +01:00
dgtlmoon 494385a379 Minor playwright memory cleanup improvements (#3736) 2026-01-14 11:54:53 +01:00
dgtlmoon c2ee84b753 Browser Steps UI async_loop bug, refactored startup of BrowserSteps, increased test coverage. Re #3734 (#3735) 2026-01-14 11:27:01 +01:00
dgtlmoon c1e0296cda 0.52.2
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
2026-01-13 16:36:16 +01:00
dgtlmoon f041223c38 Page fetchers - Were not truely running independently and could have been blocking eachother, this commit speeds up page fetches where there is more than 1 worker. 2026-01-13 16:32:50 +01:00
dgtlmoon d36738d7ef RSS - Bugfix - possible edge case of wrong feed info could be rendered (#3733) 2026-01-13 16:31:58 +01:00
92 changed files with 14224 additions and 7636 deletions
@@ -84,6 +84,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_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_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_semver'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
# Basic pytest tests with ancillary services # Basic pytest tests with ancillary services
basic-tests: basic-tests:
+1
View File
@@ -11,6 +11,7 @@ recursive-include changedetectionio/realtime *
recursive-include changedetectionio/static * recursive-include changedetectionio/static *
recursive-include changedetectionio/templates * recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests * recursive-include changedetectionio/tests *
recursive-include changedetectionio/translations *
recursive-include changedetectionio/widgets * recursive-include changedetectionio/widgets *
prune changedetectionio/static/package-lock.json prune changedetectionio/static/package-lock.json
prune changedetectionio/static/styles/node_modules prune changedetectionio/static/styles/node_modules
+5
View File
@@ -0,0 +1,5 @@
[python: **.py]
keywords = _:1,_l:1,gettext:1
[jinja2: **/templates/**.html]
encoding = utf-8
+6 -3
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki # Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1. # Semver means never use .01, or 00. Should be .1.
__version__ = '0.52.1' __version__ = '0.52.7'
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
@@ -41,9 +41,10 @@ from loguru import logger
# #
# IMPLEMENTATION: # IMPLEMENTATION:
# 1. Explicit contexts everywhere (primary protection): # 1. Explicit contexts everywhere (primary protection):
# - Watch.py: ctx = multiprocessing.get_context('spawn')
# - playwright.py: ctx = multiprocessing.get_context('spawn') # - playwright.py: ctx = multiprocessing.get_context('spawn')
# - puppeteer.py: ctx = multiprocessing.get_context('spawn') # - puppeteer.py: ctx = multiprocessing.get_context('spawn')
# - isolated_opencv.py: ctx = multiprocessing.get_context('spawn')
# - isolated_libvips.py: ctx = multiprocessing.get_context('spawn')
# #
# 2. Global default (defense-in-depth, below): # 2. Global default (defense-in-depth, below):
# - Safety net if future code forgets explicit context # - Safety net if future code forgets explicit context
@@ -286,7 +287,9 @@ def main():
return dict(right_sticky="v{}".format(datastore.data['version_tag']), return dict(right_sticky="v{}".format(datastore.data['version_tag']),
new_version_available=app.config['NEW_VERSION_AVAILABLE'], new_version_available=app.config['NEW_VERSION_AVAILABLE'],
has_password=datastore.data['settings']['application']['password'] != False, has_password=datastore.data['settings']['application']['password'] != False,
socket_io_enabled=datastore.data['settings']['application']['ui'].get('socket_io_enabled', True) socket_io_enabled=datastore.data['settings']['application']['ui'].get('socket_io_enabled', True),
all_paused=datastore.data['settings']['application'].get('all_paused', False),
all_muted=datastore.data['settings']['application'].get('all_muted', False)
) )
# Monitored websites will not receive a Referer header when a user clicks on an outgoing link. # Monitored websites will not receive a Referer header when a user clicks on an outgoing link.
+28 -8
View File
@@ -2,7 +2,9 @@ from changedetectionio import queuedWatchMetaData
from changedetectionio import worker_handler from changedetectionio import worker_handler
from flask_expects_json import expects_json from flask_expects_json import expects_json
from flask_restful import abort, Resource from flask_restful import abort, Resource
from loguru import logger
import threading
from flask import request from flask import request
from . import auth from . import auth
@@ -28,18 +30,36 @@ class Tag(Resource):
abort(404, message=f'No tag exists with the UUID of {uuid}') abort(404, message=f'No tag exists with the UUID of {uuid}')
if request.args.get('recheck'): if request.args.get('recheck'):
# Recheck all, including muted # Recheck all watches with this tag, including muted
# Get most overdue first # First collect watches to queue
i=0 watches_to_queue = []
for k in sorted(self.datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)): for k in sorted(self.datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):
watch_uuid = k[0] watch_uuid = k[0]
watch = k[1] watch = k[1]
if not watch['paused'] and tag['uuid'] not in watch['tags']: if not watch['paused'] and tag['uuid'] in watch['tags']:
continue watches_to_queue.append(watch_uuid)
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
i+=1
return f"OK, {i} watches queued", 200 # If less than 20 watches, queue synchronously for immediate feedback
if len(watches_to_queue) < 20:
for watch_uuid in watches_to_queue:
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
return {'status': f'OK, queued {len(watches_to_queue)} watches for rechecking'}, 200
else:
# 20+ watches - queue in background thread to avoid blocking API response
def queue_watches_background():
"""Background thread to queue watches - discarded after completion."""
try:
for watch_uuid in watches_to_queue:
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
logger.info(f"Background queueing complete for tag {tag['uuid']}: {len(watches_to_queue)} watches queued")
except Exception as e:
logger.error(f"Error in background queueing for tag {tag['uuid']}: {e}")
# Start background thread and return immediately
thread = threading.Thread(target=queue_watches_background, daemon=True, name=f"QueueTag-{tag['uuid'][:8]}")
thread.start()
return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202
if request.args.get('muted', '') == 'muted': if request.args.get('muted', '') == 'muted':
self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True
+89 -14
View File
@@ -1,4 +1,5 @@
import os import os
import threading
from changedetectionio.validate_url import is_safe_valid_url from changedetectionio.validate_url import is_safe_valid_url
@@ -67,13 +68,17 @@ class Watch(Resource):
import time import time
from copy import deepcopy from copy import deepcopy
watch = None watch = None
for _ in range(20): # Retry up to 20 times if dict is being modified
# With sleep(0), this is fast: ~200µs best case, ~20ms worst case under heavy load
for attempt in range(20):
try: try:
watch = deepcopy(self.datastore.data['watching'].get(uuid)) watch = deepcopy(self.datastore.data['watching'].get(uuid))
break break
except RuntimeError: except RuntimeError:
# Incase dict changed, try again # Dict changed during deepcopy, retry after yielding to scheduler
time.sleep(0.01) # sleep(0) releases GIL and yields - no fixed delay, just lets other threads run
if attempt < 19: # Don't yield on last attempt
time.sleep(0) # Yield to scheduler (microseconds, not milliseconds)
if not watch: if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid)) abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -125,17 +130,31 @@ class Watch(Resource):
if request.json.get('proxy'): if request.json.get('proxy'):
plist = self.datastore.proxy_list plist = self.datastore.proxy_list
if not request.json.get('proxy') in plist: if not plist or request.json.get('proxy') not in plist:
return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 proxy_list_str = ', '.join(plist) if plist else 'none configured'
return f"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'", 400
# Validate time_between_check when not using defaults # Validate time_between_check when not using defaults
validation_error = validate_time_between_check_required(request.json) validation_error = validate_time_between_check_required(request.json)
if validation_error: if validation_error:
return validation_error, 400 return validation_error, 400
# XSS etc protection # XSS etc protection - validate URL if it's being updated
if request.json.get('url') and not is_safe_valid_url(request.json.get('url')): if 'url' in request.json:
return "Invalid URL", 400 new_url = request.json.get('url')
# URL must be a non-empty string
if new_url is None:
return "URL cannot be null", 400
if not isinstance(new_url, str):
return "URL must be a string", 400
if not new_url.strip():
return "URL cannot be empty or whitespace only", 400
if not is_safe_valid_url(new_url.strip()):
return "Invalid or unsupported URL format. URL must use http://, https://, or ftp:// protocol", 400
# Handle processor-config-* fields separately (save to JSON, not datastore) # Handle processor-config-* fields separately (save to JSON, not datastore)
from changedetectionio import processors from changedetectionio import processors
@@ -231,6 +250,10 @@ class WatchSingleHistory(Resource):
if timestamp == 'latest': if timestamp == 'latest':
timestamp = list(watch.history.keys())[-1] timestamp = list(watch.history.keys())[-1]
# Validate that the timestamp exists in history
if timestamp not in watch.history:
abort(404, message=f"No history snapshot found for timestamp '{timestamp}'")
if request.args.get('html'): if request.args.get('html'):
content = watch.get_fetched_html(timestamp) content = watch.get_fetched_html(timestamp)
if content: if content:
@@ -418,8 +441,9 @@ class CreateWatch(Resource):
if json_data.get('proxy'): if json_data.get('proxy'):
plist = self.datastore.proxy_list plist = self.datastore.proxy_list
if not json_data.get('proxy') in plist: if not plist or json_data.get('proxy') not in plist:
return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 proxy_list_str = ', '.join(plist) if plist else 'none configured'
return f"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'", 400
# Validate time_between_check when not using defaults # Validate time_between_check when not using defaults
validation_error = validate_time_between_check_required(json_data) validation_error = validate_time_between_check_required(json_data)
@@ -438,7 +462,8 @@ class CreateWatch(Resource):
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags) new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
if new_uuid: if new_uuid:
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid})) # Dont queue because the scheduler will check that it hasnt been checked before anyway
# worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
return {'uuid': new_uuid}, 201 return {'uuid': new_uuid}, 201
else: else:
return "Invalid or unsupported URL", 400 return "Invalid or unsupported URL", 400
@@ -468,8 +493,58 @@ class CreateWatch(Resource):
} }
if request.args.get('recheck_all'): if request.args.get('recheck_all'):
for uuid in self.datastore.data['watching'].keys(): # Collect all watches to queue
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) watches_to_queue = self.datastore.data['watching'].keys()
return {'status': "OK"}, 200
# If less than 20 watches, queue synchronously for immediate feedback
if len(watches_to_queue) < 20:
# Get already queued/running UUIDs once (efficient)
queued_uuids = set(self.update_q.get_queued_uuids())
running_uuids = set(worker_handler.get_running_uuids())
# Filter out watches that are already queued or running
watches_to_queue_filtered = [
uuid for uuid in watches_to_queue
if uuid not in queued_uuids and uuid not in running_uuids
]
# Queue only the filtered watches
for uuid in watches_to_queue_filtered:
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
# Provide feedback about skipped watches
skipped_count = len(watches_to_queue) - len(watches_to_queue_filtered)
if skipped_count > 0:
return {'status': f'OK, queued {len(watches_to_queue_filtered)} watches for rechecking ({skipped_count} already queued or running)'}, 200
else:
return {'status': f'OK, queued {len(watches_to_queue_filtered)} watches for rechecking'}, 200
else:
# 20+ watches - queue in background thread to avoid blocking API response
# Capture queued/running state before background thread
queued_uuids = set(self.update_q.get_queued_uuids())
running_uuids = set(worker_handler.get_running_uuids())
def queue_all_watches_background():
"""Background thread to queue all watches - discarded after completion."""
try:
queued_count = 0
skipped_count = 0
for uuid in watches_to_queue:
# Check if already queued or running (state captured at start)
if uuid not in queued_uuids and uuid not in running_uuids:
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
queued_count += 1
else:
skipped_count += 1
logger.info(f"Background queueing complete: {queued_count} watches queued, {skipped_count} skipped (already queued/running)")
except Exception as e:
logger.error(f"Error in background queueing all watches: {e}")
# Start background thread and return immediately
thread = threading.Thread(target=queue_all_watches_background, daemon=True, name="QueueAllWatches-Background")
thread.start()
return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202
return list, 200 return list, 200
+39 -19
View File
@@ -1,5 +1,4 @@
from blinker import signal from blinker import signal
from .processors.exceptions import ProcessorException from .processors.exceptions import ProcessorException
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
@@ -9,7 +8,7 @@ from changedetectionio.flask_app import watch_check_update
import asyncio import asyncio
import importlib import importlib
import os import os
import queue import sys
import time import time
from loguru import logger from loguru import logger
@@ -17,36 +16,51 @@ from loguru import logger
# Async version of update_worker # Async version of update_worker
# Processes jobs from AsyncSignalPriorityQueue instead of threaded queue # Processes jobs from AsyncSignalPriorityQueue instead of threaded queue
async def async_update_worker(worker_id, q, notification_q, app, datastore): IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
DEFER_SLEEP_TIME_ALREADY_QUEUED = 0.3 if IN_PYTEST else 10.0
async def async_update_worker(worker_id, q, notification_q, app, datastore, executor=None):
""" """
Async worker function that processes watch check jobs from the queue. Async worker function that processes watch check jobs from the queue.
Args: Args:
worker_id: Unique identifier for this worker worker_id: Unique identifier for this worker
q: AsyncSignalPriorityQueue containing jobs to process q: AsyncSignalPriorityQueue containing jobs to process
notification_q: Standard queue for notifications notification_q: Standard queue for notifications
app: Flask application instance app: Flask application instance
datastore: Application datastore datastore: Application datastore
executor: ThreadPoolExecutor for queue operations (optional)
""" """
# Set a descriptive name for this task # Set a descriptive name for this task
task = asyncio.current_task() task = asyncio.current_task()
if task: if task:
task.set_name(f"async-worker-{worker_id}") task.set_name(f"async-worker-{worker_id}")
logger.info(f"Starting async worker {worker_id}") logger.info(f"Starting async worker {worker_id}")
while not app.config.exit.is_set(): while not app.config.exit.is_set():
update_handler = None update_handler = None
watch = None watch = None
try: try:
# Use native janus async interface - no threads needed! # Use sync interface via run_in_executor since each worker has its own event loop
queued_item_data = await asyncio.wait_for(q.async_get(), timeout=1.0) loop = asyncio.get_event_loop()
queued_item_data = await asyncio.wait_for(
loop.run_in_executor(executor, q.get, True, 1.0), # block=True, timeout=1.0
timeout=1.5
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# No jobs available, continue loop # No jobs available, continue loop
continue continue
except Exception as e: except Exception as e:
# Handle expected Empty exception from queue timeout
import queue
if isinstance(e, queue.Empty):
# Queue is empty, normal behavior - just continue
continue
# Unexpected exception - log as critical
logger.critical(f"CRITICAL: Worker {worker_id} failed to get queue item: {type(e).__name__}: {e}") logger.critical(f"CRITICAL: Worker {worker_id} failed to get queue item: {type(e).__name__}: {e}")
# Log queue health for debugging # Log queue health for debugging
@@ -61,14 +75,13 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
continue continue
uuid = queued_item_data.item.get('uuid') uuid = queued_item_data.item.get('uuid')
# RACE CONDITION FIX: Check if this UUID is already being processed by another worker # RACE CONDITION FIX: Check if this UUID is already being processed by another worker
from changedetectionio import worker_handler from changedetectionio import worker_handler
from changedetectionio.queuedWatchMetaData import PrioritizedItem from changedetectionio.queuedWatchMetaData import PrioritizedItem
if worker_handler.is_watch_running_by_another_worker(uuid, worker_id): if worker_handler.is_watch_running_by_another_worker(uuid, worker_id):
logger.trace(f"Worker {worker_id} detected UUID {uuid} already being processed by another worker - deferring") logger.trace(f"Worker {worker_id} detected UUID {uuid} already being processed by another worker - deferring")
# Sleep to avoid tight loop and give the other worker time to finish # Sleep to avoid tight loop and give the other worker time to finish
await asyncio.sleep(10.0) await asyncio.sleep(DEFER_SLEEP_TIME_ALREADY_QUEUED)
# Re-queue with lower priority so it gets checked again after current processing finishes # Re-queue with lower priority so it gets checked again after current processing finishes
deferred_priority = max(1000, queued_item_data.priority * 10) deferred_priority = max(1000, queued_item_data.priority * 10)
@@ -119,8 +132,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
# All fetchers are now async, so call directly # All fetchers are now async, so call directly
await update_handler.call_browser() await update_handler.call_browser()
# Run change detection (this is synchronous) # Run change detection in executor to avoid blocking event loop
changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch) # This includes CPU-intensive operations like HTML parsing (lxml/inscriptis)
# which can take 2-10ms and cause GIL contention across workers
loop = asyncio.get_event_loop()
changed_detected, update_obj, contents = await loop.run_in_executor(
executor,
lambda: update_handler.run_changedetection(watch=watch)
)
except PermissionError as e: except PermissionError as e:
logger.critical(f"File permission error updating file, watch: {uuid}") logger.critical(f"File permission error updating file, watch: {uuid}")
@@ -414,14 +433,13 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"}) datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
finally: finally:
try:
await update_handler.fetcher.quit(watch=watch)
except Exception as e:
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
# Always cleanup - this runs whether there was an exception or not # Always cleanup - this runs whether there was an exception or not
if uuid: if uuid:
try:
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
await update_handler.fetcher.quit(watch=watch)
except Exception as e:
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
try: try:
# Mark UUID as no longer being processed by this worker # Mark UUID as no longer being processed by this worker
worker_handler.set_uuid_processing(uuid, worker_id=worker_id, processing=False) worker_handler.set_uuid_processing(uuid, worker_id=worker_id, processing=False)
@@ -460,7 +478,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s") logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
except Exception as cleanup_error: except Exception as cleanup_error:
logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}") logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
del(uuid)
# Brief pause before continuing to avoid tight error loops (only on error) # Brief pause before continuing to avoid tight error loops (only on error)
if 'e' in locals(): if 'e' in locals():
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
@@ -92,7 +92,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Be sure we're written fresh # Be sure we're written fresh
datastore.sync_to_json() datastore.sync_to_json()
zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching"))) zip_thread = threading.Thread(
target=create_backup,
args=(datastore.datastore_path, datastore.data.get("watching")),
daemon=True,
name="BackupCreator"
)
zip_thread.start() zip_thread.start()
backup_threads.append(zip_thread) backup_threads.append(zip_thread)
flash(gettext("Backup building in background, check back in a few minutes.")) flash(gettext("Backup building in background, check back in a few minutes."))
@@ -21,31 +21,154 @@ from changedetectionio.flask_app import login_optionally_required
from loguru import logger from loguru import logger
browsersteps_sessions = {} browsersteps_sessions = {}
browsersteps_watch_to_session = {} # Maps watch_uuid -> browsersteps_session_id
io_interface_context = None io_interface_context = None
import json import json
import hashlib import hashlib
from flask import Response from flask import Response
import asyncio import asyncio
import threading import threading
import time
def run_async_in_browser_loop(coro): # Dedicated event loop for ALL browser steps sessions
"""Run async coroutine using the existing async worker event loop""" _browser_steps_loop = None
from changedetectionio import worker_handler _browser_steps_thread = None
_browser_steps_loop_lock = threading.Lock()
# Use the existing async worker event loop instead of creating a new one
if worker_handler.USE_ASYNC_WORKERS and worker_handler.async_loop and not worker_handler.async_loop.is_closed(): def _start_browser_steps_loop():
logger.debug("Browser steps using existing async worker event loop") """Start a dedicated event loop for browser steps in its own thread"""
future = asyncio.run_coroutine_threadsafe(coro, worker_handler.async_loop) global _browser_steps_loop
return future.result()
else: # Create and set the event loop for this thread
# Fallback: create a new event loop (for sync workers or if async loop not available) loop = asyncio.new_event_loop()
logger.debug("Browser steps creating temporary event loop") asyncio.set_event_loop(loop)
loop = asyncio.new_event_loop() _browser_steps_loop = loop
asyncio.set_event_loop(loop)
logger.debug("Browser steps event loop started")
try:
# Run the loop forever - handles all browsersteps sessions
loop.run_forever()
except Exception as e:
logger.error(f"Browser steps event loop error: {e}")
finally:
try: try:
return loop.run_until_complete(coro) # Cancel all remaining tasks
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Wait for tasks to finish cancellation
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception as e:
logger.debug(f"Error during browser steps loop cleanup: {e}")
finally: finally:
loop.close() loop.close()
logger.debug("Browser steps event loop closed")
def _ensure_browser_steps_loop():
"""Ensure the browser steps event loop is running"""
global _browser_steps_loop, _browser_steps_thread
with _browser_steps_loop_lock:
if _browser_steps_thread is None or not _browser_steps_thread.is_alive():
logger.debug("Starting browser steps event loop thread")
_browser_steps_thread = threading.Thread(
target=_start_browser_steps_loop,
daemon=True,
name="BrowserStepsEventLoop"
)
_browser_steps_thread.start()
# Wait for the loop to be ready
timeout = 5.0
start_time = time.time()
while _browser_steps_loop is None:
if time.time() - start_time > timeout:
raise RuntimeError("Browser steps event loop failed to start")
time.sleep(0.01)
logger.debug("Browser steps event loop thread started and ready")
def run_async_in_browser_loop(coro):
"""Run async coroutine using the dedicated browser steps event loop"""
_ensure_browser_steps_loop()
if _browser_steps_loop and not _browser_steps_loop.is_closed():
logger.debug("Browser steps using dedicated event loop")
future = asyncio.run_coroutine_threadsafe(coro, _browser_steps_loop)
return future.result()
else:
raise RuntimeError("Browser steps event loop is not available")
def cleanup_expired_sessions():
"""Remove expired browsersteps sessions and cleanup their resources"""
global browsersteps_sessions, browsersteps_watch_to_session
expired_session_ids = []
# Find expired sessions
for session_id, session_data in browsersteps_sessions.items():
browserstepper = session_data.get('browserstepper')
if browserstepper and browserstepper.has_expired:
expired_session_ids.append(session_id)
# Cleanup expired sessions
for session_id in expired_session_ids:
logger.debug(f"Cleaning up expired browsersteps session {session_id}")
session_data = browsersteps_sessions[session_id]
# Cleanup playwright resources asynchronously
browserstepper = session_data.get('browserstepper')
if browserstepper:
try:
run_async_in_browser_loop(browserstepper.cleanup())
except Exception as e:
logger.error(f"Error cleaning up session {session_id}: {e}")
# Remove from sessions dict
del browsersteps_sessions[session_id]
# Remove from watch mapping
for watch_uuid, mapped_session_id in list(browsersteps_watch_to_session.items()):
if mapped_session_id == session_id:
del browsersteps_watch_to_session[watch_uuid]
break
if expired_session_ids:
logger.info(f"Cleaned up {len(expired_session_ids)} expired browsersteps session(s)")
def cleanup_session_for_watch(watch_uuid):
"""Cleanup a specific browsersteps session for a watch UUID"""
global browsersteps_sessions, browsersteps_watch_to_session
session_id = browsersteps_watch_to_session.get(watch_uuid)
if not session_id:
logger.debug(f"No browsersteps session found for watch {watch_uuid}")
return
logger.debug(f"Cleaning up browsersteps session {session_id} for watch {watch_uuid}")
session_data = browsersteps_sessions.get(session_id)
if session_data:
browserstepper = session_data.get('browserstepper')
if browserstepper:
try:
run_async_in_browser_loop(browserstepper.cleanup())
except Exception as e:
logger.error(f"Error cleaning up session {session_id} for watch {watch_uuid}: {e}")
# Remove from sessions dict
del browsersteps_sessions[session_id]
# Remove from watch mapping
del browsersteps_watch_to_session[watch_uuid]
logger.debug(f"Cleaned up session for watch {watch_uuid}")
# Opportunistically cleanup any other expired sessions
cleanup_expired_sessions()
def construct_blueprint(datastore: ChangeDetectionStore): def construct_blueprint(datastore: ChangeDetectionStore):
browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates") browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates")
@@ -123,6 +246,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
if not watch_uuid: if not watch_uuid:
return make_response('No Watch UUID specified', 500) return make_response('No Watch UUID specified', 500)
# Cleanup any existing session for this watch
cleanup_session_for_watch(watch_uuid)
logger.debug("Starting connection with playwright") logger.debug("Starting connection with playwright")
logger.debug("browser_steps.py connecting") logger.debug("browser_steps.py connecting")
@@ -131,6 +257,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
browsersteps_sessions[browsersteps_session_id] = run_async_in_browser_loop( browsersteps_sessions[browsersteps_session_id] = run_async_in_browser_loop(
start_browsersteps_session(watch_uuid) start_browsersteps_session(watch_uuid)
) )
# Store the mapping of watch_uuid -> browsersteps_session_id
browsersteps_watch_to_session[watch_uuid] = browsersteps_session_id
except Exception as e: except Exception as e:
if 'ECONNREFUSED' in str(e): if 'ECONNREFUSED' in str(e):
return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401) return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401)
+24 -14
View File
@@ -1,13 +1,8 @@
from flask import Blueprint, request, redirect, url_for, flash, render_template from flask import Blueprint, request, redirect, url_for, flash, render_template
from loguru import logger
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio import worker_handler
from changedetectionio.blueprint.imports.importer import (
import_url_list,
import_distill_io_json,
import_xlsx_wachete,
import_xlsx_custom
)
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
import_blueprint = Blueprint('imports', __name__, template_folder="templates") import_blueprint = Blueprint('imports', __name__, template_folder="templates")
@@ -17,15 +12,26 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
def import_page(): def import_page():
remaining_urls = [] remaining_urls = []
from changedetectionio import forms from changedetectionio import forms
#
if request.method == 'POST': if request.method == 'POST':
# from changedetectionio import worker_handler
from changedetectionio.blueprint.imports.importer import (
import_url_list,
import_distill_io_json,
import_xlsx_wachete,
import_xlsx_custom
)
# URL List import # URL List import
if request.values.get('urls') and len(request.values.get('urls').strip()): if request.values.get('urls') and len(request.values.get('urls').strip()):
# Import and push into the queue for immediate update check # Import and push into the queue for immediate update check
importer_handler = import_url_list() importer_handler = import_url_list()
importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff')) importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
for uuid in importer_handler.new_uuids: logger.debug(f"Imported {len(importer_handler.new_uuids)} new UUIDs")
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) # Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
# for uuid in importer_handler.new_uuids:
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
if len(importer_handler.remaining_data) == 0: if len(importer_handler.remaining_data) == 0:
return redirect(url_for('watchlist.index')) return redirect(url_for('watchlist.index'))
@@ -37,8 +43,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Import and push into the queue for immediate update check # Import and push into the queue for immediate update check
d_importer = import_distill_io_json() d_importer = import_distill_io_json()
d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore) d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)
for uuid in d_importer.new_uuids: # Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) # for uuid in importer_handler.new_uuids:
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
# XLSX importer # XLSX importer
if request.files and request.files.get('xlsx_file'): if request.files and request.files.get('xlsx_file'):
@@ -60,8 +68,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
w_importer.import_profile = map w_importer.import_profile = map
w_importer.run(data=file, flash=flash, datastore=datastore) w_importer.run(data=file, flash=flash, datastore=datastore)
for uuid in w_importer.new_uuids: # Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) # for uuid in importer_handler.new_uuids:
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
# Could be some remaining, or we could be on GET # Could be some remaining, or we could be on GET
form = forms.importForm(formdata=request.form if request.method == 'POST' else None) form = forms.importForm(formdata=request.form if request.method == 'POST' else None)
@@ -78,14 +78,20 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Handle dynamic worker count adjustment # Handle dynamic worker count adjustment
old_worker_count = datastore.data['settings']['requests'].get('workers', 1) old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
new_worker_count = form.data['requests'].get('workers', 1) new_worker_count = form.data['requests'].get('workers', 1)
datastore.data['settings']['requests'].update(form.data['requests']) datastore.data['settings']['requests'].update(form.data['requests'])
# Adjust worker count if it changed # Adjust worker count if it changed
if new_worker_count != old_worker_count: if new_worker_count != old_worker_count:
from changedetectionio import worker_handler from changedetectionio import worker_handler
from changedetectionio.flask_app import update_q, notification_q, app, datastore as ds from changedetectionio.flask_app import update_q, notification_q, app, datastore as ds
# Check CPU core availability and warn if worker count is high
cpu_count = os.cpu_count()
if cpu_count and new_worker_count >= (cpu_count * 0.9):
flash(gettext("Warning: Worker count ({}) is close to or exceeds available CPU cores ({})").format(
new_worker_count, cpu_count), 'warning')
result = worker_handler.adjust_async_worker_count( result = worker_handler.adjust_async_worker_count(
new_count=new_worker_count, new_count=new_worker_count,
update_q=update_q, update_q=update_q,
@@ -93,7 +99,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
app=app, app=app,
datastore=ds datastore=ds
) )
if result['status'] == 'success': if result['status'] == 'success':
flash(gettext("Worker count adjusted: {}").format(result['message']), 'notice') flash(gettext("Worker count adjusted: {}").format(result['message']), 'notice')
elif result['status'] == 'not_supported': elif result['status'] == 'not_supported':
@@ -187,4 +193,32 @@ def construct_blueprint(datastore: ChangeDetectionStore):
logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."]) logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."])
return output return output
@settings_blueprint.route("/toggle-all-paused", methods=['GET'])
@login_optionally_required
def toggle_all_paused():
current_state = datastore.data['settings']['application'].get('all_paused', False)
datastore.data['settings']['application']['all_paused'] = not current_state
datastore.needs_write_urgent = True
if datastore.data['settings']['application']['all_paused']:
flash(gettext("Automatic scheduling paused - checks will not be queued."), 'notice')
else:
flash(gettext("Automatic scheduling resumed - checks will be queued normally."), 'notice')
return redirect(url_for('watchlist.index'))
@settings_blueprint.route("/toggle-all-muted", methods=['GET'])
@login_optionally_required
def toggle_all_muted():
current_state = datastore.data['settings']['application'].get('all_muted', False)
datastore.data['settings']['application']['all_muted'] = not current_state
datastore.needs_write_urgent = True
if datastore.data['settings']['application']['all_muted']:
flash(gettext("All notifications muted."), 'notice')
else:
flash(gettext("All notifications unmuted."), 'notice')
return redirect(url_for('watchlist.index'))
return settings_blueprint return settings_blueprint
@@ -25,6 +25,7 @@
<li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li> <li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li>
<li class="tab"><a href="#api">{{ _('API') }}</a></li> <li class="tab"><a href="#api">{{ _('API') }}</a></li>
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li> <li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
<li class="tab"><a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('Backups') }}</a></li>
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li> <li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li> <li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
{% if plugin_tabs %} {% if plugin_tabs %}
@@ -53,9 +54,9 @@
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }} {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }}
<span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification <span class="pure-form-message-inline">{{ _('After this many consecutive times that the CSS/xPath filter is missing, send a notification') }}
<br> <br>
Set to <strong>0</strong> to disable {{ _('Set to') }} <strong>0</strong> {{ _('to disable') }}
</span> </span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
@@ -64,21 +65,20 @@
{{ render_button(form.application.form.removepassword_button) }} {{ render_button(form.application.form.removepassword_button) }}
{% else %} {% else %}
{{ render_field(form.application.form.password) }} {{ render_field(form.application.form.password) }}
<span class="pure-form-message-inline">Password protection for your changedetection.io application.</span> <span class="pure-form-message-inline">{{ _('Password protection for your changedetection.io application.') }}</span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="pure-form-message-inline">Password is locked.</span> <span class="pure-form-message-inline">{{ _('Password is locked.') }}</span>
{% endif %} {% endif %}
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.shared_diff_access, class="shared_diff_access") }} {{ render_checkbox_field(form.application.form.shared_diff_access, class="shared_diff_access") }}
<span class="pure-form-message-inline">Allow access to the watch change history page when password is enabled (Good for sharing the diff page) <span class="pure-form-message-inline">{{ _('Allow access to the watch change history page when password is enabled (Good for sharing the diff page)') }}</span>
</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }}
<span class="pure-form-message-inline">When a request returns no content, or the HTML does not contain any text, is this considered a change?</span> <span class="pure-form-message-inline">{{ _('When a request returns no content, or the HTML does not contain any text, is this considered a change?') }}</span>
</div> </div>
</fieldset> </fieldset>
</div> </div>
@@ -90,8 +90,8 @@
<div class="pure-control-group" id="notification-base-url"> <div class="pure-control-group" id="notification-base-url">
{{ render_field(form.application.form.base_url, class="m-d") }} {{ render_field(form.application.form.base_url, class="m-d") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notification links.<br> {{ _('Base URL used for the') }} <code>{{ '{{ base_url }}' }}</code> {{ _('token in notification links.') }}<br>
Default value is the system environment variable '<code>BASE_URL</code>' - <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. {{ _('Default value is the system environment variable') }} '<code>BASE_URL</code>' - <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">{{ _('read more here') }}</a>.
</span> </span>
</div> </div>
</div> </div>
@@ -100,15 +100,15 @@
<div class="pure-control-group inline-radio"> <div class="pure-control-group inline-radio">
{{ render_field(form.application.form.fetch_backend, class="fetch-backend") }} {{ render_field(form.application.form.fetch_backend, class="fetch-backend") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> <p>{{ _('Use the') }} <strong>{{ _('Basic') }}</strong> {{ _('method (default) where your watched sites don\'t need Javascript to render.') }}</p>
<p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> <p>{{ _('The') }} <strong>{{ _('Chrome/Javascript') }}</strong> {{ _('method requires a network connection to a running WebDriver+Chrome server, set by the ENV var') }} 'WEBDRIVER_URL'. </p>
</span> </span>
</div> </div>
<fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver"> <fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver">
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> <strong>{{ _('If you\'re having trouble waiting for the page to be fully rendered (text missing etc), try increasing the \'wait\' time here.') }}</strong>
<br> <br>
This will wait <i>n</i> seconds before extracting the text. {{ _('This will wait') }} <i>n</i> {{ _('seconds before extracting the text.') }}
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.webdriver_delay) }} {{ render_field(form.application.form.webdriver_delay) }}
@@ -117,27 +117,27 @@
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.requests.form.workers) }} {{ render_field(form.requests.form.workers) }}
{% set worker_info = get_worker_status_info() %} {% set worker_info = get_worker_status_info() %}
<span class="pure-form-message-inline">Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.<br> <span class="pure-form-message-inline">{{ _('Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.') }}<br>
Currently running: <strong>{{ worker_info.count }}</strong> operational {{ worker_info.type }} workers{% if worker_info.active_workers > 0 %} ({{ worker_info.active_workers }} actively processing){% endif %}.</span> {{ _('Currently running:') }} <strong>{{ worker_info.count }}</strong> {{ _('operational') }} {{ worker_info.type }} {{ _('workers') }}{% if worker_info.active_workers > 0 %} ({{ worker_info.active_workers }} {{ _('actively processing') }}){% endif %}.</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }} {{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }}
<span class="pure-form-message-inline">Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later</span> <span class="pure-form-message-inline">{{ _('Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later') }}</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.requests.form.timeout) }} {{ render_field(form.requests.form.timeout) }}
<span class="pure-form-message-inline">For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.</span><br> <span class="pure-form-message-inline">{{ _('For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.') }}</span><br>
</div> </div>
<div class="pure-control-group inline-radio"> <div class="pure-control-group inline-radio">
{{ render_field(form.requests.form.default_ua) }} {{ render_field(form.requests.form.default_ua) }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
Applied to all requests.<br><br> {{ _('Applied to all requests.') }}<br><br>
Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider <a href="https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms">all of the ways that the browser is detected</a>. {{ _('Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it\'s important to consider') }} <a href="https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms">{{ _('all of the ways that the browser is detected') }}</a>.
</span> </span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<br> <br>
Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> {{ _('Tip:') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">{{ _('Connect using Bright Data and Oxylabs Proxies, find out more here.') }}</a>
</div> </div>
</div> </div>
@@ -146,15 +146,15 @@
<fieldset class="pure-group"> <fieldset class="pure-group">
{{ render_checkbox_field(form.application.form.ignore_whitespace) }} {{ render_checkbox_field(form.application.form.ignore_whitespace) }}
<span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br> <span class="pure-form-message-inline">{{ _('Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.') }}<br>
<i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc. <i>{{ _('Note:') }}</i> {{ _('Changing this will change the status of your existing watches, possibly trigger alerts etc.') }}
</span> </span>
</fieldset> </fieldset>
<fieldset class="pure-group"> <fieldset class="pure-group">
{{ render_checkbox_field(form.application.form.render_anchor_tag_content) }} {{ render_checkbox_field(form.application.form.render_anchor_tag_content) }}
<span class="pure-form-message-inline">Render anchor tag content, default disabled, when enabled renders links as <code>(link text)[https://somesite.com]</code> <span class="pure-form-message-inline">{{ _('Render anchor tag content, default disabled, when enabled renders links as') }} <code>(link text)[https://somesite.com]</code>
<br> <br>
<i>Note:</i> Changing this could affect the content of your existing watches, possibly trigger alerts etc. <i>{{ _('Note:') }}</i> {{ _('Changing this could affect the content of your existing watches, possibly trigger alerts etc.') }}
</span> </span>
</fieldset> </fieldset>
<fieldset class="pure-group"> <fieldset class="pure-group">
@@ -165,9 +165,9 @@ nav
//*[contains(text(), 'Advertisement')]") }} //*[contains(text(), 'Advertisement')]") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li> <li> {{ _('Remove HTML element(s) by CSS and XPath selectors before text conversion.') }} </li>
<li> Don't paste HTML here, use only CSS and XPath selectors </li> <li> {{ _('Don\'t paste HTML here, use only CSS and XPath selectors') }} </li>
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li> <li> {{ _('Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.') }} </li>
</ul> </ul>
</span> </span>
</fieldset> </fieldset>
@@ -175,50 +175,50 @@ nav
{{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line {{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line
/some.regex\d{2}/ for case-INsensitive regex /some.regex\d{2}/ for case-INsensitive regex
") }} ") }}
<span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br> <span class="pure-form-message-inline">{{ _('Note: This is applied globally in addition to the per-watch rules.') }}</span><br>
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li> <li>{{ _('Matching text will be') }} <strong>{{ _('ignored') }}</strong> {{ _('in the text snapshot (you can still see it but it wont trigger a change)') }}</li>
<li>Note: This is applied globally in addition to the per-watch rules.</li> <li>{{ _('Note: This is applied globally in addition to the per-watch rules.') }}</li>
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> <li>{{ _('Each line processed separately, any line matching will be ignored (removed before creating the checksum)') }}</li>
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> <li>{{ _('Regular Expression support, wrap the entire line in forward slash') }} <code>/regex/</code></li>
<li>Changing this will affect the comparison checksum which may trigger an alert</li> <li>{{ _('Changing this will affect the comparison checksum which may trigger an alert') }}</li>
</ul> </ul>
</span> </span>
</fieldset> </fieldset>
<fieldset class="pure-group"> <fieldset class="pure-group">
{{ render_checkbox_field(form.application.form.strip_ignored_lines) }} {{ render_checkbox_field(form.application.form.strip_ignored_lines) }}
<span class="pure-form-message-inline">Remove any text that appears in the "Ignore text" from the output (otherwise its just ignored for change-detection)<br> <span class="pure-form-message-inline">{{ _('Remove any text that appears in the "Ignore text" from the output (otherwise its just ignored for change-detection)') }}<br>
<i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc. <i>{{ _('Note:') }}</i> {{ _('Changing this will change the status of your existing watches, possibly trigger alerts etc.') }}
</span> </span>
</fieldset> </fieldset>
</div> </div>
<div class="tab-pane-inner" id="api"> <div class="tab-pane-inner" id="api">
<h4>API Access</h4> <h4>{{ _('API Access') }}</h4>
<p>Drive your changedetection.io via API, More about <a href="https://changedetection.io/docs/api_v1/index.html">API access and examples here</a>.</p> <p>{{ _('Drive your changedetection.io via API, More about') }} <a href="https://changedetection.io/docs/api_v1/index.html">{{ _('API access and examples here') }}</a>.</p>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.api_access_token_enabled) }} {{ render_checkbox_field(form.application.form.api_access_token_enabled) }}
<div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header - required for the Chrome Extension to work</div><br> <div class="pure-form-message-inline">{{ _('Restrict API access limit by using') }} <code>x-api-key</code> {{ _('header - required for the Chrome Extension to work') }}</div><br>
<div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span> <div class="pure-form-message-inline"><br>{{ _('API Key') }} <span id="api-key">{{api_key}}</span>
<span style="display:none;" id="api-key-copy" >copy</span> <span style="display:none;" id="api-key-copy" >{{ _('copy') }}</span>
</div> </div>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a> <a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">{{ _('Regenerate API key') }}</a>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<h4>Chrome Extension</h4> <h4>{{ _('Chrome Extension') }}</h4>
<p>Easily add any web-page to your changedetection.io installation from within Chrome.</p> <p>{{ _('Easily add any web-page to your changedetection.io installation from within Chrome.') }}</p>
<strong>Step 1</strong> Install the extension, <strong>Step 2</strong> Navigate to this page, <strong>{{ _('Step 1') }}</strong> {{ _('Install the extension,') }} <strong>{{ _('Step 2') }}</strong> {{ _('Navigate to this page,') }}
<strong>Step 3</strong> Open the extension from the toolbar and click "<i>Sync API Access</i>" <strong>{{ _('Step 3') }}</strong> {{ _('Open the extension from the toolbar and click') }} "<i>{{ _('Sync API Access') }}</i>"
<p> <p>
<a id="chrome-extension-link" <a id="chrome-extension-link"
title="Try our new Chrome Extension!" title="{{ _('Try our new Chrome Extension!') }}"
href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop"> href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop">
<img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" > <img alt="{{ _('Chrome store icon') }}" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" >
Chrome Webstore {{ _('Chrome Webstore') }}
</a> </a>
</p> </p>
</div> </div>
@@ -229,20 +229,20 @@ nav
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.rss_diff_length) }} {{ render_field(form.application.form.rss_diff_length) }}
<span class="pure-form-message-inline">Maximum number of history snapshots to include in the watch specific RSS feed.</span> <span class="pure-form-message-inline">{{ _('Maximum number of history snapshots to include in the watch specific RSS feed.') }}</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.rss_reader_mode) }} {{ render_checkbox_field(form.application.form.rss_reader_mode) }}
<span class="pure-form-message-inline">For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.</span> <span class="pure-form-message-inline">{{ _('For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.') }}</span>
</div> </div>
<div class="pure-control-group grey-form-border"> <div class="pure-control-group grey-form-border">
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.rss_content_format) }} {{ render_field(form.application.form.rss_content_format) }}
<span class="pure-form-message-inline">Does your reader support HTML? Set it here</span> <span class="pure-form-message-inline">{{ _('Does your reader support HTML? Set it here') }}</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.rss_template_type) }} {{ render_field(form.application.form.rss_template_type) }}
<span class="pure-form-message-inline">'System default' for the same template for all items, or re-use your "Notification Body" as the template.</span> <span class="pure-form-message-inline">{{ _('\'System default\' for the same template for all items, or re-use your "Notification Body" as the template.') }}</span>
</div> </div>
<div> <div>
{{ render_field(form.application.form.rss_template_override) }} {{ render_field(form.application.form.rss_template_override) }}
@@ -255,11 +255,11 @@ nav
</div> </div>
<div class="tab-pane-inner" id="timedate"> <div class="tab-pane-inner" id="timedate">
<div class="pure-control-group"> <div class="pure-control-group">
Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches. {{ _('Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.') }}
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<p><strong>UTC Time &amp; Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p> <p><strong>{{ _('UTC Time & Date from Server:') }}</strong> <span id="utc-time" >{{ utc_time }}</span></p>
<p><strong>Local Time &amp; Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p> <p><strong>{{ _('Local Time & Date in Browser:') }}</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p>
<div> <div>
{{ render_field(form.application.form.scheduler_timezone_default) }} {{ render_field(form.application.form.scheduler_timezone_default) }}
<datalist id="timezones" style="display: none;"> <datalist id="timezones" style="display: none;">
@@ -271,22 +271,22 @@ nav
<div class="tab-pane-inner" id="ui-options"> <div class="tab-pane-inner" id="ui-options">
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }} {{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }}
<span class="pure-form-message-inline">Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.</span> <span class="pure-form-message-inline">{{ _('Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.') }}</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ui.form.socket_io_enabled, class="socket_io_enabled") }} {{ render_checkbox_field(form.application.form.ui.form.socket_io_enabled, class="socket_io_enabled") }}
<span class="pure-form-message-inline">Realtime UI Updates Enabled - (Restart required if this is changed)</span> <span class="pure-form-message-inline">{{ _('Realtime UI Updates Enabled - (Restart required if this is changed)') }}</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ui.form.favicons_enabled, class="") }} {{ render_checkbox_field(form.application.form.ui.form.favicons_enabled, class="") }}
<span class="pure-form-message-inline">Enable or Disable Favicons next to the watch list</span> <span class="pure-form-message-inline">{{ _('Enable or Disable Favicons next to the watch list') }}</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ui.use_page_title_in_list) }} {{ render_checkbox_field(form.application.form.ui.use_page_title_in_list) }}
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.application.form.pager_size) }} {{ render_field(form.application.form.pager_size) }}
<span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span> <span class="pure-form-message-inline">{{ _('Number of items per page in the watch overview list, 0 to disable.') }}</span>
</div> </div>
</div> </div>
@@ -334,18 +334,18 @@ nav
</div> </div>
</div> </div>
<p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.</p> <p><strong>{{ _('Tip') }}</strong>: {{ _('"Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.') }}</p>
<div class="pure-control-group" id="extra-proxies-setting"> <div class="pure-control-group" id="extra-proxies-setting">
{{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }} {{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }}
<span class="pure-form-message-inline">"Name" will be used for selecting the proxy in the Watch Edit settings</span><br> <span class="pure-form-message-inline">{{ _('"Name" will be used for selecting the proxy in the Watch Edit settings') }}</span><br>
<span class="pure-form-message-inline">SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should whitelist the IP access instead</span> <span class="pure-form-message-inline">{{ _('SOCKS5 proxies with authentication are only supported with \'plain requests\' fetcher, for other fetchers you should whitelist the IP access instead') }}</span>
{% if form.requests.proxy %} {% if form.requests.proxy %}
<div> <div>
<br> <br>
<div class="inline-radio"> <div class="inline-radio">
{{ render_field(form.requests.form.proxy, class="fetch-backend-proxy") }} {{ render_field(form.requests.form.proxy, class="fetch-backend-proxy") }}
<span class="pure-form-message-inline">Choose a default proxy for all watches</span> <span class="pure-form-message-inline">{{ _('Choose a default proxy for all watches') }}</span>
</div> </div>
</div> </div>
{% endif %} {% endif %}
+52 -16
View File
@@ -1,5 +1,7 @@
import threading
from flask import Blueprint, request, render_template, flash, url_for, redirect from flask import Blueprint, request, render_template, flash, url_for, redirect
from flask_babel import gettext from flask_babel import gettext
from loguru import logger
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore
from changedetectionio.flask_app import login_optionally_required from changedetectionio.flask_app import login_optionally_required
@@ -62,39 +64,73 @@ def construct_blueprint(datastore: ChangeDetectionStore):
@tags_blueprint.route("/delete/<string:uuid>", methods=['GET']) @tags_blueprint.route("/delete/<string:uuid>", methods=['GET'])
@login_optionally_required @login_optionally_required
def delete(uuid): def delete(uuid):
removed = 0 # Delete the tag from settings immediately
# Delete the tag, and any tag reference
if datastore.data['settings']['application']['tags'].get(uuid): if datastore.data['settings']['application']['tags'].get(uuid):
del datastore.data['settings']['application']['tags'][uuid] del datastore.data['settings']['application']['tags'][uuid]
for watch_uuid, watch in datastore.data['watching'].items(): # Remove tag from all watches in background thread to avoid blocking
if watch.get('tags') and uuid in watch['tags']: def remove_tag_background(tag_uuid):
removed += 1 """Background thread to remove tag from watches - discarded after completion."""
watch['tags'].remove(uuid) removed_count = 0
try:
for watch_uuid, watch in datastore.data['watching'].items():
if watch.get('tags') and tag_uuid in watch['tags']:
watch['tags'].remove(tag_uuid)
removed_count += 1
logger.info(f"Background: Tag {tag_uuid} removed from {removed_count} watches")
except Exception as e:
logger.error(f"Error removing tag from watches: {e}")
flash(gettext("Tag deleted and removed from {} watches").format(removed)) # Start daemon thread
threading.Thread(target=remove_tag_background, args=(uuid,), daemon=True).start()
flash(gettext("Tag deleted, removing from watches in background"))
return redirect(url_for('tags.tags_overview_page')) return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/unlink/<string:uuid>", methods=['GET']) @tags_blueprint.route("/unlink/<string:uuid>", methods=['GET'])
@login_optionally_required @login_optionally_required
def unlink(uuid): def unlink(uuid):
unlinked = 0 # Unlink tag from all watches in background thread to avoid blocking
for watch_uuid, watch in datastore.data['watching'].items(): def unlink_tag_background(tag_uuid):
if watch.get('tags') and uuid in watch['tags']: """Background thread to unlink tag from watches - discarded after completion."""
unlinked += 1 unlinked_count = 0
watch['tags'].remove(uuid) try:
for watch_uuid, watch in datastore.data['watching'].items():
if watch.get('tags') and tag_uuid in watch['tags']:
watch['tags'].remove(tag_uuid)
unlinked_count += 1
logger.info(f"Background: Tag {tag_uuid} unlinked from {unlinked_count} watches")
except Exception as e:
logger.error(f"Error unlinking tag from watches: {e}")
flash(gettext("Tag unlinked removed from {} watches").format(unlinked)) # Start daemon thread
threading.Thread(target=unlink_tag_background, args=(uuid,), daemon=True).start()
flash(gettext("Unlinking tag from watches in background"))
return redirect(url_for('tags.tags_overview_page')) return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/delete_all", methods=['GET']) @tags_blueprint.route("/delete_all", methods=['GET'])
@login_optionally_required @login_optionally_required
def delete_all(): def delete_all():
for watch_uuid, watch in datastore.data['watching'].items(): # Clear all tags from settings immediately
watch['tags'] = []
datastore.data['settings']['application']['tags'] = {} datastore.data['settings']['application']['tags'] = {}
flash(gettext("All tags deleted")) # Clear tags from all watches in background thread to avoid blocking
def clear_all_tags_background():
"""Background thread to clear tags from all watches - discarded after completion."""
cleared_count = 0
try:
for watch_uuid, watch in datastore.data['watching'].items():
watch['tags'] = []
cleared_count += 1
logger.info(f"Background: Cleared tags from {cleared_count} watches")
except Exception as e:
logger.error(f"Error clearing tags from watches: {e}")
# Start daemon thread
threading.Thread(target=clear_all_tags_background, daemon=True).start()
flash(gettext("All tags deleted, clearing from watches in background"))
return redirect(url_for('tags.tags_overview_page')) return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/edit/<string:uuid>", methods=['GET']) @tags_blueprint.route("/edit/<string:uuid>", methods=['GET'])
@@ -50,7 +50,8 @@
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td> <td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td> <td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td>
<td> <td>
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>&nbsp; <a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>
<a href="{{ url_for('ui.form_watch_checknow', tag=uuid) }}" class="pure-button pure-button-primary" >{{ _('Recheck') }}</a>
<a class="pure-button button-error" <a class="pure-button button-error"
href="{{ url_for('tags.delete', uuid=uuid) }}" href="{{ url_for('tags.delete', uuid=uuid) }}"
data-requires-confirm data-requires-confirm
+107 -34
View File
@@ -1,4 +1,5 @@
import time import time
import threading
from flask import Blueprint, request, redirect, url_for, flash, render_template, session from flask import Blueprint, request, redirect, url_for, flash, render_template, session
from flask_babel import gettext from flask_babel import gettext
from loguru import logger from loguru import logger
@@ -151,9 +152,24 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
confirmtext = request.form.get('confirmtext') confirmtext = request.form.get('confirmtext')
if confirmtext == 'clear': if confirmtext == 'clear':
for uuid in datastore.data['watching'].keys(): # Run in background thread to avoid blocking
datastore.clear_watch_history(uuid) def clear_history_background():
flash(gettext("Cleared snapshot history for all watches")) # Capture UUIDs first to avoid race conditions
watch_uuids = list(datastore.data['watching'].keys())
logger.info(f"Background: Clearing history for {len(watch_uuids)} watches")
for uuid in watch_uuids:
try:
datastore.clear_watch_history(uuid)
except Exception as e:
logger.error(f"Error clearing history for watch {uuid}: {e}")
logger.info("Background: Completed clearing history")
# Start daemon thread
threading.Thread(target=clear_history_background, daemon=True).start()
flash(gettext("History clearing started in background"))
else: else:
flash(gettext('Incorrect confirmation text.'), 'error') flash(gettext('Incorrect confirmation text.'), 'error')
@@ -169,18 +185,32 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
# Save the current newest history as the most recently viewed # Save the current newest history as the most recently viewed
with_errors = request.args.get('with_errors') == "1" with_errors = request.args.get('with_errors') == "1"
tag_limit = request.args.get('tag') tag_limit = request.args.get('tag')
logger.debug(f"Limiting to tag {tag_limit}")
now = int(time.time()) now = int(time.time())
for watch_uuid, watch in datastore.data['watching'].items():
if with_errors and not watch.get('last_error'):
continue
if tag_limit and ( not watch.get('tags') or tag_limit not in watch['tags'] ): # Mark watches as viewed in background thread to avoid blocking
logger.debug(f"Skipping watch {watch_uuid}") def mark_viewed_background():
continue """Background thread to mark watches as viewed - discarded after completion."""
marked_count = 0
try:
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, now) if tag_limit and (not watch.get('tags') or tag_limit not in watch['tags']):
continue
datastore.set_last_viewed(watch_uuid, now)
marked_count += 1
logger.info(f"Background marking complete: {marked_count} watches marked as viewed")
except Exception as e:
logger.error(f"Error in background mark as viewed: {e}")
# Start background thread and return immediately
thread = threading.Thread(target=mark_viewed_background, daemon=True)
thread.start()
flash(gettext("Marking watches as viewed in background..."))
return redirect(url_for('watchlist.index', tag=tag_limit)) return redirect(url_for('watchlist.index', tag=tag_limit))
@ui_blueprint.route("/delete", methods=['GET']) @ui_blueprint.route("/delete", methods=['GET'])
@@ -225,38 +255,81 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
uuid = request.args.get('uuid') uuid = request.args.get('uuid')
with_errors = request.args.get('with_errors') == "1" with_errors = request.args.get('with_errors') == "1"
i = 0
running_uuids = worker_handler.get_running_uuids()
if uuid: if uuid:
if uuid not in running_uuids: # Single watch - check if already queued or running
if worker_handler.is_watch_running(uuid) or uuid in update_q.get_queued_uuids():
flash(gettext("Watch is already queued or being checked."))
else:
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
i += 1 flash(gettext("Queued 1 watch for rechecking."))
else: else:
# Recheck all, including muted # Multiple watches - first count how many need to be queued
# Get most overdue first watches_to_queue = []
for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)): for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):
watch_uuid = k[0] watch_uuid = k[0]
watch = k[1] watch = k[1]
if not watch['paused']: if not watch['paused'] and watch_uuid:
if watch_uuid not in running_uuids: if with_errors and not watch.get('last_error'):
if with_errors and not watch.get('last_error'): continue
continue if tag != None and tag not in watch['tags']:
continue
watches_to_queue.append(watch_uuid)
if tag != None and tag not in watch['tags']: # If less than 20 watches, queue synchronously for immediate feedback
continue if len(watches_to_queue) < 20:
# Get already queued/running UUIDs once (efficient)
queued_uuids = set(update_q.get_queued_uuids())
running_uuids = set(worker_handler.get_running_uuids())
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})) # Filter out watches that are already queued or running
i += 1 watches_to_queue_filtered = []
for watch_uuid in watches_to_queue:
if watch_uuid not in queued_uuids and watch_uuid not in running_uuids:
watches_to_queue_filtered.append(watch_uuid)
if i == 1: # Queue only the filtered watches
flash(gettext("Queued 1 watch for rechecking.")) for watch_uuid in watches_to_queue_filtered:
if i > 1: worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
flash(gettext("Queued {} watches for rechecking.").format(i))
if i == 0: # Provide feedback about skipped watches
flash(gettext("No watches available to recheck.")) skipped_count = len(watches_to_queue) - len(watches_to_queue_filtered)
if skipped_count > 0:
flash(gettext("Queued {} watches for rechecking ({} already queued or running).").format(
len(watches_to_queue_filtered), skipped_count))
else:
if len(watches_to_queue_filtered) == 1:
flash(gettext("Queued 1 watch for rechecking."))
else:
flash(gettext("Queued {} watches for rechecking.").format(len(watches_to_queue_filtered)))
else:
# 20+ watches - queue in background thread to avoid blocking HTTP response
# Capture queued/running state before background thread
queued_uuids = set(update_q.get_queued_uuids())
running_uuids = set(worker_handler.get_running_uuids())
def queue_watches_background():
"""Background thread to queue watches - discarded after completion."""
try:
queued_count = 0
skipped_count = 0
for watch_uuid in watches_to_queue:
# Check if already queued or running (state captured at start)
if watch_uuid not in queued_uuids and watch_uuid not in running_uuids:
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
queued_count += 1
else:
skipped_count += 1
logger.info(f"Background queueing complete: {queued_count} watches queued, {skipped_count} skipped (already queued/running)")
except Exception as e:
logger.error(f"Error in background queueing: {e}")
# Start background thread and return immediately
thread = threading.Thread(target=queue_watches_background, daemon=True, name="QueueWatches-Background")
thread.start()
# Return immediately with approximate message
flash(gettext("Queueing watches for rechecking in background..."))
return redirect(url_for('watchlist.index', **({'tag': tag} if tag else {}))) return redirect(url_for('watchlist.index', **({'tag': tag} if tag else {})))
+14 -3
View File
@@ -52,7 +52,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
redirect(url_for('ui_edit.edit_page', uuid=uuid)) redirect(url_for('ui_edit.edit_page', uuid=uuid))
# be sure we update with a copy instead of accidently editing the live object by reference # be sure we update with a copy instead of accidently editing the live object by reference
default = deepcopy(datastore.data['watching'][uuid]) default = None
while not default:
try:
default = deepcopy(datastore.data['watching'][uuid])
except RuntimeError as e:
# Dictionary changed
continue
# Defaults for proxy choice # Defaults for proxy choice
if datastore.proxy_list is not None: # When enabled if datastore.proxy_list is not None: # When enabled
@@ -238,6 +244,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid]) datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch.")) flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch."))
# Cleanup any browsersteps session for this watch
try:
from changedetectionio.blueprint.browser_steps import cleanup_session_for_watch
cleanup_session_for_watch(uuid)
except Exception as e:
logger.debug(f"Error cleaning up browsersteps session: {e}")
# Re #286 - We wait for syncing new data to disk in another thread every 60 seconds # Re #286 - We wait for syncing new data to disk in another thread every 60 seconds
# But in the case something is added we should save straight away # But in the case something is added we should save straight away
datastore.needs_write_urgent = True datastore.needs_write_urgent = True
@@ -325,8 +338,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'url': url_for('rss.rss_single_watch', uuid=watch['uuid'], token=app_rss_token) 'url': url_for('rss.rss_single_watch', uuid=watch['uuid'], token=app_rss_token)
}, },
'settings_application': datastore.data['settings']['application'], 'settings_application': datastore.data['settings']['application'],
'system_has_playwright_configured': os.getenv('PLAYWRIGHT_DRIVER_URL'),
'system_has_webdriver_configured': os.getenv('WEBDRIVER_URL'),
'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch), 'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch),
'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid), 'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),
'timezone_default_config': datastore.data['settings']['application'].get('scheduler_timezone_default'), 'timezone_default_config': datastore.data['settings']['application'].get('scheduler_timezone_default'),
@@ -206,7 +206,7 @@ Math: {{ 1 + 1 }}") }}
<div class="tab-pane-inner" id="browser-steps"> <div class="tab-pane-inner" id="browser-steps">
{% if capabilities.supports_browser_steps %} {% if capabilities.supports_browser_steps %}
{% if visual_selector_data_ready %} {% if true %}
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality"> <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
@@ -2,8 +2,8 @@ import os
import time import time
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session
from flask_login import current_user
from flask_paginate import Pagination, get_page_parameter from flask_paginate import Pagination, get_page_parameter
from flask_babel import gettext as _
from changedetectionio import forms from changedetectionio import forms
from changedetectionio import processors from changedetectionio import processors
@@ -74,7 +74,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
pagination = Pagination(page=page, pagination = Pagination(page=page,
total=total_count, total=total_count,
per_page=datastore.data['settings']['application'].get('pager_size', 50), css_framework="semantic") per_page=datastore.data['settings']['application'].get('pager_size', 50),
css_framework="semantic",
display_msg=_('displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>'),
record_name=_('records'))
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title']) sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
@@ -85,6 +88,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'), app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
datastore=datastore, datastore=datastore,
errored_count=errored_count, errored_count=errored_count,
extra_classes='has-queue' if not update_q.empty() else '',
form=form, form=form,
generate_tag_colors=processors.generate_processor_badge_colors, generate_tag_colors=processors.generate_processor_badge_colors,
guid=datastore.data['app_guid'], guid=datastore.data['app_guid'],
@@ -92,10 +96,11 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
hosted_sticky=os.getenv("SALTED_PASS", False) == False, hosted_sticky=os.getenv("SALTED_PASS", False) == False,
now_time_server=round(time.time()), now_time_server=round(time.time()),
pagination=pagination, pagination=pagination,
processor_badge_css=processors.get_processor_badge_css(),
processor_badge_texts=processors.get_processor_badge_texts(), processor_badge_texts=processors.get_processor_badge_texts(),
processor_descriptions=processors.get_processor_descriptions(), processor_descriptions=processors.get_processor_descriptions(),
processor_badge_css=processors.get_processor_badge_css(), queue_size=update_q.qsize(),
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue], queued_uuids=update_q.get_queued_uuids(),
search_q=request.args.get('q', '').strip(), search_q=request.args.get('q', '').strip(),
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'), sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'), sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
@@ -62,7 +62,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
{{ render_nolabel_field(form.edit_and_watch_submit_button, title=_("Edit first then Watch") ) }} {{ render_nolabel_field(form.edit_and_watch_submit_button, title=_("Edit first then Watch") ) }}
</div> </div>
<div id="watch-group-tag"> <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") }} {{ render_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder=_("Watch group / tag"), class="transparent-field") }}
</div> </div>
<div id="quick-watch-processor-type"> <div id="quick-watch-processor-type">
{{ render_simple_field(form.processor) }} {{ render_simple_field(form.processor) }}
@@ -99,9 +99,14 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
data-confirm-message="{{ _('<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}" data-confirm-message="{{ _('<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}"
data-confirm-button="{{ _('Delete') }}"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Delete') }}</button> data-confirm-button="{{ _('Delete') }}"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Delete') }}</button>
</div> </div>
{%- if watches|length >= pagination.per_page -%}
{{ pagination.info }} <div id="stats_row">
{%- endif -%} <div class="left">{%- if watches|length >= pagination.per_page -%}{{ pagination.info }}{%- endif -%}</div>
<div class="right" >{{ _('Queued size') }}: <span id="queue-size-int">{{ queue_size }}</span></div>
</div>
{%- if search_q -%}<div id="search-result-info">{{ _('Searching') }} "<strong><i>{{search_q}}</i></strong>"</div>{%- endif -%} {%- if search_q -%}<div id="search-result-info">{{ _('Searching') }} "<strong><i>{{search_q}}</i></strong>"</div>{%- endif -%}
<div> <div>
<a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">{{ _('All') }}</a> <a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">{{ _('All') }}</a>
@@ -154,7 +159,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<tbody> <tbody>
{%- if not watches|length -%} {%- if not watches|length -%}
<tr> <tr>
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">{{ _('No website watches configured, please add a URL in the box above, or') }} <a href="{{ url_for('imports.import_page')}}" >{{ _('import a list') }}</a>.</td> <td colspan="{{ cols_required }}" style="text-wrap: wrap;">{{ _('No web page change detection watches configured, please add a URL in the box above, or') }} <a href="{{ url_for('imports.import_page')}}" >{{ _('import a list') }}</a>.</td>
</tr> </tr>
{%- endif -%} {%- endif -%}
@@ -1,3 +1,4 @@
import gc
import json import json
import os import os
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -185,20 +186,33 @@ class fetcher(Fetcher):
super().screenshot_step(step_n=step_n) super().screenshot_step(step_n=step_n)
screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format) screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format)
# Request GC immediately after screenshot to free memory
# Screenshots can be large and browser steps take many of them
await self.page.request_gc()
if self.browser_steps_screenshot_path is not None: if self.browser_steps_screenshot_path is not None:
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n)) destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
logger.debug(f"Saving step screenshot to {destination}") logger.debug(f"Saving step screenshot to {destination}")
with open(destination, 'wb') as f: with open(destination, 'wb') as f:
f.write(screenshot) f.write(screenshot)
# Clear local reference to allow screenshot bytes to be collected
del screenshot
gc.collect()
async def save_step_html(self, step_n): async def save_step_html(self, step_n):
super().save_step_html(step_n=step_n) super().save_step_html(step_n=step_n)
content = await self.page.content() content = await self.page.content()
# Request GC after getting page content
await self.page.request_gc()
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n)) destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n))
logger.debug(f"Saving step HTML to {destination}") logger.debug(f"Saving step HTML to {destination}")
with open(destination, 'w', encoding='utf-8') as f: with open(destination, 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
# Clear local reference
del content
gc.collect()
async def run(self, async def run(self,
fetch_favicon=True, fetch_favicon=True,
@@ -305,6 +319,12 @@ class fetcher(Fetcher):
if self.status_code != 200 and not ignore_status_codes: if self.status_code != 200 and not ignore_status_codes:
screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format) screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format)
# Cleanup before raising to prevent memory leak
await self.page.close()
await context.close()
await browser.close()
# Force garbage collection to release Playwright resources immediately
gc.collect()
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot) raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0: if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0:
@@ -313,48 +333,52 @@ class fetcher(Fetcher):
await browser.close() await browser.close()
raise EmptyReply(url=url, status_code=response.status) raise EmptyReply(url=url, status_code=response.status)
# Run Browser Steps here # Wrap remaining operations in try/finally to ensure cleanup
if self.browser_steps_get_valid_steps():
await self.iterate_browser_steps(start_url=url)
await self.page.wait_for_timeout(extra_wait * 1000)
now = time.time()
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
if current_include_filters is not None:
await self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
else:
await self.page.evaluate("var include_filters=''")
await self.page.request_gc()
# request_gc before and after evaluate to free up memory
# @todo browsersteps etc
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
"visualselector_xpath_selectors": visualselector_xpath_selectors,
"max_height": MAX_TOTAL_HEIGHT
})
await self.page.request_gc()
self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
await self.page.request_gc()
self.content = await self.page.content()
await self.page.request_gc()
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
# Bug 3 in Playwright screenshot handling
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
# JPEG is better here because the screenshots can be very very large
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
# acceptable screenshot quality here
try: try:
# Run Browser Steps here
if self.browser_steps_get_valid_steps():
await self.iterate_browser_steps(start_url=url)
await self.page.wait_for_timeout(extra_wait * 1000)
now = time.time()
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
if current_include_filters is not None:
await self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
else:
await self.page.evaluate("var include_filters=''")
await self.page.request_gc()
# request_gc before and after evaluate to free up memory
# @todo browsersteps etc
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
"visualselector_xpath_selectors": visualselector_xpath_selectors,
"max_height": MAX_TOTAL_HEIGHT
})
await self.page.request_gc()
self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
await self.page.request_gc()
self.content = await self.page.content()
await self.page.request_gc()
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
# Bug 3 in Playwright screenshot handling
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
# JPEG is better here because the screenshots can be very very large
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
# acceptable screenshot quality here
# The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage # The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage
self.screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format) self.screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format)
except ScreenshotUnavailable:
# Re-raise screenshot unavailable exceptions
raise
except Exception as e: except Exception as e:
# It's likely the screenshot was too long/big and something crashed # It's likely the screenshot was too long/big and something crashed
raise ScreenshotUnavailable(url=url, status_code=self.status_code) raise ScreenshotUnavailable(url=url, status_code=self.status_code)
@@ -389,6 +413,10 @@ class fetcher(Fetcher):
pass pass
browser = None browser = None
# Force Python GC to release Playwright resources immediately
# Playwright objects can have circular references that delay cleanup
gc.collect()
# Plugin registration for built-in fetcher # Plugin registration for built-in fetcher
class PlaywrightFetcherPlugin: class PlaywrightFetcherPlugin:
@@ -15,7 +15,7 @@ class fetcher(Fetcher):
proxy_url = None proxy_url = None
# Capability flags # Capability flags
supports_browser_steps = True supports_browser_steps = False
supports_screenshots = True supports_screenshots = True
supports_xpath_element_data = True supports_xpath_element_data = True
@@ -156,6 +156,19 @@ class fetcher(Fetcher):
from PIL import Image from PIL import Image
import io import io
img = Image.open(io.BytesIO(screenshot_png)) img = Image.open(io.BytesIO(screenshot_png))
# Convert to RGB if needed (JPEG doesn't support transparency)
# Always convert non-RGB modes to RGB to ensure JPEG compatibility
if img.mode in ('RGBA', 'LA', 'P', 'PA'):
# Handle transparency by compositing onto white background
if img.mode == 'P':
img = img.convert('RGBA')
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode in ('RGBA', 'LA', 'PA'):
background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask
img = background
elif img.mode != 'RGB':
# For other modes, direct conversion
img = img.convert('RGB')
jpeg_buffer = io.BytesIO() jpeg_buffer = io.BytesIO()
img.save(jpeg_buffer, format='JPEG', quality=int(os.getenv("SCREENSHOT_QUALITY", 72))) img.save(jpeg_buffer, format='JPEG', quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
self.screenshot = jpeg_buffer.getvalue() self.screenshot = jpeg_buffer.getvalue()
+6 -4
View File
@@ -57,14 +57,15 @@ class SignalPriorityQueue(queue.PriorityQueue):
def put(self, item, block=True, timeout=None): def put(self, item, block=True, timeout=None):
# Call the parent's put method first # Call the parent's put method first
super().put(item, block, timeout) super().put(item, block, timeout)
# After putting the item in the queue, check if it has a UUID and emit signal # After putting the item in the queue, check if it has a UUID and emit signal
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item: if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
uuid = item.item['uuid'] uuid = item.item['uuid']
# Get the signal and send it if it exists # Get the signal and send it if it exists
watch_check_update = signal('watch_check_update') watch_check_update = signal('watch_check_update')
if watch_check_update: if watch_check_update:
# Send the watch_uuid parameter # NOTE: This would block other workers from .put/.get while this signal sends
# Signal handlers may iterate the queue/datastore while holding locks
watch_check_update.send(watch_uuid=uuid) watch_check_update.send(watch_uuid=uuid)
# Send queue_length signal with current queue size # Send queue_length signal with current queue size
@@ -312,14 +313,15 @@ class AsyncSignalPriorityQueue(asyncio.PriorityQueue):
async def put(self, item): async def put(self, item):
# Call the parent's put method first # Call the parent's put method first
await super().put(item) await super().put(item)
# After putting the item in the queue, check if it has a UUID and emit signal # After putting the item in the queue, check if it has a UUID and emit signal
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item: if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
uuid = item.item['uuid'] uuid = item.item['uuid']
# Get the signal and send it if it exists # Get the signal and send it if it exists
watch_check_update = signal('watch_check_update') watch_check_update = signal('watch_check_update')
if watch_check_update: if watch_check_update:
# Send the watch_uuid parameter # NOTE: This would block other workers from .put/.get while this signal sends
# Signal handlers may iterate the queue/datastore while holding locks
watch_check_update.send(watch_uuid=uuid) watch_check_update.send(watch_uuid=uuid)
# Send queue_length signal with current queue size # Send queue_length signal with current queue size
+49 -22
View File
@@ -9,6 +9,7 @@ import threading
import time import time
import timeago import timeago
from blinker import signal from blinker import signal
from pathlib import Path
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from threading import Event from threading import Event
@@ -26,9 +27,7 @@ from flask import (
session, session,
url_for, url_for,
) )
from urllib.parse import urlparse
from flask_compress import Compress as FlaskCompress from flask_compress import Compress as FlaskCompress
from flask_login import current_user
from flask_restful import abort, Api from flask_restful import abort, Api
from flask_cors import CORS from flask_cors import CORS
@@ -45,6 +44,7 @@ from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, Watch
from changedetectionio.api.Search import Search from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule from .time_handler import is_within_schedule
from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
datastore = None datastore = None
@@ -55,7 +55,7 @@ extra_stylesheets = []
# Use bulletproof janus-based queues for sync/async reliability # Use bulletproof janus-based queues for sync/async reliability
update_q = RecheckPriorityQueue() update_q = RecheckPriorityQueue()
notification_q = NotificationQueue() notification_q = NotificationQueue()
MAX_QUEUE_SIZE = 2000 MAX_QUEUE_SIZE = 5000
app = Flask(__name__, app = Flask(__name__,
static_url_path="", static_url_path="",
@@ -84,6 +84,10 @@ app.config['NEW_VERSION_AVAILABLE'] = False
if os.getenv('FLASK_SERVER_NAME'): if os.getenv('FLASK_SERVER_NAME'):
app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME') app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME')
# Babel/i18n configuration
app.config['BABEL_TRANSLATION_DIRECTORIES'] = str(Path(__file__).parent / 'translations')
app.config['BABEL_DEFAULT_LOCALE'] = 'en_GB'
#app.config["EXPLAIN_TEMPLATE_LOADING"] = True #app.config["EXPLAIN_TEMPLATE_LOADING"] = True
@@ -370,6 +374,9 @@ def changedetection_app(config=None, datastore_o=None):
global datastore, socketio_server global datastore, socketio_server
datastore = datastore_o datastore = datastore_o
# Set datastore reference in notification queue for all_muted checking
notification_q.set_datastore(datastore)
# Import and create a wrapper for is_safe_url that has access to app # Import and create a wrapper for is_safe_url that has access to app
from changedetectionio.is_safe_url import is_safe_url as _is_safe_url from changedetectionio.is_safe_url import is_safe_url as _is_safe_url
@@ -395,13 +402,9 @@ def changedetection_app(config=None, datastore_o=None):
def get_locale(): def get_locale():
# 1. Try to get locale from session (user explicitly selected) # 1. Try to get locale from session (user explicitly selected)
if 'locale' in session: if 'locale' in session:
locale = session['locale'] return session['locale']
logger.trace(f"DEBUG: get_locale() returning from session: {locale}")
return locale
# 2. Fall back to Accept-Language header # 2. Fall back to Accept-Language header
locale = request.accept_languages.best_match(language_codes) return request.accept_languages.best_match(language_codes)
logger.trace(f"DEBUG: get_locale() returning from Accept-Language: {locale}")
return locale
# Initialize Babel with locale selector # Initialize Babel with locale selector
babel = Babel(app, locale_selector=get_locale) babel = Babel(app, locale_selector=get_locale)
@@ -518,9 +521,20 @@ def changedetection_app(config=None, datastore_o=None):
@app.route('/set-language/<locale>') @app.route('/set-language/<locale>')
def set_language(locale): def set_language(locale):
"""Set the user's preferred language in the session""" """Set the user's preferred language in the session"""
if not request.cookies:
logger.error("Cannot set language without session cookie")
flash("Cannot set language without session cookie", 'error')
return redirect(url_for('watchlist.index'))
# Validate the locale against available languages # Validate the locale against available languages
if locale in language_codes: if locale in language_codes:
session['locale'] = locale session['locale'] = locale
# CRITICAL: Flask-Babel caches the locale in the request context (ctx.babel_locale)
# We must refresh to clear this cache so the new locale takes effect immediately
# This is especially important for tests where multiple requests happen rapidly
from flask_babel import refresh
refresh()
else: else:
logger.error(f"Invalid locale {locale}, available: {language_codes}") logger.error(f"Invalid locale {locale}, available: {language_codes}")
@@ -863,13 +877,13 @@ def changedetection_app(config=None, datastore_o=None):
worker_handler.start_workers(n_workers, update_q, notification_q, app, datastore) worker_handler.start_workers(n_workers, update_q, notification_q, app, datastore)
# @todo handle ctrl break # @todo handle ctrl break
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start() ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks, daemon=True, name="TickerThread-ScheduleChecker").start()
threading.Thread(target=notification_runner).start() threading.Thread(target=notification_runner, daemon=True, name="NotificationRunner").start()
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
# Check for new release version, but not when running in test/build or pytest # Check for new release version, but not when running in test/build or pytest
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest: if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:
threading.Thread(target=check_for_new_version).start() threading.Thread(target=check_for_new_version, daemon=True, name="VersionChecker").start()
# Return the Flask app - the Socket.IO will be attached to it but initialized separately # Return the Flask app - the Socket.IO will be attached to it but initialized separately
# This avoids circular dependencies # This avoids circular dependencies
@@ -967,6 +981,10 @@ def ticker_thread_check_time_launch_checks():
logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}") logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}")
# Workers are now started during app initialization, not here # Workers are now started during app initialization, not here
WAIT_TIME_BETWEEN_LOOP = 1.0 if not IN_PYTEST else 0.01
if IN_PYTEST:
# The time between loops should be less than the first .sleep/wait in def wait_for_all_checks() of tests/util.py
logger.warning(f"Looks like we're in PYTEST! Setting time between searching for items to add to the queue to {WAIT_TIME_BETWEEN_LOOP}s")
while not app.config.exit.is_set(): while not app.config.exit.is_set():
@@ -984,12 +1002,20 @@ def ticker_thread_check_time_launch_checks():
if health_result['status'] != 'healthy': if health_result['status'] != 'healthy':
logger.warning(f"Worker health check: {health_result['message']}") logger.warning(f"Worker health check: {health_result['message']}")
last_health_check = now last_health_check = now
# Check if all checks are paused
if datastore.data['settings']['application'].get('all_paused', False):
app.config.exit.wait(1)
continue
# Get a list of watches by UUID that are currently fetching data # Get a list of watches by UUID that are currently fetching data
running_uuids = worker_handler.get_running_uuids() running_uuids = worker_handler.get_running_uuids()
# Build set of queued UUIDs once for O(1) lookup instead of O(n) per watch
queued_uuids = {q_item.item['uuid'] for q_item in update_q.queue}
# Re #232 - Deepcopy the data incase it changes while we're iterating through it all # Re #232 - Deepcopy the data incase it changes while we're iterating through it all
watch_uuid_list = [] watch_uuid_list = []
while True: while True:
@@ -1006,16 +1032,17 @@ def ticker_thread_check_time_launch_checks():
else: else:
break break
# Re #438 - Don't place more watches in the queue to be checked if the queue is already large
while update_q.qsize() >= 2000:
logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items")
app.config.exit.wait(10.0)
recheck_time_system_seconds = int(datastore.threshold_seconds) recheck_time_system_seconds = int(datastore.threshold_seconds)
# Check for watches outside of the time threshold to put in the thread queue. # Check for watches outside of the time threshold to put in the thread queue.
for uuid in watch_uuid_list: for watch_index, uuid in enumerate(watch_uuid_list):
# Re #438 - Check queue size every 100 watches for CPU efficiency (not every watch)
if watch_index % 100 == 0:
current_queue_size = update_q.qsize()
if current_queue_size >= MAX_QUEUE_SIZE:
logger.debug(f"Queue size limit reached ({current_queue_size}/{MAX_QUEUE_SIZE}), stopping scheduler this iteration.")
break
now = time.time() now = time.time()
watch = datastore.data['watching'].get(uuid) watch = datastore.data['watching'].get(uuid)
if not watch: if not watch:
@@ -1065,7 +1092,7 @@ def ticker_thread_check_time_launch_checks():
seconds_since_last_recheck = now - watch['last_checked'] seconds_since_last_recheck = now - watch['last_checked']
if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds: if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds:
if not uuid in running_uuids and uuid not in [q_uuid.item['uuid'] for q_uuid in update_q.queue]: if not uuid in running_uuids and uuid not in queued_uuids:
# Proxies can be set to have a limit on seconds between which they can be called # Proxies can be set to have a limit on seconds between which they can be called
watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid) watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid)
@@ -1108,4 +1135,4 @@ def ticker_thread_check_time_launch_checks():
watch.jitter_seconds = 0 watch.jitter_seconds = 0
# Should be low so we can break this out in testing # Should be low so we can break this out in testing
app.config.exit.wait(1) app.config.exit.wait(WAIT_TIME_BETWEEN_LOOP)
+5 -4
View File
@@ -727,8 +727,8 @@ class ValidateStartsWithRegex(object):
raise ValidationError(self.message or _l("Invalid value.")) raise ValidationError(self.message or _l("Invalid value."))
class quickWatchForm(Form): class quickWatchForm(Form):
url = fields.URLField('URL', validators=[validateURL()]) url = fields.URLField(_l('URL'), validators=[validateURL()])
tags = StringTagUUID('Group tag', [validators.Optional()]) tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"}) watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff") processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff")
edit_and_watch_submit_button = SubmitField(_l('Edit > Watch'), render_kw={"class": "pure-button pure-button-primary"}) edit_and_watch_submit_button = SubmitField(_l('Edit > Watch'), render_kw={"class": "pure-button pure-button-primary"})
@@ -786,6 +786,7 @@ class processor_text_json_diff_form(commonSettingsForm):
time_between_check = EnhancedFormField( time_between_check = EnhancedFormField(
TimeBetweenCheckForm, TimeBetweenCheckForm,
label=_l('Time Between Check'),
conditional_field='time_between_check_use_default', conditional_field='time_between_check_use_default',
conditional_message=REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT, conditional_message=REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT,
conditional_test_function=validate_time_between_check_has_values conditional_test_function=validate_time_between_check_has_values
@@ -947,7 +948,7 @@ class DefaultUAInputForm(Form):
# datastore.data['settings']['requests'].. # datastore.data['settings']['requests']..
class globalSettingsRequestForm(Form): class globalSettingsRequestForm(Form):
time_between_check = RequiredFormField(TimeBetweenCheckForm) time_between_check = RequiredFormField(TimeBetweenCheckForm, label=_l('Time Between Check'))
time_schedule_limit = FormField(ScheduleLimitForm) time_schedule_limit = FormField(ScheduleLimitForm)
proxy = RadioField(_l('Default proxy')) proxy = RadioField(_l('Default proxy'))
jitter_seconds = IntegerField(_l('Random jitter seconds ± check'), jitter_seconds = IntegerField(_l('Random jitter seconds ± check'),
@@ -1007,7 +1008,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
render_kw={"placeholder": "0.1", "style": "width: 8em;"} render_kw={"placeholder": "0.1", "style": "width: 8em;"}
) )
password = SaltyPasswordField() password = SaltyPasswordField(_l('Password'))
pager_size = IntegerField(_l('Pager size'), pager_size = IntegerField(_l('Pager size'),
render_kw={"style": "width: 5em;"}, render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0, validators=[validators.NumberRange(min=0,
+12
View File
@@ -539,6 +539,18 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False, timeout=10) -> str: def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False, timeout=10) -> str:
"""
Convert HTML content to plain text using inscriptis.
Thread-Safety: This function uses inscriptis.get_text() which internally calls
lxml.html.fromstring() with the default parser. Testing with 50 concurrent threads
confirms this approach is thread-safe and produces deterministic output.
Alternative Approach Rejected: An explicit HTMLParser instance (thread-local or fresh)
would also be thread-safe, but was found to break change detection logic in subtle ways
(test_check_basic_change_detection_functionality). The default parser provides correct
and reliable behavior.
"""
from inscriptis import get_text from inscriptis import get_text
from inscriptis.model.config import ParserConfig from inscriptis.model.config import ParserConfig
+4 -1
View File
@@ -29,6 +29,9 @@ def get_timeago_locale(flask_locale):
""" """
locale_map = { locale_map = {
'zh': 'zh_CN', # Chinese Simplified 'zh': 'zh_CN', # Chinese Simplified
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
'zh_TW': 'zh_TW', # Chinese Traditional (timeago uses zh_TW)
'zh_Hant_TW': 'zh_TW', # Flask-Babel normalizes zh_TW to zh_Hant_TW, map back to timeago's zh_TW
'pt': 'pt_PT', # Portuguese (Portugal) 'pt': 'pt_PT', # Portuguese (Portugal)
'sv': 'sv_SE', # Swedish 'sv': 'sv_SE', # Swedish
'no': 'nb_NO', # Norwegian Bokmål 'no': 'nb_NO', # Norwegian Bokmål
@@ -53,7 +56,7 @@ LANGUAGE_DATA = {
'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'}, 'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},
'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'}, 'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},
'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'}, 'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},
'zh_TW': {'flag': 'fi fi-tw fis', 'name': '繁體中文'}, 'zh_Hant_TW': {'flag': 'fi fi-tw fis', 'name': '繁體中文'},
'ru': {'flag': 'fi fi-ru fis', 'name': 'Русский'}, 'ru': {'flag': 'fi fi-ru fis', 'name': 'Русский'},
'pl': {'flag': 'fi fi-pl fis', 'name': 'Polski'}, 'pl': {'flag': 'fi fi-pl fis', 'name': 'Polski'},
'nl': {'flag': 'fi fi-nl fis', 'name': 'Nederlands'}, 'nl': {'flag': 'fi fi-nl fis', 'name': 'Nederlands'},
+2
View File
@@ -37,6 +37,8 @@ class model(dict):
}, },
'application': { 'application': {
# Custom notification content # Custom notification content
'all_paused': False,
'all_muted': False,
'api_access_token_enabled': True, 'api_access_token_enabled': True,
'base_url' : None, 'base_url' : None,
'empty_pages_are_a_change': False, 'empty_pages_are_a_change': False,
+33 -110
View File
@@ -10,54 +10,23 @@ from pathlib import Path
from loguru import logger from loguru import logger
from .. import jinja2_custom as safe_jinja from .. import jinja2_custom as safe_jinja
from ..diff import ADDED_PLACEMARKER_OPEN
from ..html_tools import TRANSLATE_WHITESPACE_TABLE from ..html_tools import TRANSLATE_WHITESPACE_TABLE
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
def _brotli_compress_worker(conn, filepath, mode=None): minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
def _brotli_save(contents, filepath, mode=None, fallback_uncompressed=False):
""" """
Worker function to compress data with brotli in a separate process. Save compressed data using native brotli.
This isolates memory - when process exits, OS reclaims all memory. Testing shows no memory leak when using gc.collect() after compression.
Args:
conn: multiprocessing.Pipe connection to receive data
filepath: destination file path
mode: brotli compression mode (e.g., brotli.MODE_TEXT)
"""
import brotli
try:
# Receive data from parent process via pipe (avoids pickle overhead)
contents = conn.recv()
if mode is not None:
compressed_data = brotli.compress(contents, mode=mode)
else:
compressed_data = brotli.compress(contents)
with open(filepath, 'wb') as f:
f.write(compressed_data)
# Send success status back
conn.send(True)
# No need for explicit cleanup - process exit frees all memory
except Exception as e:
logger.error(f"Brotli compression worker failed: {e}")
conn.send(False)
finally:
conn.close()
def _brotli_subprocess_save(contents, filepath, mode=None, timeout=30, fallback_uncompressed=False):
"""
Save compressed data using subprocess to isolate memory.
Uses Pipe to avoid pickle overhead for large data.
Args: Args:
contents: data to compress (str or bytes) contents: data to compress (str or bytes)
filepath: destination file path filepath: destination file path
mode: brotli compression mode (e.g., brotli.MODE_TEXT) mode: brotli compression mode (e.g., brotli.MODE_TEXT)
timeout: subprocess timeout in seconds
fallback_uncompressed: if True, save uncompressed on failure; if False, raise exception fallback_uncompressed: if True, save uncompressed on failure; if False, raise exception
Returns: Returns:
@@ -67,88 +36,43 @@ def _brotli_subprocess_save(contents, filepath, mode=None, timeout=30, fallback_
Exception: if compression fails and fallback_uncompressed is False Exception: if compression fails and fallback_uncompressed is False
""" """
import brotli import brotli
import multiprocessing import gc
import sys
# Ensure contents are bytes # Ensure contents are bytes
if isinstance(contents, str): if isinstance(contents, str):
contents = contents.encode('utf-8') contents = contents.encode('utf-8')
# Use explicit spawn context for thread safety (avoids fork() with multi-threaded parent)
# Always use spawn - consistent behavior in tests and production
ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
# Run compression in subprocess using spawn (not fork)
proc = ctx.Process(target=_brotli_compress_worker, args=(child_conn, filepath, mode))
# Windows-safe: Set daemon=False explicitly to avoid issues with process cleanup
proc.daemon = False
proc.start()
try: try:
# Send data to subprocess via pipe (avoids pickle) logger.debug(f"Starting brotli compression of {len(contents)} bytes.")
parent_conn.send(contents)
# Wait for result with timeout if mode is not None:
if parent_conn.poll(timeout): compressed_data = brotli.compress(contents, mode=mode)
success = parent_conn.recv()
else: else:
success = False compressed_data = brotli.compress(contents)
logger.warning(f"Brotli compression subprocess timed out after {timeout}s")
# Graceful termination with platform-aware cleanup
try:
proc.terminate()
except Exception as term_error:
logger.debug(f"Process termination issue (may be normal on Windows): {term_error}")
parent_conn.close() with open(filepath, 'wb') as f:
proc.join(timeout=5) f.write(compressed_data)
# Force kill if still alive after graceful termination logger.debug(f"Finished brotli compression - From {len(contents)} to {len(compressed_data)} bytes.")
if proc.is_alive():
try:
if sys.platform == 'win32':
# Windows: use kill() which is more forceful
proc.kill()
else:
# Unix: terminate() already sent SIGTERM, now try SIGKILL
proc.kill()
proc.join(timeout=2)
except Exception as kill_error:
logger.warning(f"Failed to kill brotli compression process: {kill_error}")
# Check if file was created successfully # Force garbage collection to prevent memory buildup
if success and os.path.exists(filepath): gc.collect()
return filepath
return filepath
except Exception as e: except Exception as e:
logger.error(f"Brotli compression error: {e}") logger.error(f"Brotli compression error: {e}")
try:
parent_conn.close()
except:
pass
try:
proc.terminate()
proc.join(timeout=2)
except:
pass
# Compression failed # Compression failed
if fallback_uncompressed: if fallback_uncompressed:
logger.warning(f"Brotli compression failed for {filepath}, saving uncompressed") logger.warning(f"Brotli compression failed for {filepath}, saving uncompressed")
fallback_path = filepath.replace('.br', '') fallback_path = filepath.replace('.br', '')
with open(fallback_path, 'wb') as f: with open(fallback_path, 'wb') as f:
f.write(contents) f.write(contents)
return fallback_path return fallback_path
else: else:
raise Exception(f"Brotli compression subprocess failed for {filepath}") raise Exception(f"Brotli compression failed for {filepath}: {e}")
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
class model(watch_base): class model(watch_base):
__newest_history_key = None __newest_history_key = None
@@ -492,7 +416,6 @@ class model(watch_base):
self.ensure_data_dir_exists() self.ensure_data_dir_exists()
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False')) skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
# Binary data - detect file type and save without compression # Binary data - detect file type and save without compression
@@ -516,7 +439,7 @@ class model(watch_base):
# Text data - use brotli compression if enabled and above threshold # Text data - use brotli compression if enabled and above threshold
else: else:
if not skip_brotli and len(contents) > threshold: if not skip_brotli and len(contents) > BROTLI_COMPRESS_SIZE_THRESHOLD:
# Compressed text # Compressed text
import brotli import brotli
snapshot_fname = f"{snapshot_id}.txt.br" snapshot_fname = f"{snapshot_id}.txt.br"
@@ -524,7 +447,7 @@ class model(watch_base):
if not os.path.exists(dest): if not os.path.exists(dest):
try: try:
actual_dest = _brotli_subprocess_save(contents, dest, mode=brotli.MODE_TEXT, fallback_uncompressed=True) actual_dest = _brotli_save(contents, dest, mode=brotli.MODE_TEXT, fallback_uncompressed=True)
if actual_dest != dest: if actual_dest != dest:
snapshot_fname = os.path.basename(actual_dest) snapshot_fname = os.path.basename(actual_dest)
except Exception as e: except Exception as e:
@@ -950,13 +873,13 @@ class model(watch_base):
def save_last_text_fetched_before_filters(self, contents): def save_last_text_fetched_before_filters(self, contents):
import brotli import brotli
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br') filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
_brotli_subprocess_save(contents, filepath, mode=brotli.MODE_TEXT, fallback_uncompressed=False) _brotli_save(contents, filepath, mode=brotli.MODE_TEXT, fallback_uncompressed=False)
def save_last_fetched_html(self, timestamp, contents): def save_last_fetched_html(self, timestamp, contents):
self.ensure_data_dir_exists() self.ensure_data_dir_exists()
snapshot_fname = f"{timestamp}.html.br" snapshot_fname = f"{timestamp}.html.br"
filepath = os.path.join(self.watch_data_dir, snapshot_fname) filepath = os.path.join(self.watch_data_dir, snapshot_fname)
_brotli_subprocess_save(contents, filepath, mode=None, fallback_uncompressed=True) _brotli_save(contents, filepath, mode=None, fallback_uncompressed=True)
self._prune_last_fetched_html_snapshots() self._prune_last_fetched_html_snapshots()
def get_fetched_html(self, timestamp): def get_fetched_html(self, timestamp):
@@ -130,7 +130,7 @@ def get_asset(asset_name, watch, datastore, request):
except Exception as e: except Exception as e:
exception_container[0] = e exception_container[0] = e
thread = threading.Thread(target=thread_target) thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-Asset")
thread.start() thread.start()
thread.join(timeout=60) thread.join(timeout=60)
@@ -284,7 +284,7 @@ def _draw_bounding_box_if_configured(img_bytes, watch, datastore):
except Exception as e: except Exception as e:
exception_container[0] = e exception_container[0] = e
thread = threading.Thread(target=thread_target) thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-BoundingBox")
thread.start() thread.start()
thread.join(timeout=15) thread.join(timeout=15)
@@ -393,7 +393,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
except Exception as e: except Exception as e:
exception_container[0] = e exception_container[0] = e
thread = threading.Thread(target=thread_target) thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-ChangePercentage")
thread.start() thread.start()
thread.join(timeout=60) thread.join(timeout=60)
@@ -13,14 +13,9 @@ Research: https://github.com/libvips/pyvips/issues/234
import multiprocessing import multiprocessing
# CRITICAL: Use 'spawn' instead of 'fork' to avoid inheriting parent's # CRITICAL: Use 'spawn' context instead of 'fork' to avoid inheriting parent's
# LibVIPS threading state which can cause hangs in gaussblur operations # LibVIPS threading state which can cause hangs in gaussblur operations
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods # https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
try:
multiprocessing.set_start_method('spawn', force=False)
except RuntimeError:
# Already set, ignore
pass
def _worker_generate_diff(conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height): def _worker_generate_diff(conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height):
@@ -95,9 +90,10 @@ def generate_diff_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
Returns: Returns:
bytes: JPEG diff image or None on failure bytes: JPEG diff image or None on failure
""" """
parent_conn, child_conn = multiprocessing.Pipe() ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
p = multiprocessing.Process( p = ctx.Process(
target=_worker_generate_diff, target=_worker_generate_diff,
args=(child_conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height) args=(child_conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height)
) )
@@ -140,7 +136,8 @@ def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, threshold
Returns: Returns:
float: Change percentage float: Change percentage
""" """
parent_conn, child_conn = multiprocessing.Pipe() ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
def _worker_calculate(conn): def _worker_calculate(conn):
try: try:
@@ -185,7 +182,7 @@ def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, threshold
finally: finally:
conn.close() conn.close()
p = multiprocessing.Process(target=_worker_calculate, args=(child_conn,)) p = ctx.Process(target=_worker_calculate, args=(child_conn,))
p.start() p.start()
result = 0.0 result = 0.0
@@ -233,7 +230,8 @@ def compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
tuple: (changed_detected, change_percentage) tuple: (changed_detected, change_percentage)
""" """
print(f"[Parent] Starting compare_images_isolated subprocess", flush=True) print(f"[Parent] Starting compare_images_isolated subprocess", flush=True)
parent_conn, child_conn = multiprocessing.Pipe() ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
def _worker_compare(conn): def _worker_compare(conn):
try: try:
@@ -301,7 +299,7 @@ def compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
finally: finally:
conn.close() conn.close()
p = multiprocessing.Process(target=_worker_compare, args=(child_conn,)) p = ctx.Process(target=_worker_compare, args=(child_conn,))
print(f"[Parent] Starting subprocess (pid will be assigned)", flush=True) print(f"[Parent] Starting subprocess (pid will be assigned)", flush=True)
p.start() p.start()
print(f"[Parent] Subprocess started (pid={p.pid}), waiting for result (30s timeout)", flush=True) print(f"[Parent] Subprocess started (pid={p.pid}), waiting for result (30s timeout)", flush=True)
@@ -204,7 +204,7 @@ class perform_site_check(difference_detection_processor):
except Exception as e: except Exception as e:
exception_container[0] = e exception_container[0] = e
thread = threading.Thread(target=thread_target) thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-Processor")
thread.start() thread.start()
thread.join(timeout=60) thread.join(timeout=60)
+31 -3
View File
@@ -86,6 +86,7 @@ class RecheckPriorityQueue:
def get(self, block: bool = True, timeout: Optional[float] = None): def get(self, block: bool = True, timeout: Optional[float] = None):
"""Thread-safe sync get with priority ordering""" """Thread-safe sync get with priority ordering"""
import queue
try: try:
# Wait for notification # Wait for notification
self.sync_q.get(block=block, timeout=timeout) self.sync_q.get(block=block, timeout=timeout)
@@ -103,8 +104,11 @@ class RecheckPriorityQueue:
logger.debug(f"Successfully retrieved item: {self._get_item_uuid(item)}") logger.debug(f"Successfully retrieved item: {self._get_item_uuid(item)}")
return item return item
except queue.Empty:
# Queue is empty with timeout - expected behavior, re-raise without logging
raise
except Exception as e: except Exception as e:
logger.critical(f"CRITICAL: Failed to get item from queue: {str(e)}") # Re-raise without logging - caller (worker) will handle and log appropriately
raise raise
# ASYNC INTERFACE (for workers) # ASYNC INTERFACE (for workers)
@@ -172,7 +176,16 @@ class RecheckPriorityQueue:
def empty(self) -> bool: def empty(self) -> bool:
"""Check if queue is empty""" """Check if queue is empty"""
return self.qsize() == 0 return self.qsize() == 0
def get_queued_uuids(self) -> list:
"""Get list of all queued UUIDs efficiently with single lock"""
try:
with self._lock:
return [item.item['uuid'] for item in self._priority_items if hasattr(item, 'item') and 'uuid' in item.item]
except Exception as e:
logger.critical(f"CRITICAL: Failed to get queued UUIDs: {str(e)}")
return []
def close(self): def close(self):
"""Close the janus queue""" """Close the janus queue"""
try: try:
@@ -348,21 +361,31 @@ class NotificationQueue:
Simple wrapper around janus with bulletproof error handling. Simple wrapper around janus with bulletproof error handling.
""" """
def __init__(self, maxsize: int = 0): def __init__(self, maxsize: int = 0, datastore=None):
try: try:
self._janus_queue = janus.Queue(maxsize=maxsize) self._janus_queue = janus.Queue(maxsize=maxsize)
# BOTH interfaces required - see class docstring for why # BOTH interfaces required - see class docstring for why
self.sync_q = self._janus_queue.sync_q # Flask routes, threads self.sync_q = self._janus_queue.sync_q # Flask routes, threads
self.async_q = self._janus_queue.async_q # Async workers self.async_q = self._janus_queue.async_q # Async workers
self.notification_event_signal = signal('notification_event') self.notification_event_signal = signal('notification_event')
self.datastore = datastore # For checking all_muted setting
logger.debug("NotificationQueue initialized successfully") logger.debug("NotificationQueue initialized successfully")
except Exception as e: except Exception as e:
logger.critical(f"CRITICAL: Failed to initialize NotificationQueue: {str(e)}") logger.critical(f"CRITICAL: Failed to initialize NotificationQueue: {str(e)}")
raise raise
def set_datastore(self, datastore):
"""Set datastore reference after initialization (for circular dependency handling)"""
self.datastore = datastore
def put(self, item: Dict[str, Any], block: bool = True, timeout: Optional[float] = None): def put(self, item: Dict[str, Any], block: bool = True, timeout: Optional[float] = None):
"""Thread-safe sync put with signal emission""" """Thread-safe sync put with signal emission"""
try: try:
# Check if all notifications are muted
if self.datastore and self.datastore.data['settings']['application'].get('all_muted', False):
logger.debug(f"Notification blocked - all notifications are muted: {item.get('uuid', 'unknown')}")
return False
self.sync_q.put(item, block=block, timeout=timeout) self.sync_q.put(item, block=block, timeout=timeout)
self._emit_notification_signal(item) self._emit_notification_signal(item)
logger.debug(f"Successfully queued notification: {item.get('uuid', 'unknown')}") logger.debug(f"Successfully queued notification: {item.get('uuid', 'unknown')}")
@@ -374,6 +397,11 @@ class NotificationQueue:
async def async_put(self, item: Dict[str, Any]): async def async_put(self, item: Dict[str, Any]):
"""Pure async put with signal emission""" """Pure async put with signal emission"""
try: try:
# Check if all notifications are muted
if self.datastore and self.datastore.data['settings']['application'].get('all_muted', False):
logger.debug(f"Notification blocked - all notifications are muted: {item.get('uuid', 'unknown')}")
return False
await self.async_q.put(item) await self.async_q.put(item)
self._emit_notification_signal(item) self._emit_notification_signal(item)
logger.debug(f"Successfully async queued notification: {item.get('uuid', 'unknown')}") logger.debug(f"Successfully async queued notification: {item.get('uuid', 'unknown')}")
+24 -16
View File
@@ -150,11 +150,8 @@ def handle_watch_update(socketio, **kwargs):
# Get list of watches that are currently running # Get list of watches that are currently running
running_uuids = worker_handler.get_running_uuids() running_uuids = worker_handler.get_running_uuids()
# Get list of watches in the queue # Get list of watches in the queue (efficient single-lock method)
queue_list = [] queue_list = update_q.get_queued_uuids()
for q_item in update_q.queue:
if hasattr(q_item, 'item') and 'uuid' in q_item.item:
queue_list.append(q_item.item['uuid'])
# Get the error texts from the watch # Get the error texts from the watch
error_texts = watch.compile_error_texts() error_texts = watch.compile_error_texts()
@@ -254,21 +251,32 @@ def init_socketio(app, datastore):
from changedetectionio import queuedWatchMetaData from changedetectionio import queuedWatchMetaData
from changedetectionio import worker_handler from changedetectionio import worker_handler
from changedetectionio.flask_app import update_q, watch_check_update from changedetectionio.flask_app import update_q, watch_check_update
import threading
logger.trace(f"Got checkbox operations event: {data}") logger.trace(f"Got checkbox operations event: {data}")
datastore = socketio.datastore datastore = socketio.datastore
_handle_operations( def run_operation():
op=data.get('op'), """Run the operation in a background thread to avoid blocking the socket.io event loop"""
uuids=data.get('uuids'), try:
datastore=datastore, _handle_operations(
extra_data=data.get('extra_data'), op=data.get('op'),
worker_handler=worker_handler, uuids=data.get('uuids'),
update_q=update_q, datastore=datastore,
queuedWatchMetaData=queuedWatchMetaData, extra_data=data.get('extra_data'),
watch_check_update=watch_check_update, worker_handler=worker_handler,
emit_flash=False update_q=update_q,
) queuedWatchMetaData=queuedWatchMetaData,
watch_check_update=watch_check_update,
emit_flash=False
)
except Exception as e:
logger.error(f"Error in checkbox operation thread: {e}")
# Start operation in a disposable daemon thread
thread = threading.Thread(target=run_operation, daemon=True, name=f"checkbox-op-{data.get('op')}")
thread.start()
@socketio.on('connect') @socketio.on('connect')
def handle_connect(): def handle_connect():
+7 -12
View File
@@ -82,27 +82,22 @@ echo "RUNNING WITH BASE_URL SET"
# Re #65 - Ability to include a link back to the installation, in the notification. # Re #65 - Ability to include a link back to the installation, in the notification.
export BASE_URL="https://really-unique-domain.io" export BASE_URL="https://really-unique-domain.io"
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py
# Re-run with HIDE_REFERER set - could affect login # Re-run with HIDE_REFERER set - could affect login
export HIDE_REFERER=True export HIDE_REFERER=True
pytest -vv -s --maxfail=1 tests/test_access_control.py REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py tests/test_access_control.py
# Re-run a few tests that will trigger brotli based storage # Re-run a few tests that will trigger brotli based storage
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 # And again with brotli+screenshot attachment
pytest -vv -s --maxfail=1 tests/test_access_control.py SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 --dist=load tests/test_backend.py tests/test_rss.py tests/test_unique_lines.py tests/test_notification.py tests/test_access_control.py
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
pytest -vv -s --maxfail=1 tests/test_backend.py
pytest -vv -s --maxfail=1 tests/test_rss.py
pytest -vv -s --maxfail=1 tests/test_unique_lines.py
# Try high concurrency # Try high concurrency
FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l FETCH_WORKERS=50 pytest tests/test_history_consistency.py -vv -l -s
# Check file:// will pickup a file when enabled # Check file:// will pickup a file when enabled
echo "Hello world" > /tmp/test-file.txt echo "Hello world" > /tmp/test-file.txt
ALLOW_FILE_URI=yes pytest -vv -s tests/test_security.py ALLOW_FILE_URI=yes pytest -vv -s tests/test_security.py
# Run it again so that brotli kicks in
TEST_WITH_BROTLI=1 SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=100 FETCH_WORKERS=20 pytest tests/test_history_consistency.py -vv -l -s
+5 -1
View File
@@ -76,7 +76,7 @@ $(document).ready(function () {
// Cache DOM elements for performance // Cache DOM elements for performance
const queueBubble = document.getElementById('queue-bubble'); const queueBubble = document.getElementById('queue-bubble');
const queueSizePagerInfoText = document.getElementById('queue-size-int');
// Only try to connect if authentication isn't required or user is authenticated // Only try to connect if authentication isn't required or user is authenticated
// The 'is_authenticated' variable will be set in the template // The 'is_authenticated' variable will be set in the template
if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) { if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) {
@@ -118,6 +118,10 @@ $(document).ready(function () {
socket.on('queue_size', function (data) { socket.on('queue_size', function (data) {
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`); console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
if(queueSizePagerInfoText) {
queueSizePagerInfoText.textContent = parseInt(data.q_length).toLocaleString() || 'None';
}
document.body.classList.toggle('has-queue', parseInt(data.q_length) > 0);
// Update queue bubble in action sidebar // Update queue bubble in action sidebar
//if (queueBubble) { //if (queueBubble) {
@@ -110,6 +110,9 @@
background: var(--color-background-menu-link-hover); background: var(--color-background-menu-link-hover);
} }
} }
&#menu-pause, &#menu-mute {
display: none;
}
} }
} }
} }
@@ -2,6 +2,13 @@
padding: 0.5rem 1em; padding: 0.5rem 1em;
line-height: 1.2rem; line-height: 1.2rem;
} }
#menu-mute, #menu-pause {
padding-left: 0.3rem;
padding-right: 0.3rem;
img {
height: 1.2rem;
}
}
.pure-menu-item { .pure-menu-item {
svg { svg {
@@ -1,6 +1,4 @@
.pagination-page-info { .pagination-page-info {
color: #fff;
font-size: 0.85rem;
text-transform: capitalize; text-transform: capitalize;
} }
@@ -184,24 +184,24 @@ html[data-darkmode=true] {
// Mobile adjustments // Mobile adjustments
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
.toast-container { .toast-container {
left: 10px !important; left: 50% !important;
right: 10px !important; right: auto !important;
top: 10px !important; top: 80px !important;
transform: none !important; transform: translateX(-50%) !important;
align-items: stretch; align-items: center;
&.toast-bottom-right, &.toast-bottom-right,
&.toast-bottom-center, &.toast-bottom-center,
&.toast-bottom-left { &.toast-bottom-left {
top: auto !important; top: auto !important;
bottom: 10px !important; bottom: 80px !important;
} }
} }
.toast { .toast {
min-width: auto; min-width: auto;
max-width: none; max-width: none;
width: 100%; width: 80vw;
transform: translateY(-100px); transform: translateY(-100px);
&.toast-show { &.toast-show {
@@ -125,6 +125,11 @@ $grid-gap: 0.5rem;
border-bottom: none; border-bottom: none;
} }
// Empty state message - span full width on mobile
> td[colspan] {
grid-column: 1 / -1;
}
> td.title-col { > td.title-col {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 1; grid-row: 1;
@@ -1,4 +1,32 @@
/* table related */ /* table related */
#stats_row {
display: flex;
align-items: center;
width: 100%;
color: #fff;
font-size: 0.85rem;
>* {
padding-bottom: 0.5rem;
}
.left {
text-align: left;
}
.right {
opacity: 0.5;
transition: opacity 0.6s ease;
margin-left: auto; /* pushes it to the far right */
text-align: right;
}
}
body.has-queue {
#stats_row {
.right {
opacity: 1.0;
}
}
}
.watch-table { .watch-table {
width: 100%; width: 100%;
font-size: 80%; font-size: 80%;
@@ -33,6 +33,31 @@
@use "parts/login_form"; @use "parts/login_form";
@use "parts/tabs"; @use "parts/tabs";
// Smooth transitions for theme switching
body,
.pure-table,
.pure-table thead,
.pure-table td,
.pure-table th,
.pure-form input,
.pure-form textarea,
.pure-form select,
.edit-form .inner,
.pure-menu-horizontal,
footer,
.sticky-tab,
#diff-jump,
.button-tag,
#new-watch-form,
#new-watch-form input:not(.pure-button),
code,
.messages li,
#checkbox-operations,
.inline-warning,
a,
.watch-controls img {
transition: color 0.4s ease, background-color 0.4s ease, background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
}
body { body {
color: var(--color-text); color: var(--color-text);
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -186,7 +186,7 @@ class ChangeDetectionStore:
# Finally start the thread that will manage periodic data saves to JSON # Finally start the thread that will manage periodic data saves to JSON
# Only start if thread is not already running (reload_state might be called multiple times) # Only start if thread is not already running (reload_state might be called multiple times)
if not self.save_data_thread or not self.save_data_thread.is_alive(): if not self.save_data_thread or not self.save_data_thread.is_alive():
self.save_data_thread = threading.Thread(target=self.save_datastore) self.save_data_thread = threading.Thread(target=self.save_datastore, daemon=True, name="DatastoreSaver")
self.save_data_thread.start() self.save_data_thread.start()
def rehydrate_entity(self, uuid, entity, processor_override=None): def rehydrate_entity(self, uuid, entity, processor_override=None):
@@ -348,7 +348,8 @@ class ChangeDetectionStore:
r = requests.request(method="GET", r = requests.request(method="GET",
url=url, url=url,
# So we know to return the JSON instead of the human-friendly "help" page # So we know to return the JSON instead of the human-friendly "help" page
headers={'App-Guid': self.__data['app_guid']}) headers={'App-Guid': self.__data['app_guid']},
timeout=5.0) # 5 second timeout to prevent blocking
res = r.json() res = r.json()
# List of permissible attributes we accept from the wild internet # List of permissible attributes we accept from the wild internet
+2
View File
@@ -17,6 +17,8 @@ _MAP = {
def strtobool(value): def strtobool(value):
if not value:
return False
try: try:
return _MAP[str(value).lower()] return _MAP[str(value).lower()]
except KeyError: except KeyError:
+41 -42
View File
@@ -6,94 +6,93 @@
<div class="pure-controls"> <div class="pure-controls">
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
Body for all notifications &dash; You can use <a target="newwindow" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> templating in the notification title, body and URL, and tokens from below. {{ _('Body for all notifications You can use') }} <a target="newwindow" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> {{ _('templating in the notification title, body and URL, and tokens from below.') }}
</span><br> </span><br>
<div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">Show <div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show token/placeholders') }}
token/placeholders
</div> </div>
</div> </div>
<div class="pure-controls" style="display: none;" id="notification-tokens-info{{ suffix }}"> <div class="pure-controls" style="display: none;" id="notification-tokens-info{{ suffix }}">
<table class="pure-table" id="token-table"> <table class="pure-table" id="token-table">
<thead> <thead>
<tr> <tr>
<th>Token</th> <th>{{ _('Token') }}</th>
<th>Description</th> <th>{{ _('Description') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><code>{{ '{{base_url}}' }}</code></td> <td><code>{{ '{{base_url}}' }}</code></td>
<td>The URL of the changedetection.io instance you are running.</td> <td>{{ _('The URL of the changedetection.io instance you are running.') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_url}}' }}</code></td> <td><code>{{ '{{watch_url}}' }}</code></td>
<td>The URL being watched.</td> <td>{{ _('The URL being watched.') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_uuid}}' }}</code></td> <td><code>{{ '{{watch_uuid}}' }}</code></td>
<td>The UUID of the watch.</td> <td>{{ _('The UUID of the watch.') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_title}}' }}</code></td> <td><code>{{ '{{watch_title}}' }}</code></td>
<td>The page title of the watch, uses &lt;title&gt; if not set, falls back to URL</td> <td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_tag}}' }}</code></td> <td><code>{{ '{{watch_tag}}' }}</code></td>
<td>The watch group / tag</td> <td>{{ _('The watch group / tag') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{preview_url}}' }}</code></td> <td><code>{{ '{{preview_url}}' }}</code></td>
<td>The URL of the preview page generated by changedetection.io.</td> <td>{{ _('The URL of the preview page generated by changedetection.io.') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_url}}' }}</code></td> <td><code>{{ '{{diff_url}}' }}</code></td>
<td>The URL of the diff output for the watch.</td> <td>{{ _('The URL of the diff output for the watch.') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff}}' }}</code></td> <td><code>{{ '{{diff}}' }}</code></td>
<td>The diff output - only changes, additions, and removals</td> <td>{{ _('The diff output - only changes, additions, and removals') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_clean}}' }}</code></td> <td><code>{{ '{{diff_clean}}' }}</code></td>
<td>The diff output - only changes, additions, and removals &dash; <i>Without (added) prefix or colors</i> <td>{{ _('The diff output - only changes, additions, and removals —') }} <i>{{ _('Without (added) prefix or colors') }}</i>
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_added}}' }}</code></td> <td><code>{{ '{{diff_added}}' }}</code></td>
<td>The diff output - only changes and additions</td> <td>{{ _('The diff output - only changes and additions') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_added_clean}}' }}</code></td> <td><code>{{ '{{diff_added_clean}}' }}</code></td>
<td>The diff output - only changes and additions &dash; <i>Without (added) prefix or colors</i></td> <td>{{ _('The diff output - only changes and additions —') }} <i>{{ _('Without (added) prefix or colors') }}</i></td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_removed}}' }}</code></td> <td><code>{{ '{{diff_removed}}' }}</code></td>
<td>The diff output - only changes and removals</td> <td>{{ _('The diff output - only changes and removals') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_removed_clean}}' }}</code></td> <td><code>{{ '{{diff_removed_clean}}' }}</code></td>
<td>The diff output - only changes and removals &dash; <i>Without (added) prefix or colors</i></td> <td>{{ _('The diff output - only changes and removals —') }} <i>{{ _('Without (added) prefix or colors') }}</i></td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_full}}' }}</code></td> <td><code>{{ '{{diff_full}}' }}</code></td>
<td>The diff output - full difference output</td> <td>{{ _('The diff output - full difference output') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_full_clean}}' }}</code></td> <td><code>{{ '{{diff_full_clean}}' }}</code></td>
<td>The diff output - full difference output &dash; <i>Without (added) prefix or colors</i></td> <td>{{ _('The diff output - full difference output —') }} <i>{{ _('Without (added) prefix or colors') }}</i></td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{diff_patch}}' }}</code></td> <td><code>{{ '{{diff_patch}}' }}</code></td>
<td>The diff output - patch in unified format</td> <td>{{ _('The diff output - patch in unified format') }}</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{current_snapshot}}' }}</code></td> <td><code>{{ '{{current_snapshot}}' }}</code></td>
<td>The current snapshot text contents value, useful when combined with JSON or CSS filters <td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{triggered_text}}' }}</code></td> <td><code>{{ '{{triggered_text}}' }}</code></td>
<td>Text that tripped the trigger from filters</td> <td>{{ _('Text that tripped the trigger from filters') }}</td>
{% if extra_notification_token_placeholder_info %} {% if extra_notification_token_placeholder_info %}
{% for token in extra_notification_token_placeholder_info %} {% for token in extra_notification_token_placeholder_info %}
@@ -107,8 +106,8 @@
</table> </table>
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
Warning: Contents of <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, and <code>{{ '{{diff_added}}' }}</code> depend on how the difference algorithm perceives the change. <br> {{ _('Warning: Contents of') }} <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, {{ _('and') }} <code>{{ '{{diff_added}}' }}</code> {{ _('depend on how the difference algorithm perceives the change.') }} <br>
For example, an addition or removal could be perceived as a change in some cases. <a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removed%7D%7D-notification-tokens">More Here</a> <br> {{ _('For example, an addition or removal could be perceived as a change in some cases.') }} <a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removed%7D%7D-notification-tokens">{{ _('More Here') }}</a> <br>
</span> </span>
</div> </div>
{% endmacro %} {% endmacro %}
@@ -124,32 +123,32 @@
}} }}
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<p> <p>
<strong>Tip:</strong> Use <a target="newwindow" href="https://github.com/caronc/apprise">AppRise Notification URLs</a> for notification to just about any service! <i><a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.<br> <strong>{{ _('Tip:') }}</strong> {{ _('Use') }} <a target="newwindow" href="https://github.com/caronc/apprise">{{ _('AppRise Notification URLs') }}</a> {{ _('for notification to just about any service!') }} <i><a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">{{ _('Please read the notification services wiki here for important configuration notes') }}</a></i>.<br>
</p> </p>
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div> <div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show advanced help and tips') }}</div>
<ul style="display: none" id="advanced-help-notifications"> <ul style="display: none" id="advanced-help-notifications">
<li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li> <li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> {{ _('(or') }} <code>https://discord.com/api/webhooks...</code>)) {{ _('only supports a maximum') }} <strong>{{ _('2,000 characters') }}</strong> {{ _('of notification text, including the title.') }}</li>
<li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li> <li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> {{ _('bots can\'t send messages to other bots, so you should specify chat ID of non-bot user.') }}</li>
<li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li> <li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> {{ _('only supports very limited HTML and can fail when extra tags are sent,') }} <a href="https://core.telegram.org/bots/api#html-style">{{ _('read more here') }}</a> {{ _('(or use plaintext/markdown format)') }}</li>
<li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> for direct API calls (or omit the "<code>s</code>" for non-SSL ie <code>get://</code>) <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes#postposts">more help here</a></li> <li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> {{ _('for direct API calls (or omit the') }} "<code>s</code>" {{ _('for non-SSL ie') }} <code>get://</code>) <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes#postposts">{{ _('more help here') }}</a></li>
<li>Accepts the <code>{{ '{{token}}' }}</code> placeholders listed below</li> <li>{{ _('Accepts the') }} <code>{{ '{{token}}' }}</code> {{ _('placeholders listed below') }}</li>
</ul> </ul>
</div> </div>
<div class="notifications-wrapper"> <div class="notifications-wrapper">
<a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> <div class="spinner" style="display: none;"></div> <a id="send-test-notification" class="pure-button button-secondary button-xsmall" >{{ _('Send test notification') }}</a> <div class="spinner" style="display: none;"></div>
{% if emailprefix %} {% if emailprefix %}
<a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a> <a id="add-email-helper" class="pure-button button-secondary button-xsmall" >{{ _('Add email') }} <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="{{ _('Add an email address') }}"> </a>
{% endif %} {% endif %}
<a href="{{url_for('settings.notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a> <a href="{{url_for('settings.notification_logs')}}" class="pure-button button-secondary button-xsmall" >{{ _('Notification debug logs') }}</a>
<br> <br>
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div> <div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">{{ _('Processing..') }}</span></div>
</div> </div>
</div> </div>
<div class="pure-control-group grey-form-border"> <div class="pure-control-group grey-form-border">
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }} {{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }}
<span class="pure-form-message-inline">Title for all notifications</span> <span class="pure-form-message-inline">{{ _('Title for all notifications') }}</span>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }} {{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }}
@@ -157,16 +156,16 @@
<div class="pure-form-message-inline"> <div class="pure-form-message-inline">
<ul> <ul>
<li><span class="pure-form-message-inline"> <li><span class="pure-form-message-inline">
For JSON payloads, use <strong>|tojson</strong> without quotes for automatic escaping, for example - <code>{ "name": {{ '{{ watch_title|tojson }}' }} }</code> {{ _('For JSON payloads, use') }} <strong>|tojson</strong> {{ _('without quotes for automatic escaping, for example -') }} <code>{ "name": {{ '{{ watch_title|tojson }}' }} }</code>
</span></li> </span></li>
<li><span class="pure-form-message-inline"> <li><span class="pure-form-message-inline">
URL encoding, use <strong>|urlencode</strong>, for example - <code>gets://hook-website.com/test.php?title={{ '{{ watch_title|urlencode }}' }}</code> {{ _('URL encoding, use') }} <strong>|urlencode</strong>, {{ _('for example -') }} <code>gets://hook-website.com/test.php?title={{ '{{ watch_title|urlencode }}' }}</code>
</span></li> </span></li>
<li><span class="pure-form-message-inline"> <li><span class="pure-form-message-inline">
Regular-expression replace, use <strong>|regex_replace</strong>, for example - <code>{{ "{{ \"hello world 123\" | regex_replace('[0-9]+', 'no-more-numbers') }}" }}</code> {{ _('Regular-expression replace, use') }} <strong>|regex_replace</strong>, {{ _('for example -') }} <code>{{ "{{ \"hello world 123\" | regex_replace('[0-9]+', 'no-more-numbers') }}" }}</code>
</span></li> </span></li>
<li><span class="pure-form-message-inline"> <li><span class="pure-form-message-inline">
For a complete reference of all Jinja2 built-in filters, users can refer to the <a href="https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters">https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters</a> {{ _('For a complete reference of all Jinja2 built-in filters, users can refer to the') }} <a href="https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters">https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters</a>
</span></li> </span></li>
</ul> </ul>
<br> <br>
@@ -174,7 +173,7 @@
</div> </div>
<div class=""> <div class="">
{{ render_field(form.notification_format , class="notification-format") }} {{ render_field(form.notification_format , class="notification-format") }}
<span class="pure-form-message-inline">Format for all notifications</span> <span class="pure-form-message-inline">{{ _('Format for all notifications') }}</span>
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
+7 -7
View File
@@ -1,5 +1,5 @@
{% macro render_field(field) %} {% macro render_field(field) %}
<div {% if field.errors or field.top_errors %} class="error" {% endif %}><label for="{{ field.id }}">{{ field.label.text | string | forceescape }}</label></div> <div {% if field.errors or field.top_errors %} class="error" {% endif %}>{{ field.label }}</div>
<div {% if field.errors or field.top_errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }} <div {% if field.errors or field.top_errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }}
{% if field.top_errors %} {% if field.top_errors %}
top top
@@ -59,7 +59,7 @@
{% macro render_ternary_field(field, BooleanField=false) %} {% macro render_ternary_field(field, BooleanField=false) %}
{% if BooleanField %} {% if BooleanField %}
{% set _ = field.__setattr__('boolean_mode', true) %} {% set dummy = field.__setattr__('boolean_mode', true) %}
{% endif %} {% endif %}
<div class="ternary-field {% if field.errors %} error {% endif %}"> <div class="ternary-field {% if field.errors %} error {% endif %}">
<div class="ternary-field-label"><label for="{{ field.id }}">{{ field.label.text | string | forceescape }}</label></div> <div class="ternary-field-label"><label for="{{ field.id }}">{{ field.label.text | string | forceescape }}</label></div>
@@ -113,17 +113,17 @@
{% macro render_fieldlist_with_inline_errors(fieldlist) %} {% macro render_fieldlist_with_inline_errors(fieldlist) %}
{# Specialized macro for FieldList(FormField(...)) that renders errors inline with each field #} {# Specialized macro for FieldList(FormField(...)) that renders errors inline with each field #}
<div {% if fieldlist.errors %} class="error" {% endif %}>{{ fieldlist.label }}</div> <div {% if fieldlist.errors %} class="error" {% endif %}>{{ _(fieldlist.label.text | string) }}</div>
<div {% if fieldlist.errors %} class="error" {% endif %}> <div {% if fieldlist.errors %} class="error" {% endif %}>
<ul id="{{ fieldlist.id }}"> <ul id="{{ fieldlist.id }}">
{% for entry in fieldlist %} {% for entry in fieldlist %}
<li {% if entry.errors %} class="error" {% endif %}> <li {% if entry.errors %} class="error" {% endif %}>
<label for="{{ entry.id }}" {% if entry.errors %} class="error" {% endif %}>{{ fieldlist.label.text }}-{{ loop.index0 }}</label> <label for="{{ entry.id }}" {% if entry.errors %} class="error" {% endif %}>{{ _(fieldlist.label.text | string) }}-{{ loop.index0 }}</label>
<table id="{{ entry.id }}" {% if entry.errors %} class="error" {% endif %}> <table id="{{ entry.id }}" {% if entry.errors %} class="error" {% endif %}>
<tbody> <tbody>
{% for subfield in entry %} {% for subfield in entry %}
<tr {% if subfield.errors %} class="error" {% endif %}> <tr {% if subfield.errors %} class="error" {% endif %}>
<th {% if subfield.errors %} class="error" {% endif %}><label for="{{ subfield.id }}" {% if subfield.errors %} class="error" {% endif %}>{{ subfield.label.text }}</label></th> <th {% if subfield.errors %} class="error" {% endif %}><label for="{{ subfield.id }}" {% if subfield.errors %} class="error" {% endif %}>{{ subfield.label.text | string }}</label></th>
<td {% if subfield.errors %} class="error" {% endif %}> <td {% if subfield.errors %} class="error" {% endif %}>
{{ subfield(**kwargs)|safe }} {{ subfield(**kwargs)|safe }}
{% if subfield.errors %} {% if subfield.errors %}
@@ -148,7 +148,7 @@
<div class="fieldlist_formfields" id="{{ table_id }}"> <div class="fieldlist_formfields" id="{{ table_id }}">
<div class="fieldlist-header"> <div class="fieldlist-header">
{% for subfield in fieldlist[0] %} {% for subfield in fieldlist[0] %}
<div class="fieldlist-header-cell">{{ subfield.label }}</div> <div class="fieldlist-header-cell">{{ subfield.label.text | string }}</div>
{% endfor %} {% endfor %}
<div class="fieldlist-header-cell">{{ _('Actions') }}</div> <div class="fieldlist-header-cell">{{ _('Actions') }}</div>
</div> </div>
@@ -267,7 +267,7 @@
</ul> </ul>
<br> <br>
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<a href="https://changedetection.io/tutorials">{{ _('More help and examples about using the scheduler') }}</a> <a href="https://changedetection.io/tutorial/checking-web-pages-changes-according-schedule">{{ _('More help and examples about using the scheduler') }}</a>
</span> </span>
</div> </div>
{% else %} {% else %}
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ get_locale() }}" data-darkmode="{{ get_darkmode_state() }}"> <html lang="{{ get_locale()|replace('_', '-') }}" data-darkmode="{{ get_darkmode_state() }}">
<head> <head>
<meta charset="utf-8" > <meta charset="utf-8" >
@@ -6,10 +6,10 @@
") }} ") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li> <li>{{ _('Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.') }}</li>
<li>Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li> <li>{{ _('Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>
<li>Each line is processed separately (think of each line as "OR")</li> <li>{{ _('Each line is processed separately (think of each line as "OR")') }}</li>
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li> <li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\d/</code></li>
</ul> </ul>
</span> </span>
</div> </div>
@@ -20,10 +20,10 @@
") }} ") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li> <li>{{ _('Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)') }}</li>
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> <li>{{ _('Each line processed separately, any line matching will be ignored (removed before creating the checksum)') }}</li>
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> <li>{{ _('Regular Expression support, wrap the entire line in forward slash') }} <code>/regex/</code></li>
<li>Changing this will affect the comparison checksum which may trigger an alert</li> <li>{{ _('Changing this will affect the comparison checksum which may trigger an alert') }}</li>
</ul> </ul>
</span> </span>
<br><br> <br><br>
@@ -40,10 +40,10 @@ Not in stock
Unavailable") }} Unavailable") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Block change-detection while this text is on the page, all text and regex are tested <i>case-insensitive</i>, good for waiting for when a product is available again</li> <li>{{ _('Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again') }}</li>
<li>Block text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li> <li>{{ _('Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>
<li>All lines here must not exist (think of each line as "OR")</li> <li>{{ _('All lines here must not exist (think of each line as "OR")') }}</li>
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li> <li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\d/</code></li>
</ul> </ul>
</span> </span>
</div> </div>
@@ -55,17 +55,17 @@ Unavailable") }}
keyword") }} keyword") }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
<ul> <ul>
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match; <li>{{ _('Extracts text in the final output (line by line) after other filters using regular expressions or string match:') }}
<ul> <ul>
<li>Regular expression &dash; example <code>/reports.+?2022/i</code></li> <li>{{ _('Regular expression - example') }} <code>/reports.+?2022/i</code></li>
<li>Don't forget to consider the white-space at the start of a line <code>/.+?reports.+?2022/i</code></li> <li>{{ _('Don\'t forget to consider the white-space at the start of a line') }} <code>/.+?reports.+?2022/i</code></li>
<li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br></li> <li>{{ _('Use') }} <code>//(?aiLmsux))</code> {{ _('type flags (more') }} <a href="https://docs.python.org/3/library/re.html#index-15">{{ _('information here') }}</a>)<br></li>
<li>Keyword example &dash; example <code>Out of stock</code></li> <li>{{ _('Keyword example - example') }} <code>Out of stock</code></li>
<li>Use groups to extract just that text &dash; example <code>/reports.+?(\d+)/i</code> returns a list of years only</li> <li>{{ _('Use groups to extract just that text - example') }} <code>/reports.+?(\d+)/i</code> {{ _('returns a list of years only') }}</li>
<li>Example - match lines containing a keyword <code>/.*icecream.*/</code></li> <li>{{ _('Example - match lines containing a keyword') }} <code>/.*icecream.*/</code></li>
</ul> </ul>
</li> </li>
<li>One line per regular-expression/string match</li> <li>{{ _('One line per regular-expression/string match') }}</li>
</ul> </ul>
</span> </span>
</div> </div>
+6 -3
View File
@@ -13,8 +13,11 @@
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('imports.') %}active{% endif %}"> <li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('imports.') %}active{% endif %}">
<a href="{{ url_for('imports.import_page') }}" class="pure-menu-link">{{ _('IMPORT') }}</a> <a href="{{ url_for('imports.import_page') }}" class="pure-menu-link">{{ _('IMPORT') }}</a>
</li> </li>
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('backups.') %}active{% endif %}"> <li class="pure-menu-item" id="menu-pause">
<a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('BACKUPS') }}</a> <a href="{{ url_for('settings.toggle_all_paused') }}" ><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="{% if all_paused %}{{ _('Resume automatic scheduling') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}" title="{% if all_paused %}{{ _('Scheduling is paused - click to resume') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}" class="icon icon-pause"{% if not all_paused %} style="opacity: 0.3"{% endif %}></a>
</li>
<li class="pure-menu-item " id="menu-mute">
<a href="{{ url_for('settings.toggle_all_muted') }}" ><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}" title="{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}" class="icon icon-mute"{% if not all_muted %} style="opacity: 0.3"{% endif %}></a>
</li> </li>
{% else %} {% else %}
<li class="pure-menu-item menu-collapsible"> <li class="pure-menu-item menu-collapsible">
@@ -30,7 +33,7 @@
{% else %} {% else %}
<li class="pure-menu-item menu-collapsible"> <li class="pure-menu-item menu-collapsible">
<a class="pure-menu-link" href="https://changedetection.io">Website Change Detection and Notification.</a> <a class="pure-menu-link" href="https://changedetection.io">{{ _('Website Change Detection and Notification.') }}</a>
</li> </li>
{% endif %} {% endif %}
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group"> <li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
+53 -9
View File
@@ -159,6 +159,14 @@ def prepare_test_function(live_server, datastore_path):
# CRITICAL: Get datastore and stop it from writing stale data # CRITICAL: Get datastore and stop it from writing stale data
datastore = live_server.app.config.get('DATASTORE') datastore = live_server.app.config.get('DATASTORE')
# Clear the queue before starting the test to prevent state leakage
from changedetectionio.flask_app import update_q
while not update_q.empty():
try:
update_q.get_nowait()
except:
break
# Prevent background thread from writing during cleanup/reload # Prevent background thread from writing during cleanup/reload
datastore.needs_write = False datastore.needs_write = False
datastore.needs_write_urgent = False datastore.needs_write_urgent = False
@@ -183,8 +191,17 @@ def prepare_test_function(live_server, datastore_path):
yield yield
# Cleanup: Clear watches again after test # Cleanup: Clear watches and queue after test
try: try:
from changedetectionio.flask_app import update_q
# Clear the queue to prevent leakage to next test
while not update_q.empty():
try:
update_q.get_nowait()
except:
break
datastore.data['watching'] = {} datastore.data['watching'] = {}
datastore.needs_write = True datastore.needs_write = True
except Exception as e: except Exception as e:
@@ -238,17 +255,20 @@ def app(request, datastore_path):
app.config['TEST_DATASTORE_PATH'] = datastore_path app.config['TEST_DATASTORE_PATH'] = datastore_path
def teardown(): def teardown():
import threading
import time
# Stop all threads and services # Stop all threads and services
datastore.stop_thread = True datastore.stop_thread = True
app.config.exit.set() app.config.exit.set()
# Shutdown workers gracefully before loguru cleanup # Shutdown workers gracefully before loguru cleanup
try: try:
from changedetectionio import worker_handler from changedetectionio import worker_handler
worker_handler.shutdown_workers() worker_handler.shutdown_workers()
except Exception: except Exception:
pass pass
# Stop socket server threads # Stop socket server threads
try: try:
from changedetectionio.flask_app import socketio_server from changedetectionio.flask_app import socketio_server
@@ -256,17 +276,41 @@ def app(request, datastore_path):
socketio_server.shutdown() socketio_server.shutdown()
except Exception: except Exception:
pass pass
# Give threads a moment to finish their shutdown # Get all active threads before cleanup
import time main_thread = threading.main_thread()
time.sleep(0.1) active_threads = [t for t in threading.enumerate() if t != main_thread and t.is_alive()]
# Wait for non-daemon threads to finish (with timeout)
timeout = 2.0 # 2 seconds max wait
start_time = time.time()
for thread in active_threads:
if not thread.daemon:
remaining_time = timeout - (time.time() - start_time)
if remaining_time > 0:
logger.debug(f"Waiting for non-daemon thread to finish: {thread.name}")
thread.join(timeout=remaining_time)
if thread.is_alive():
logger.warning(f"Thread {thread.name} did not finish in time")
# Give daemon threads a moment to finish their current work
time.sleep(0.2)
# Log any threads still running
remaining_threads = [t for t in threading.enumerate() if t != main_thread and t.is_alive()]
if remaining_threads:
logger.debug(f"Threads still running after teardown: {[t.name for t in remaining_threads]}")
# Remove all loguru handlers to prevent "closed file" errors # Remove all loguru handlers to prevent "closed file" errors
logger.remove() logger.remove()
# Cleanup files # Cleanup files
cleanup(app_config['datastore_path']) cleanup(app_config['datastore_path'])
request.addfinalizer(teardown) request.addfinalizer(teardown)
yield app yield app
@@ -124,7 +124,6 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
# Menu should be available now # Menu should be available now
assert b"SETTINGS" in res.data assert b"SETTINGS" in res.data
assert b"BACKUP" in res.data
assert b"IMPORT" in res.data assert b"IMPORT" in res.data
assert b"LOG OUT" in res.data assert b"LOG OUT" in res.data
assert b"time_between_check-minutes" in res.data assert b"time_between_check-minutes" in res.data
+174 -6
View File
@@ -58,7 +58,7 @@ def is_valid_uuid(val):
def test_api_simple(client, live_server, measure_memory_usage, datastore_path): def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
@@ -109,18 +109,17 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
headers={'x-api-key': api_key} headers={'x-api-key': api_key}
) )
assert len(res.json) == 0 assert len(res.json) == 0
time.sleep(1) time.sleep(2)
wait_for_all_checks(client) wait_for_all_checks(client)
set_modified_response(datastore_path=datastore_path) set_modified_response(datastore_path=datastore_path)
# Trigger recheck of all ?recheck_all=1 # Trigger recheck of all ?recheck_all=1
client.get( res = client.get(
url_for("createwatch", recheck_all='1'), url_for("createwatch", recheck_all='1'),
headers={'x-api-key': api_key}, headers={'x-api-key': api_key},
) )
wait_for_all_checks(client) wait_for_all_checks(client)
time.sleep(1) time.sleep(2)
# Did the recheck fire? # Did the recheck fire?
res = client.get( res = client.get(
url_for("createwatch"), url_for("createwatch"),
@@ -507,7 +506,7 @@ def test_api_import(client, live_server, measure_memory_usage, datastore_path):
def test_api_conflict_UI_password(client, live_server, measure_memory_usage, datastore_path): def test_api_conflict_UI_password(client, live_server, measure_memory_usage, datastore_path):
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Enable password check and diff page access bypass # Enable password check and diff page access bypass
@@ -549,3 +548,172 @@ def test_api_conflict_UI_password(client, live_server, measure_memory_usage, dat
assert len(res.json) assert len(res.json)
def test_api_url_validation(client, live_server, measure_memory_usage, datastore_path):
"""
Test URL validation for edge cases in both CREATE and UPDATE endpoints.
Addresses security issues where empty/null/invalid URLs could bypass validation.
This test ensures that:
- CREATE endpoint rejects null, empty, and invalid URLs
- UPDATE endpoint rejects attempts to change URL to null, empty, or invalid
- UPDATE endpoint allows updating other fields without touching URL
- URL validation properly checks protocol, format, and safety
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: CREATE with null URL should fail
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": None}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 400, "Creating watch with null URL should fail"
# Test 2: CREATE with empty string URL should fail
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": ""}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 400, "Creating watch with empty string URL should fail"
assert b'Invalid or unsupported URL' in res.data or b'required' in res.data.lower()
# Test 3: CREATE with whitespace-only URL should fail
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": " "}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 400, "Creating watch with whitespace-only URL should fail"
# Test 4: CREATE with invalid protocol should fail
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "javascript:alert(1)"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 400, "Creating watch with javascript: protocol should fail"
# Test 5: CREATE with missing protocol should fail
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "example.com"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 400, "Creating watch without protocol should fail"
# Test 6: CREATE with valid URL should succeed (baseline)
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url, "title": "Valid URL test"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 201, "Creating watch with valid URL should succeed"
assert is_valid_uuid(res.json.get('uuid'))
watch_uuid = res.json.get('uuid')
wait_for_all_checks(client)
# Test 7: UPDATE to null URL should fail
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"url": None}),
)
assert res.status_code == 400, "Updating watch URL to null should fail"
# Accept either OpenAPI validation error or our custom validation error
assert b'URL cannot be null' in res.data or b'OpenAPI validation failed' in res.data or b'validation error' in res.data.lower()
# Test 8: UPDATE to empty string URL should fail
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"url": ""}),
)
assert res.status_code == 400, "Updating watch URL to empty string should fail"
# Accept either our custom validation error or OpenAPI/schema validation error
assert b'URL cannot be empty' in res.data or b'OpenAPI validation' in res.data or b'Invalid or unsupported URL' in res.data
# Test 9: UPDATE to whitespace-only URL should fail
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"url": " \t\n "}),
)
assert res.status_code == 400, "Updating watch URL to whitespace should fail"
# Accept either our custom validation error or generic validation error
assert b'URL cannot be empty' in res.data or b'Invalid or unsupported URL' in res.data or b'validation' in res.data.lower()
# Test 10: UPDATE to invalid protocol should fail (javascript:)
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"url": "javascript:alert(document.domain)"}),
)
assert res.status_code == 400, "Updating watch URL to XSS attempt should fail"
assert b'Invalid or unsupported URL' in res.data or b'protocol' in res.data.lower()
# Test 11: UPDATE to file:// protocol should fail (unless ALLOW_FILE_URI is set)
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"url": "file:///etc/passwd"}),
)
assert res.status_code == 400, "Updating watch URL to file:// should fail by default"
# Test 12: UPDATE other fields without URL should succeed
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"title": "Updated title without URL change"}),
)
assert res.status_code == 200, "Updating other fields without URL should succeed"
# Test 13: Verify URL is still valid after non-URL update
res = client.get(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key}
)
assert res.json.get('url') == test_url, "URL should remain unchanged"
assert res.json.get('title') == "Updated title without URL change"
# Test 14: UPDATE to valid different URL should succeed
new_valid_url = test_url + "?new=param"
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"url": new_valid_url}),
)
assert res.status_code == 200, "Updating to valid different URL should succeed"
# Test 15: Verify URL was actually updated
res = client.get(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key}
)
assert res.json.get('url') == new_valid_url, "URL should be updated to new valid URL"
# Test 16: CREATE with XSS in URL parameters should fail
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "http://example.com?xss=<script>alert(1)</script>"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
# This should fail because of suspicious characters check
assert res.status_code == 400, "Creating watch with XSS in URL params should fail"
# Cleanup
client.delete(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key},
)
delete_all_watches(client)
@@ -0,0 +1,805 @@
#!/usr/bin/env python3
"""
Comprehensive security and edge case tests for the API.
Tests critical areas that were identified as gaps in the existing test suite.
"""
import time
import json
import threading
import uuid as uuid_module
from flask import url_for
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
import os
def set_original_response(datastore_path):
test_return_data = """<html>
<body>
Some initial text<br>
<p>Which is across multiple lines</p>
</body>
</html>
"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(test_return_data)
return None
def is_valid_uuid(val):
try:
uuid_module.UUID(str(val))
return True
except ValueError:
return False
# ============================================================================
# TIER 1: CRITICAL SECURITY TESTS
# ============================================================================
def test_api_path_traversal_in_uuids(client, live_server, measure_memory_usage, datastore_path):
"""
Test that path traversal attacks via UUID parameter are blocked.
Addresses CVE-like vulnerabilities where ../../../ in UUID could access arbitrary files.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Create a valid watch first
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url, "title": "Valid watch"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201
valid_uuid = res.json.get('uuid')
# Test 1: Path traversal with ../../../
res = client.get(
f"/api/v1/watch/../../etc/passwd",
headers={'x-api-key': api_key}
)
assert res.status_code in [400, 404], "Path traversal should be rejected"
# Test 2: Encoded path traversal
res = client.get(
"/api/v1/watch/..%2F..%2F..%2Fetc%2Fpasswd",
headers={'x-api-key': api_key}
)
assert res.status_code in [400, 404], "Encoded path traversal should be rejected"
# Test 3: Double-encoded path traversal
res = client.get(
"/api/v1/watch/%2e%2e%2f%2e%2e%2f%2e%2e%2f",
headers={'x-api-key': api_key}
)
assert res.status_code in [400, 404], "Double-encoded traversal should be rejected"
# Test 4: Try to access datastore file
res = client.get(
"/api/v1/watch/../url-watches.json",
headers={'x-api-key': api_key}
)
assert res.status_code in [400, 404], "Access to datastore should be blocked"
# Test 5: Null byte injection
res = client.get(
f"/api/v1/watch/{valid_uuid}%00.json",
headers={'x-api-key': api_key}
)
# Should either work (ignoring null byte) or reject - but not crash
assert res.status_code in [200, 400, 404]
# Test 6: DELETE with path traversal
res = client.delete(
"/api/v1/watch/../../datastore/url-watches.json",
headers={'x-api-key': api_key}
)
assert res.status_code in [400, 404, 405], "DELETE with traversal should be blocked (405=method not allowed is also acceptable)"
# Cleanup
client.delete(url_for("watch", uuid=valid_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_injection_via_headers_and_proxy(client, live_server, measure_memory_usage, datastore_path):
"""
Test that injection attacks via headers and proxy fields are properly sanitized.
Addresses XSS and injection vulnerabilities.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: XSS in headers
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"headers": {
"User-Agent": "<script>alert(1)</script>",
"X-Custom": "'; DROP TABLE watches; --"
}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Headers are metadata used for HTTP requests, not HTML rendering
# Storing them as-is is expected behavior
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
# Verify headers are stored (API returns JSON, not HTML, so no XSS risk)
res = client.get(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
assert res.status_code == 200
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 2: Null bytes in headers
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"headers": {"X-Test": "value\x00null"}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should handle null bytes gracefully (reject or sanitize)
assert res.status_code in [201, 400]
# Test 3: Malformed proxy string
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"proxy": "http://evil.com:8080@victim.com"
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should reject invalid proxy format
assert res.status_code == 400
# Test 4: Control characters in notification title
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"notification_title": "Test\r\nInjected-Header: value"
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should accept but sanitize control characters
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_large_payload_dos(client, live_server, measure_memory_usage, datastore_path):
"""
Test that excessively large payloads are rejected to prevent DoS.
Addresses memory leak issues found in changelog.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: Huge ignore_text array
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"ignore_text": ["a" * 10000] * 100 # 1MB of data
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should either accept (with limits) or reject
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 2: Massive headers object
huge_headers = {f"X-Header-{i}": "x" * 1000 for i in range(100)}
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"headers": huge_headers
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should reject or truncate
assert res.status_code in [201, 400, 413]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 3: Huge browser_steps array
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"browser_steps": [
{"operation": "click", "selector": "#test" * 1000, "optional_value": ""}
] * 100
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should reject or limit
assert res.status_code in [201, 400, 413]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 4: Extremely long title
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"title": "x" * 100000 # 100KB title
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should reject (exceeds maxLength: 5000)
assert res.status_code == 400
delete_all_watches(client)
def test_api_utf8_encoding_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test UTF-8 encoding edge cases that have caused bugs on Windows.
Addresses 18+ encoding bugs from changelog.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: Unicode in title (should work)
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"title": "Test 中文 Ελληνικά 日本語 🔥"
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201
watch_uuid = res.json.get('uuid')
# Verify it round-trips correctly
res = client.get(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
assert res.status_code == 200
assert "中文" in res.json.get('title')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 2: Unicode in URL query parameters
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url + "?search=日本語"
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should handle URL encoding properly
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 3: Null byte in title (should be rejected or sanitized)
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"title": "Test\x00Title"
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should handle gracefully
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 4: BOM (Byte Order Mark) in title
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"title": "\ufeffTest with BOM"
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_concurrency_race_conditions(client, live_server, measure_memory_usage, datastore_path):
"""
Test concurrent API requests to detect race conditions.
Addresses 20+ concurrency bugs from changelog.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Create a watch
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url, "title": "Concurrency test"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201
watch_uuid = res.json.get('uuid')
wait_for_all_checks(client)
# Test 1: Concurrent updates to same watch
# Note: Flask test client is not thread-safe, so we test sequential updates instead
# Real concurrency issues would be caught in integration tests with actual HTTP requests
results = []
for i in range(10):
try:
r = client.put(
url_for("watch", uuid=watch_uuid),
data=json.dumps({"title": f"Title {i}"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
results.append(r.status_code)
except Exception as e:
results.append(str(e))
# All updates should succeed (200) without crashes
assert all(r == 200 for r in results), f"Some updates failed: {results}"
# Test 2: Update while watch is being checked
# Queue a recheck
client.get(
url_for("watch", uuid=watch_uuid, recheck=True),
headers={'x-api-key': api_key}
)
# Immediately update it
res = client.put(
url_for("watch", uuid=watch_uuid),
data=json.dumps({"title": "Updated during check"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should succeed without error
assert res.status_code == 200
# Test 3: Delete watch that's being processed
# Create another watch
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
watch_uuid2 = res.json.get('uuid')
# Queue it for checking
client.get(url_for("watch", uuid=watch_uuid2, recheck=True), headers={'x-api-key': api_key})
# Immediately delete it
res = client.delete(url_for("watch", uuid=watch_uuid2), headers={'x-api-key': api_key})
# Should succeed or return appropriate error
assert res.status_code in [204, 404, 400]
# Cleanup
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
# ============================================================================
# TIER 2: IMPORTANT FUNCTIONALITY TESTS
# ============================================================================
def test_api_time_validation_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test time_between_check validation edge cases.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: Zero interval
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"time_between_check_use_default": False,
"time_between_check": {"seconds": 0}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 400, "Zero interval should be rejected"
# Test 2: Negative interval
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"time_between_check_use_default": False,
"time_between_check": {"seconds": -100}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 400, "Negative interval should be rejected"
# Test 3: All fields null with use_default=false
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"time_between_check_use_default": False,
"time_between_check": {"weeks": None, "days": None, "hours": None, "minutes": None, "seconds": None}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 400, "All null intervals should be rejected when not using default"
# Test 4: Extremely large interval (overflow risk)
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"time_between_check_use_default": False,
"time_between_check": {"weeks": 999999999}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should either accept (with limits) or reject
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 5: Valid minimal interval (should work)
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"time_between_check_use_default": False,
"time_between_check": {"seconds": 60}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_browser_steps_validation(client, live_server, measure_memory_usage, datastore_path):
"""
Test browser_steps validation for invalid operations and structures.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: Empty browser step
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"browser_steps": [
{"operation": "", "selector": "", "optional_value": ""}
]
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should accept (empty is valid as null)
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 2: Invalid operation type
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"browser_steps": [
{"operation": "invalid_operation", "selector": "#test", "optional_value": ""}
]
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should accept (validation happens at runtime) or reject
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 3: Missing required fields in browser step
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"browser_steps": [
{"operation": "click"} # Missing selector and optional_value
]
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should be rejected due to schema validation
assert res.status_code == 400
# Test 4: Extra fields in browser step
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"browser_steps": [
{"operation": "click", "selector": "#test", "optional_value": "", "extra_field": "value"}
]
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should be rejected due to additionalProperties: false
assert res.status_code == 400
delete_all_watches(client)
def test_api_queue_manipulation(client, live_server, measure_memory_usage, datastore_path):
"""
Test queue behavior under stress and edge cases.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: Create many watches rapidly
watch_uuids = []
for i in range(20):
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url, "title": f"Watch {i}"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
if res.status_code == 201:
watch_uuids.append(res.json.get('uuid'))
assert len(watch_uuids) == 20, "Should be able to create 20 watches"
# Test 2: Recheck all when watches exist
res = client.get(
url_for("createwatch", recheck_all='1'),
headers={'x-api-key': api_key},
)
# Should return success (200 or 202 for background processing)
assert res.status_code in [200, 202]
# Test 3: Verify queue doesn't overflow with moderate load
# The app has MAX_QUEUE_SIZE = 5000, we're well below that
wait_for_all_checks(client)
# Cleanup
for uuid in watch_uuids:
client.delete(url_for("watch", uuid=uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
# ============================================================================
# TIER 3: EDGE CASES & POLISH
# ============================================================================
def test_api_history_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test history API with invalid timestamps and edge cases.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Create watch and generate history
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
watch_uuid = res.json.get('uuid')
wait_for_all_checks(client)
# Test 1: Get history with invalid timestamp
res = client.get(
url_for("watchsinglehistory", uuid=watch_uuid, timestamp="invalid"),
headers={'x-api-key': api_key}
)
assert res.status_code == 404, "Invalid timestamp should return 404"
# Test 2: Future timestamp
res = client.get(
url_for("watchsinglehistory", uuid=watch_uuid, timestamp="9999999999"),
headers={'x-api-key': api_key}
)
assert res.status_code == 404, "Future timestamp should return 404"
# Test 3: Negative timestamp
res = client.get(
url_for("watchsinglehistory", uuid=watch_uuid, timestamp="-1"),
headers={'x-api-key': api_key}
)
assert res.status_code == 404, "Negative timestamp should return 404"
# Test 4: Diff with reversed timestamps (from > to)
# First get actual timestamps
res = client.get(
url_for("watchhistory", uuid=watch_uuid),
headers={'x-api-key': api_key}
)
if len(res.json) >= 2:
timestamps = sorted(res.json.keys())
# Try reversed order
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp=timestamps[-1], to_timestamp=timestamps[0]),
headers={'x-api-key': api_key}
)
# Should either work (show reverse diff) or return error
assert res.status_code in [200, 400]
# Cleanup
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_notification_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test notification configuration edge cases.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: Invalid notification URL
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"notification_urls": ["invalid://url", "ftp://test.com"]
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should accept (apprise validates at runtime) or reject
assert res.status_code in [201, 400]
if res.status_code == 201:
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
# Test 2: Invalid notification format
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"notification_format": "invalid_format"
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should be rejected by schema
assert res.status_code == 400
# Test 3: Empty notification arrays
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"notification_urls": []
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should accept (empty is valid)
assert res.status_code == 201
watch_uuid = res.json.get('uuid')
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_tag_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test tag/group API edge cases including XSS and path traversal.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Test 1: Empty tag title
res = client.post(
url_for("tag"),
data=json.dumps({"title": ""}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should be rejected (empty title)
assert res.status_code == 400
# Test 2: XSS in tag title
res = client.post(
url_for("tag"),
data=json.dumps({"title": "<script>alert(1)</script>"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should accept but sanitize
if res.status_code == 201:
tag_uuid = res.json.get('uuid')
# Verify title is stored safely
res = client.get(url_for("tag", uuid=tag_uuid), headers={'x-api-key': api_key})
# Should be escaped or sanitized
client.delete(url_for("tag", uuid=tag_uuid), headers={'x-api-key': api_key})
# Test 3: Path traversal in tag title
res = client.post(
url_for("tag"),
data=json.dumps({"title": "../../etc/passwd"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should accept (it's just a string, not a path)
if res.status_code == 201:
tag_uuid = res.json.get('uuid')
client.delete(url_for("tag", uuid=tag_uuid), headers={'x-api-key': api_key})
# Test 4: Very long tag title
res = client.post(
url_for("tag"),
data=json.dumps({"title": "x" * 10000}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
# Should be rejected (exceeds maxLength)
assert res.status_code == 400
def test_api_authentication_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test API authentication edge cases.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Test 1: Missing API key
res = client.get(url_for("createwatch"))
assert res.status_code == 403, "Missing API key should be forbidden"
# Test 2: Invalid API key
res = client.get(
url_for("createwatch"),
headers={'x-api-key': "invalid_key_12345"}
)
assert res.status_code == 403, "Invalid API key should be forbidden"
# Test 3: API key with special characters
res = client.get(
url_for("createwatch"),
headers={'x-api-key': "key<script>alert(1)</script>"}
)
assert res.status_code == 403, "Invalid API key should be forbidden"
# Test 4: Very long API key
res = client.get(
url_for("createwatch"),
headers={'x-api-key': "x" * 10000}
)
assert res.status_code == 403, "Invalid API key should be forbidden"
# Test 5: Case sensitivity of API key
wrong_case_key = api_key.upper() if api_key.islower() else api_key.lower()
res = client.get(
url_for("createwatch"),
headers={'x-api-key': wrong_case_key}
)
# Should be forbidden (keys are case-sensitive)
assert res.status_code == 403, "Wrong case API key should be forbidden"
# Test 6: Valid API key should work
res = client.get(
url_for("createwatch"),
headers={'x-api-key': api_key}
)
assert res.status_code == 200, "Valid API key should work"
+5 -3
View File
@@ -125,10 +125,12 @@ def test_api_tags_listing(client, live_server, measure_memory_usage, datastore_p
url_for("tag", uuid=new_tag_uuid) + "?recheck=true", url_for("tag", uuid=new_tag_uuid) + "?recheck=true",
headers={'x-api-key': api_key} headers={'x-api-key': api_key}
) )
wait_for_all_checks()
assert res.status_code == 200
assert b'OK, 1 watches' in res.data
assert res.status_code == 200
assert b'OK, queued 1 watches for rechecking' in res.data
wait_for_all_checks()
after_check_time = live_server.app.config['DATASTORE'].data['watching'][watch_uuid].get('last_checked') after_check_time = live_server.app.config['DATASTORE'].data['watching'][watch_uuid].get('last_checked')
assert before_check_time != after_check_time assert before_check_time != after_check_time
-1
View File
@@ -38,7 +38,6 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
# Default no password set, this stuff should be always available. # Default no password set, this stuff should be always available.
assert b"SETTINGS" in res.data assert b"SETTINGS" in res.data
assert b"BACKUP" in res.data
assert b"IMPORT" in res.data assert b"IMPORT" in res.data
##################### #####################
@@ -206,11 +206,10 @@ def test_regex_error_handling(client, live_server, measure_memory_usage, datasto
# Add our URL to the import page # Add our URL to the import page
test_url = url_for('test_endpoint', _external=True) test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) time.sleep(0.2)
### test regex error handling ### test regex error handling
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid=uuid),
data={"extract_text": '/something bad\d{3/XYZ', data={"extract_text": '/something bad\d{3/XYZ',
"url": test_url, "url": test_url,
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
@@ -45,7 +45,7 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
res = client.get(url_for("watchlist.index")) res = client.get(url_for("watchlist.index"))
assert b'No website watches configured' not in res.data assert b'No web page change detection watches configured' not in res.data
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
@@ -4,25 +4,47 @@ import time
import os import os
import json import json
from flask import url_for from flask import url_for
from loguru import logger
from .. import strtobool
from .util import wait_for_all_checks, delete_all_watches from .util import wait_for_all_checks, delete_all_watches
from urllib.parse import urlparse, parse_qs import brotli
def test_consistent_history(client, live_server, measure_memory_usage, datastore_path): def test_consistent_history(client, live_server, measure_memory_usage, datastore_path):
# live_server_setup(live_server) # Setup on conftest per function
workers = int(os.getenv("FETCH_WORKERS", 10))
r = range(1, 10+workers)
for one in r: uuids = set()
test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True) sys_fetch_workers = int(os.getenv("FETCH_WORKERS", 10))
res = client.post( workers = range(1, sys_fetch_workers)
url_for("imports.import_page"), now = time.time()
data={"urls": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data for one in workers:
if strtobool(os.getenv("TEST_WITH_BROTLI")):
# A very long string that WILL trigger Brotli compression of the snapshot
# BROTLI_COMPRESS_SIZE_THRESHOLD should be set to say 200
from ..model.Watch import BROTLI_COMPRESS_SIZE_THRESHOLD
content = str(one) + "x" + str(one) * (BROTLI_COMPRESS_SIZE_THRESHOLD + 10)
else:
# Just enough to test datastore
content = str(one)+'x'
test_url = url_for('test_endpoint', content_type="text/html", content=content, _external=True)
uuids.add(client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'title': str(one)}))
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client) wait_for_all_checks(client)
duration = time.time() - now
per_worker = duration/sys_fetch_workers
if sys_fetch_workers < 20:
per_worker_threshold=0.6
elif sys_fetch_workers < 50:
per_worker_threshold = 0.8
else:
per_worker_threshold = 1.5
logger.debug(f"All fetched in {duration:.2f}s, {per_worker}s per worker")
# Problematic on github
#assert per_worker < per_worker_threshold, f"If concurrency is working good, no blocking async problems, each worker ({sys_fetch_workers} workers) should have done his job in under {per_worker_threshold}s, got {per_worker:.2f}s per worker, total duration was {duration:.2f}s"
# Essentially just triggers the DB write/update # Essentially just triggers the DB write/update
res = client.post( res = client.post(
@@ -34,7 +56,7 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
) )
assert b"Settings updated." in res.data assert b"Settings updated." in res.data
# Wait for the sync DB save to happen
time.sleep(2) time.sleep(2)
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json') json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
@@ -44,14 +66,18 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
json_obj = json.load(f) json_obj = json.load(f)
# assert the right amount of watches was found in the JSON # assert the right amount of watches was found in the JSON
assert len(json_obj['watching']) == len(r), "Correct number of watches was found in the JSON" assert len(json_obj['watching']) == len(workers), "Correct number of watches was found in the JSON"
i=0
i = 0
# each one should have a history.txt containing just one line # each one should have a history.txt containing just one line
for w in json_obj['watching'].keys(): for w in json_obj['watching'].keys():
i+=1 i += 1
history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt') history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt')
assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}" assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}"
# Should be no errors (could be from brotli etc)
assert not live_server.app.config['DATASTORE'].data['watching'][w].get('last_error')
# Same like in model.Watch # Same like in model.Watch
with open(history_txt_index_file, "r") as f: with open(history_txt_index_file, "r") as f:
tmp_history = dict(i.strip().split(',', 2) for i in f.readlines()) tmp_history = dict(i.strip().split(',', 2) for i in f.readlines())
@@ -63,15 +89,21 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
# Find the snapshot one # Find the snapshot one
for fname in files_in_watch_dir: for fname in files_in_watch_dir:
if fname != 'history.txt' and 'html' not in fname: if fname != 'history.txt' and 'html' not in fname:
if strtobool(os.getenv("TEST_WITH_BROTLI")):
assert fname.endswith('.br'), "Forced TEST_WITH_BROTLI then it should be a .br filename"
full_snapshot_history_path = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname)
# contents should match what we requested as content returned from the test url # contents should match what we requested as content returned from the test url
with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f: if fname.endswith('.br'):
contents = snapshot_f.read() with open(full_snapshot_history_path, 'rb') as f:
watch_url = json_obj['watching'][w]['url'] contents = brotli.decompress(f.read()).decode('utf-8')
u = urlparse(watch_url) else:
q = parse_qs(u[4]) with open(full_snapshot_history_path, 'r') as snapshot_f:
assert q['content'][0] == contents.strip(), f"Snapshot file {fname} should contain {q['content'][0]}" contents = snapshot_f.read()
watch_title = json_obj['watching'][w]['title']
assert json_obj['watching'][w]['title'], "Watch should have a title set"
assert contents.startswith(watch_title + "x"), f"Snapshot contents in file {fname} should start with '{watch_title}x', got '{contents}'"
assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot" assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot"
+177 -1
View File
@@ -1,7 +1,71 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from flask import url_for from flask import url_for
from .util import live_server_setup from .util import live_server_setup, wait_for_all_checks
def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
import time
test_url = url_for('test_endpoint', _external=True)
# Be sure we got a session cookie
res = client.get(url_for("watchlist.index"), follow_redirects=True)
res = client.get(
url_for("set_language", locale="zh_Hant_TW"), # Traditional
follow_redirects=True
)
# HTML follows BCP 47 language tag rules, not underscore-based locale formats.
assert b'<html lang="zh-Hant-TW"' in res.data
assert b'Cannot set language without session cookie' not in res.data
assert '選擇語言'.encode() in res.data
# Check second set works
res = client.get(
url_for("set_language", locale="en_GB"),
follow_redirects=True
)
assert b'Cannot set language without session cookie' not in res.data
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert b"Select Language" in res.data, "Second set of language worked"
# Check arbitration between zh_Hant_TW<->zh
res = client.get(
url_for("set_language", locale="zh"), # Simplified chinese
follow_redirects=True
)
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert "选择语言".encode() in res.data, "Simplified chinese worked and it means the flask-babel cache worked"
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
# The Python timeago library (https://github.com/hustcc/timeago) supports 48 locales but uses different naming conventions than Flask-Babel.
def test_zh_Hant_TW_timeago_integration():
"""Test that zh_Hant_TW mapping works and timeago renders Traditional Chinese correctly"""
import timeago
from datetime import datetime, timedelta
from changedetectionio.languages import get_timeago_locale
# 1. Test the mapping
mapped_locale = get_timeago_locale('zh_Hant_TW')
assert mapped_locale == 'zh_TW', "zh_Hant_TW should map to timeago's zh_TW"
assert get_timeago_locale('zh_TW') == 'zh_TW', "zh_TW should also map to zh_TW"
# 2. Test timeago library renders Traditional Chinese with the mapped locale
now = datetime.now()
# Test various time periods with Traditional Chinese strings
result_15s = timeago.format(now - timedelta(seconds=15), now, mapped_locale)
assert '秒前' in result_15s, f"Expected '秒前' in '{result_15s}'"
result_5m = timeago.format(now - timedelta(minutes=5), now, mapped_locale)
assert '分鐘前' in result_5m, f"Expected '分鐘前' in '{result_5m}'"
result_2h = timeago.format(now - timedelta(hours=2), now, mapped_locale)
assert '小時前' in result_2h, f"Expected '小時前' in '{result_2h}'"
result_3d = timeago.format(now - timedelta(days=3), now, mapped_locale)
assert '天前' in result_3d, f"Expected '天前' in '{result_3d}'"
def test_language_switching(client, live_server, measure_memory_usage, datastore_path): def test_language_switching(client, live_server, measure_memory_usage, datastore_path):
@@ -13,6 +77,9 @@ def test_language_switching(client, live_server, measure_memory_usage, datastore
3. Switch back to English and verify English text appears 3. Switch back to English and verify English text appears
""" """
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# Step 1: Set the language to Italian using the /set-language endpoint # Step 1: Set the language to Italian using the /set-language endpoint
res = client.get( res = client.get(
url_for("set_language", locale="it"), url_for("set_language", locale="it"),
@@ -61,6 +128,9 @@ def test_invalid_locale(client, live_server, measure_memory_usage, datastore_pat
The app should ignore invalid locales and continue working. The app should ignore invalid locales and continue working.
""" """
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# First set to English # First set to English
res = client.get( res = client.get(
url_for("set_language", locale="en"), url_for("set_language", locale="en"),
@@ -93,6 +163,9 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
within the same session. within the same session.
""" """
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# Set language to Italian # Set language to Italian
res = client.get( res = client.get(
url_for("set_language", locale="it"), url_for("set_language", locale="it"),
@@ -119,6 +192,9 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
""" """
from flask import url_for from flask import url_for
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# Set language with a redirect parameter (simulating language change from /settings) # Set language with a redirect parameter (simulating language change from /settings)
res = client.get( res = client.get(
url_for("set_language", locale="de", redirect="/settings"), url_for("set_language", locale="de", redirect="/settings"),
@@ -149,3 +225,103 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
assert res.status_code in [302, 303] assert res.status_code in [302, 303]
# Should not redirect to evil.com # Should not redirect to evil.com
assert 'evil.com' not in res.location assert 'evil.com' not in res.location
def test_time_unit_translations(client, live_server, measure_memory_usage, datastore_path):
"""
Test that time unit labels (Hours, Minutes, Seconds) and Chrome Extension
are correctly translated on the settings page for all supported languages.
"""
from flask import url_for
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# Test Italian translations
res = client.get(url_for("set_language", locale="it"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
assert res.status_code == 200
# Check that Italian translations are present (not English)
assert b"Minutes" not in res.data or b"Minuti" in res.data, "Expected Italian 'Minuti' not English 'Minutes'"
assert b"Ore" in res.data, "Expected Italian 'Ore' for Hours"
assert b"Minuti" in res.data, "Expected Italian 'Minuti' for Minutes"
assert b"Secondi" in res.data, "Expected Italian 'Secondi' for Seconds"
assert b"Estensione Chrome" in res.data, "Expected Italian 'Estensione Chrome' for Chrome Extension"
assert b"Intervallo tra controlli" in res.data, "Expected Italian 'Intervallo tra controlli' for Time Between Check"
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test Korean translations
res = client.get(url_for("set_language", locale="ko"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
assert res.status_code == 200
# Check that Korean translations are present (not English)
# Korean: Hours=시간, Minutes=분, Seconds=초, Chrome Extension=Chrome 확장 프로그램, Time Between Check=확인 간격
assert "시간".encode() in res.data, "Expected Korean '시간' for Hours"
assert "".encode() in res.data, "Expected Korean '' for Minutes"
assert "".encode() in res.data, "Expected Korean '' for Seconds"
assert "Chrome 확장 프로그램".encode() in res.data, "Expected Korean 'Chrome 확장 프로그램' for Chrome Extension"
assert "확인 간격".encode() in res.data, "Expected Korean '확인 간격' for Time Between Check"
# Make sure we don't have the incorrect translations
assert "목요일".encode() not in res.data, "Should not have '목요일' (Thursday) for Hours"
assert "무음".encode() not in res.data, "Should not have '무음' (Mute) for Minutes"
assert "Chrome 요청".encode() not in res.data, "Should not have 'Chrome 요청' (Chrome requests) for Chrome Extension"
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test Chinese Simplified translations
res = client.get(url_for("set_language", locale="zh"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
assert res.status_code == 200
# Check that Chinese translations are present
# Chinese: Hours=小时, Minutes=分钟, Seconds=秒, Chrome Extension=Chrome 扩展程序, Time Between Check=检查间隔
assert "小时".encode() in res.data, "Expected Chinese '小时' for Hours"
assert "分钟".encode() in res.data, "Expected Chinese '分钟' for Minutes"
assert "".encode() in res.data, "Expected Chinese '' for Seconds"
assert "Chrome 扩展程序".encode() in res.data, "Expected Chinese 'Chrome 扩展程序' for Chrome Extension"
assert "检查间隔".encode() in res.data, "Expected Chinese '检查间隔' for Time Between Check"
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test German translations
res = client.get(url_for("set_language", locale="de"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
assert res.status_code == 200
# Check that German translations are present
# German: Hours=Stunden, Minutes=Minuten, Seconds=Sekunden, Chrome Extension=Chrome-Erweiterung, Time Between Check=Prüfintervall
assert b"Stunden" in res.data, "Expected German 'Stunden' for Hours"
assert b"Minuten" in res.data, "Expected German 'Minuten' for Minutes"
assert b"Sekunden" in res.data, "Expected German 'Sekunden' for Seconds"
assert b"Chrome-Erweiterung" in res.data, "Expected German 'Chrome-Erweiterung' for Chrome Extension"
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test Traditional Chinese (zh_Hant_TW) translations
res = client.get(url_for("set_language", locale="zh_Hant_TW"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
assert res.status_code == 200
# Check that Traditional Chinese translations are present (not English)
# Traditional Chinese: Hours=小時, Minutes=分鐘, Seconds=秒, Chrome Extension=Chrome 擴充功能, Time Between Check=檢查間隔
assert "小時".encode() in res.data, "Expected Traditional Chinese '小時' for Hours"
assert "分鐘".encode() in res.data, "Expected Traditional Chinese '分鐘' for Minutes"
assert "".encode() in res.data, "Expected Traditional Chinese '' for Seconds"
assert "Chrome 擴充功能".encode() in res.data, "Expected Traditional Chinese 'Chrome 擴充功能' for Chrome Extension"
assert "發送測試通知".encode() in res.data, "Expected Traditional Chinese '發送測試通知' for Send test notification"
assert "通知除錯記錄".encode() in res.data, "Expected Traditional Chinese '通知除錯記錄' for Notification debug logs"
assert "檢查間隔".encode() in res.data, "Expected Traditional Chinese '檢查間隔' for Time Between Check"
# Make sure we don't have incorrect English text or wrong translations
assert b"Send test notification" not in res.data, "Should not have English 'Send test notification'"
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
assert "Chrome 請求".encode() not in res.data, "Should not have incorrect 'Chrome 請求' (Chrome requests)"
assert "使用預設通知".encode() not in res.data, "Should not have incorrect '使用預設通知' (Use default notification)"
+2 -3
View File
@@ -22,14 +22,13 @@ something to trigger<br>
def test_content_filter_live_preview(client, live_server, measure_memory_usage, datastore_path): def test_content_filter_live_preview(client, live_server, measure_memory_usage, datastore_path):
# live_server_setup(live_server) # Setup on conftest per function # live_server_setup(live_server) # Setup on conftest per function
set_response(datastore_path=datastore_path) set_response(datastore_path=datastore_path)
import time
test_url = url_for('test_endpoint', _external=True) test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b'Queued 1 watch for rechecking.' in res.data time.sleep(0.5)
wait_for_all_checks(client) wait_for_all_checks(client)
res = client.post( res = client.post(
@@ -42,6 +42,9 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
wait_for_all_checks(client)
found=False found=False
for i in range(1, 10): for i in range(1, 10):
+9 -1
View File
@@ -2,7 +2,7 @@
import time import time
from flask import url_for from flask import url_for
from .util import live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches
import os import os
@@ -87,6 +87,9 @@ def test_rss_group(client, live_server, measure_memory_usage, datastore_path):
# Wait for initial checks to complete # Wait for initial checks to complete
wait_for_all_checks(client) wait_for_all_checks(client)
# Ensure initial snapshots are saved
assert wait_for_watch_history(client, min_history_count=1, timeout=10), "Watches did not save initial snapshots"
# Trigger a change # Trigger a change
set_modified_response(datastore_path=datastore_path) set_modified_response(datastore_path=datastore_path)
@@ -94,6 +97,9 @@ def test_rss_group(client, live_server, measure_memory_usage, datastore_path):
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client) wait_for_all_checks(client)
# Ensure all watches have sufficient history for RSS generation
assert wait_for_watch_history(client, min_history_count=2, timeout=10), "Watches did not accumulate sufficient history"
# Get RSS token # Get RSS token
rss_token = extract_rss_token_from_UI(client) rss_token = extract_rss_token_from_UI(client)
assert rss_token is not None assert rss_token is not None
@@ -216,11 +222,13 @@ def test_rss_group_only_unviewed(client, live_server, measure_memory_usage, data
assert b"Watch added" in res.data assert b"Watch added" in res.data
wait_for_all_checks(client) wait_for_all_checks(client)
assert wait_for_watch_history(client, min_history_count=1, timeout=10), "Initial snapshots not saved"
# Trigger changes # Trigger changes
set_modified_response(datastore_path=datastore_path) set_modified_response(datastore_path=datastore_path)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client) wait_for_all_checks(client)
assert wait_for_watch_history(client, min_history_count=2, timeout=10), "History not accumulated"
# Get RSS token # Get RSS token
rss_token = extract_rss_token_from_UI(client) rss_token = extract_rss_token_from_UI(client)
@@ -1,8 +1,10 @@
import sys import sys
import os import os
import pytest import pytest
from changedetectionio import html_tools
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import html_tools
# test generation guide. # test generation guide.
# 1. Do not include encoding in the xml declaration if the test object is a str type. # 1. Do not include encoding in the xml declaration if the test object is a str type.
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
# coding=utf-8
"""Unit tests for html_tools.html_to_text function."""
import hashlib
import threading
import unittest
from queue import Queue
from changedetectionio.html_tools import html_to_text
class TestHtmlToText(unittest.TestCase):
"""Test html_to_text function for correctness and thread-safety."""
def test_basic_text_extraction(self):
"""Test basic HTML to text conversion."""
html = '<html><body><h1>Title</h1><p>Paragraph text.</p></body></html>'
text = html_to_text(html)
assert 'Title' in text
assert 'Paragraph text.' in text
assert '<' not in text # HTML tags should be stripped
assert '>' not in text
def test_empty_html(self):
"""Test handling of empty HTML."""
html = '<html><body></body></html>'
text = html_to_text(html)
# Should return empty or whitespace only
assert text.strip() == ''
def test_nested_elements(self):
"""Test extraction from nested HTML elements."""
html = '''
<html>
<body>
<div>
<h1>Header</h1>
<div>
<p>First paragraph</p>
<p>Second paragraph</p>
</div>
</div>
</body>
</html>
'''
text = html_to_text(html)
assert 'Header' in text
assert 'First paragraph' in text
assert 'Second paragraph' in text
def test_anchor_tag_rendering(self):
"""Test anchor tag rendering option."""
html = '<html><body><a href="https://example.com">Link text</a></body></html>'
# Without rendering anchors
text_without = html_to_text(html, render_anchor_tag_content=False)
assert 'Link text' in text_without
assert 'https://example.com' not in text_without
# With rendering anchors
text_with = html_to_text(html, render_anchor_tag_content=True)
assert 'Link text' in text_with
assert 'https://example.com' in text_with or '[Link text]' in text_with
def test_rss_mode(self):
"""Test RSS mode converts title tags to h1."""
html = '<item><title>RSS Title</title><description>Content</description></item>'
# is_rss=True should convert <title> to <h1>
text = html_to_text(html, is_rss=True)
assert 'RSS Title' in text
assert 'Content' in text
def test_special_characters(self):
"""Test handling of special characters and entities."""
html = '<html><body><p>Test &amp; &lt;special&gt; characters</p></body></html>'
text = html_to_text(html)
# Entities should be decoded
assert 'Test &' in text or 'Test &amp;' in text
assert 'special' in text
def test_whitespace_handling(self):
"""Test that whitespace is properly handled."""
html = '<html><body><p>Line 1</p><p>Line 2</p></body></html>'
text = html_to_text(html)
# Should have some separation between lines
assert 'Line 1' in text
assert 'Line 2' in text
assert text.count('\n') >= 1 # At least one newline
def test_deterministic_output(self):
"""Test that the same HTML always produces the same text."""
html = '<html><body><h1>Test</h1><p>Content here</p></body></html>'
# Extract text multiple times
results = [html_to_text(html) for _ in range(10)]
# All results should be identical
assert len(set(results)) == 1, "html_to_text should be deterministic"
def test_thread_safety_determinism(self):
"""
Test that html_to_text produces deterministic output under high concurrency.
This verifies that lxml's default parser (used by inscriptis.get_text)
is thread-safe and produces consistent results when called from multiple
threads simultaneously.
"""
html = '''
<html>
<head><title>Test Page</title></head>
<body>
<h1>Main Heading</h1>
<div class="content">
<p>First paragraph with <b>bold text</b>.</p>
<p>Second paragraph with <i>italic text</i>.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</div>
</body>
</html>
'''
results_queue = Queue()
def worker(worker_id, iterations=10):
"""Worker that converts HTML to text multiple times."""
for i in range(iterations):
text = html_to_text(html)
md5 = hashlib.md5(text.encode('utf-8')).hexdigest()
results_queue.put((worker_id, i, md5))
# Launch many threads simultaneously
num_threads = 50
threads = []
for i in range(num_threads):
t = threading.Thread(target=worker, args=(i,))
threads.append(t)
t.start()
# Wait for all threads to complete
for t in threads:
t.join()
# Collect all MD5 results
md5_values = []
while not results_queue.empty():
_, _, md5 = results_queue.get()
md5_values.append(md5)
# All MD5s should be identical
unique_md5s = set(md5_values)
assert len(unique_md5s) == 1, (
f"Thread-safety issue detected! Found {len(unique_md5s)} different MD5 values: {unique_md5s}. "
"The thread-local parser fix may not be working correctly."
)
print(f"✓ Thread-safety test passed: {len(md5_values)} conversions, all identical")
def test_thread_safety_basic(self):
"""Verify basic thread safety - multiple threads can call html_to_text simultaneously."""
results = []
errors = []
def worker():
"""Worker that converts HTML to text."""
try:
html = '<html><body><h1>Test</h1><p>Content</p></body></html>'
text = html_to_text(html)
results.append(text)
except Exception as e:
errors.append(e)
# Launch 10 threads simultaneously
threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# Should have no errors
assert len(errors) == 0, f"Thread-safety errors occurred: {errors}"
# All results should be identical
assert len(set(results)) == 1, "All threads should produce identical output"
print(f"✓ Basic thread-safety test passed: {len(results)} threads, no errors")
if __name__ == '__main__':
# Can run this file directly for quick testing
unittest.main()
+35 -2
View File
@@ -164,14 +164,45 @@ def wait_for_all_checks(client=None):
if q_length == 0 and not any_workers_busy: if q_length == 0 and not any_workers_busy:
if empty_since is None: if empty_since is None:
empty_since = time.time() empty_since = time.time()
elif time.time() - empty_since >= 0.15: # Shorter wait # Brief stabilization period for async workers
elif time.time() - empty_since >= 0.3:
break break
else: else:
empty_since = None empty_since = None
attempt += 1 attempt += 1
time.sleep(0.3) time.sleep(0.3)
def wait_for_watch_history(client, min_history_count=2, timeout=10):
"""
Wait for watches to have sufficient history entries.
Useful after wait_for_all_checks() when you need to ensure history is populated.
Args:
client: Test client with access to datastore
min_history_count: Minimum number of history entries required
timeout: Maximum time to wait in seconds
"""
datastore = client.application.config.get('DATASTORE')
start_time = time.time()
while time.time() - start_time < timeout:
all_have_history = True
for uuid, watch in datastore.data['watching'].items():
history_count = len(watch.history.keys())
if history_count < min_history_count:
all_have_history = False
break
if all_have_history:
return True
time.sleep(0.2)
# Timeout - return False
return False
# Replaced by new_live_server_setup and calling per function scope in conftest.py # Replaced by new_live_server_setup and calling per function scope in conftest.py
def live_server_setup(live_server): def live_server_setup(live_server):
return True return True
@@ -189,6 +220,8 @@ def new_live_server_setup(live_server):
@live_server.app.route('/test-endpoint') @live_server.app.route('/test-endpoint')
def test_endpoint(): def test_endpoint():
from loguru import logger
logger.debug(f"/test-endpoint hit {request}")
ctype = request.args.get('content_type') ctype = request.args.get('content_type')
status_code = request.args.get('status_code') status_code = request.args.get('status_code')
content = request.args.get('content') or None content = request.args.get('content') or None
@@ -144,7 +144,6 @@ def test_basic_browserstep(client, live_server, measure_memory_usage, datastore_
def test_non_200_errors_report_browsersteps(client, live_server, measure_memory_usage, datastore_path): def test_non_200_errors_report_browsersteps(client, live_server, measure_memory_usage, datastore_path):
four_o_four_url = url_for('test_endpoint', status_code=404, _external=True) four_o_four_url = url_for('test_endpoint', status_code=404, _external=True)
four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio') four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio')
four_o_four_url = four_o_four_url.replace('localhost', 'cdio') four_o_four_url = four_o_four_url.replace('localhost', 'cdio')
@@ -186,3 +185,65 @@ def test_non_200_errors_report_browsersteps(client, live_server, measure_memory_
url_for("ui.form_delete", uuid="all"), url_for("ui.form_delete", uuid="all"),
follow_redirects=True follow_redirects=True
) )
def test_browsersteps_edit_UI_startsession(client, live_server, measure_memory_usage, datastore_path):
assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
# Add a watch first
test_url = url_for('test_interactive_html_endpoint', _external=True)
test_url = test_url.replace('localhost.localdomain', 'cdio')
test_url = test_url.replace('localhost', 'cdio')
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'fetch_backend': 'html_webdriver', 'paused': True})
# Test starting a browsersteps session
res = client.get(
url_for("browser_steps.browsersteps_start_session", uuid=uuid),
follow_redirects=True
)
assert res.status_code == 200
assert res.is_json
json_data = res.get_json()
assert 'browsersteps_session_id' in json_data
assert json_data['browsersteps_session_id'] # Not empty
browsersteps_session_id = json_data['browsersteps_session_id']
# Verify the session exists in browsersteps_sessions
from changedetectionio.blueprint.browser_steps import browsersteps_sessions, browsersteps_watch_to_session
assert browsersteps_session_id in browsersteps_sessions
assert uuid in browsersteps_watch_to_session
assert browsersteps_watch_to_session[uuid] == browsersteps_session_id
# Verify browsersteps UI shows up on edit page
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
assert b'browsersteps-click-start' in res.data, "Browsersteps manual UI shows up"
# Session should still exist after GET (not cleaned up yet)
assert browsersteps_session_id in browsersteps_sessions
assert uuid in browsersteps_watch_to_session
# Test cleanup happens on save (POST)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
"url": test_url,
"tags": "",
'fetch_backend': "html_webdriver",
"time_between_check_use_default": "y",
},
follow_redirects=True
)
assert b"Updated watch" in res.data
# NOW verify the session was cleaned up after save
assert browsersteps_session_id not in browsersteps_sessions
assert uuid not in browsersteps_watch_to_session
# Cleanup
client.get(
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: changedetection.io\n" "Project-Id-Version: changedetection.io\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n" "Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n" "POT-Creation-Date: 2026-01-02 16:07+0100\n"
"PO-Revision-Date: 2026-01-12 16:33+0100\n" "PO-Revision-Date: 2026-01-12 16:33+0100\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.10.3\n" "Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225 #: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
@@ -38,15 +38,11 @@ msgid "Incorrect password"
msgstr "" msgstr ""
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243 #: changedetectionio/forms.py:63 changedetectionio/forms.py:243
msgid "" msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified."
msgstr "" msgstr ""
#: changedetectionio/forms.py:64 #: changedetectionio/forms.py:64
msgid "" msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified when not using global settings."
msgstr "" msgstr ""
#: changedetectionio/forms.py:164 #: changedetectionio/forms.py:164
@@ -589,9 +585,7 @@ msgid "A backup is running!"
msgstr "" msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:13 #: changedetectionio/blueprint/backups/templates/overview.html:13
msgid "" msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
"Here you can download and request a new backup, when a backup is "
"completed you will see it listed below."
msgstr "" msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:19 #: changedetectionio/blueprint/backups/templates/overview.html:19
@@ -611,9 +605,7 @@ msgid "Remove backups"
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/importer.py:45 #: changedetectionio/blueprint/imports/importer.py:45
msgid "" msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
"Importing 5,000 of the first URLs from your list, the rest can be "
"imported again."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/importer.py:78 #: changedetectionio/blueprint/imports/importer.py:78
@@ -648,9 +640,7 @@ msgstr ""
#: changedetectionio/blueprint/imports/importer.py:214 #: changedetectionio/blueprint/imports/importer.py:214
#: changedetectionio/blueprint/imports/importer.py:297 #: changedetectionio/blueprint/imports/importer.py:297
#, python-brace-format #, python-brace-format
msgid "" msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
"Error processing row number {}, check all cell data types are correct, "
"row was skipped."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/importer.py:218 #: changedetectionio/blueprint/imports/importer.py:218
@@ -676,9 +666,7 @@ msgid ".XLSX & Wachete"
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:20 #: changedetectionio/blueprint/imports/templates/import.html:20
msgid "" msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
"Enter one URL per line, and optionally add tags for each URL after a "
"space, delineated by comma (,):"
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:22 #: changedetectionio/blueprint/imports/templates/import.html:22
@@ -690,9 +678,7 @@ msgid "URLs which do not pass validation will stay in the textarea."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:44 #: changedetectionio/blueprint/imports/templates/import.html:44
msgid "" msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
" file."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:45 #: changedetectionio/blueprint/imports/templates/import.html:45
@@ -771,12 +757,17 @@ msgstr ""
msgid "Password protection removed." msgid "Password protection removed."
msgstr "" msgstr ""
#: changedetectionio/blueprint/settings/__init__.py:98 #: changedetectionio/blueprint/settings/__init__.py:92
#, python-brace-format
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py:104
#, python-brace-format #, python-brace-format
msgid "Worker count adjusted: {}" msgid "Worker count adjusted: {}"
msgstr "" msgstr ""
#: changedetectionio/blueprint/settings/__init__.py:100 #: changedetectionio/blueprint/settings/__init__.py:106
msgid "Dynamic worker adjustment not supported for sync workers" msgid "Dynamic worker adjustment not supported for sync workers"
msgstr "" msgstr ""
@@ -985,9 +976,7 @@ msgid "Watch group / tag"
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21 #: changedetectionio/blueprint/tags/templates/groups-overview.html:21
msgid "" msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
"Groups allows you to manage filters and notifications for multiple "
"watches under a single organisational tag."
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31 #: changedetectionio/blueprint/tags/templates/groups-overview.html:31
@@ -1013,9 +1002,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59 #: changedetectionio/blueprint/tags/templates/groups-overview.html:59
#, python-format #, python-format
msgid "" msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
"<p>Are you sure you want to delete group "
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60 #: changedetectionio/blueprint/tags/templates/groups-overview.html:60
@@ -1035,10 +1022,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67 #: changedetectionio/blueprint/tags/templates/groups-overview.html:67
#, python-format #, python-format
msgid "" msgid "<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>"
"<p>Are you sure you want to unlink all watches from group "
"<strong>%(title)s</strong>?</p><p>The tag will be kept but watches will "
"be removed from it.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68 #: changedetectionio/blueprint/tags/templates/groups-overview.html:68
@@ -1144,7 +1128,7 @@ msgstr ""
msgid "Queued 1 watch for rechecking." msgid "Queued 1 watch for rechecking."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:257 #: changedetectionio/blueprint/ui/__init__.py:283
#, python-brace-format #, python-brace-format
msgid "Queued {} watches for rechecking." msgid "Queued {} watches for rechecking."
msgstr "" msgstr ""
@@ -1155,9 +1139,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:330 #: changedetectionio/blueprint/ui/__init__.py:330
#, python-brace-format #, python-brace-format
msgid "" msgid "Could not share, something went wrong while communicating with the share server - {}"
"Could not share, something went wrong while communicating with the share "
"server - {}"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/diff.py:93 #: changedetectionio/blueprint/ui/diff.py:93
@@ -1170,9 +1152,7 @@ msgid "No history found for the specified link, bad link?"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/diff.py:98 #: changedetectionio/blueprint/ui/diff.py:98
msgid "" msgid "Not enough history (2 snapshots required) to show difference page for this watch."
"Not enough history (2 snapshots required) to show difference page for "
"this watch."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/edit.py:35 #: changedetectionio/blueprint/ui/edit.py:35
@@ -1220,9 +1200,7 @@ msgid "Watch added."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12 #: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
msgid "" msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
"This will remove version history (snapshots) for ALL watches, but keep "
"your list of URLs!"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13 #: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
@@ -1404,9 +1382,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:144 #: changedetectionio/blueprint/ui/templates/diff.html:144
#: changedetectionio/blueprint/ui/templates/preview.html:80 #: changedetectionio/blueprint/ui/templates/preview.html:80
msgid "" msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
"For now, Differences are performed on text, not graphically, only the "
"latest screenshot is available."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:149 #: changedetectionio/blueprint/ui/templates/diff.html:149
@@ -1468,9 +1444,7 @@ msgid "Organisational tag/group name used in the main listing page"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:85 #: changedetectionio/blueprint/ui/templates/edit.html:85
msgid "" msgid "Automatically uses the page title if found, you can also use your own title/description here"
"Automatically uses the page title if found, you can also use your own "
"title/description here"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:95 #: changedetectionio/blueprint/ui/templates/edit.html:95
@@ -1478,10 +1452,7 @@ msgid "The interval/amount of time between each check."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:110 #: changedetectionio/blueprint/ui/templates/edit.html:110
msgid "" msgid "Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore."
"Sends a notification when the filter can no longer be seen on the page, "
"good for knowing when the page changed and your filter will not work "
"anymore."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123 #: changedetectionio/blueprint/ui/templates/edit.html:123
@@ -1493,9 +1464,7 @@ msgid "Basic"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123 #: changedetectionio/blueprint/ui/templates/edit.html:123
msgid "" msgid "method (default) where your watched site doesn't need Javascript to render."
"method (default) where your watched site doesn't need Javascript to "
"render."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124 #: changedetectionio/blueprint/ui/templates/edit.html:124
@@ -1507,9 +1476,7 @@ msgid "Chrome/Javascript"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124 #: changedetectionio/blueprint/ui/templates/edit.html:124
msgid "" msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
"method requires a network connection to a running WebDriver+Chrome "
"server, set by the ENV var 'WEBDRIVER_URL'."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:125 #: changedetectionio/blueprint/ui/templates/edit.html:125
@@ -1525,9 +1492,7 @@ msgid "Choose a proxy for this watch"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:143 #: changedetectionio/blueprint/ui/templates/edit.html:143
msgid "" msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
"If you're having trouble waiting for the page to be fully rendered (text "
"missing etc), try increasing the 'wait' time here."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:145 #: changedetectionio/blueprint/ui/templates/edit.html:145
@@ -1548,9 +1513,7 @@ msgid "Show advanced options"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:157 #: changedetectionio/blueprint/ui/templates/edit.html:157
msgid "" msgid "Run this code before performing change detection, handy for filling in fields and other actions"
"Run this code before performing change detection, handy for filling in "
"fields and other actions"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:158 #: changedetectionio/blueprint/ui/templates/edit.html:158
@@ -1607,9 +1570,7 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:253 #: changedetectionio/blueprint/ui/templates/edit.html:253
msgid "" msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
"Sorry, this functionality only works with fetchers that support "
"interactive Javascript (so far only Playwright based fetchers)"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:254 #: changedetectionio/blueprint/ui/templates/edit.html:254
@@ -1627,9 +1588,7 @@ msgid "to one that supports interactive Javascript."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:297 #: changedetectionio/blueprint/ui/templates/edit.html:297
msgid "" msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
"Use the verify (✓) button to test if a condition passes against the "
"current snapshot."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:298 #: changedetectionio/blueprint/ui/templates/edit.html:298
@@ -1657,9 +1616,7 @@ msgid "Limit trigger/ignore/block/extract to;"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326 #: changedetectionio/blueprint/ui/templates/edit.html:326
msgid "" msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
"Note: Depending on the length and similarity of the text on each line, "
"the algorithm may consider an"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326 #: changedetectionio/blueprint/ui/templates/edit.html:326
@@ -1700,16 +1657,11 @@ msgid "Only trigger when unique lines appear"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:332 #: changedetectionio/blueprint/ui/templates/edit.html:332
msgid "" msgid "Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch."
"Good for websites that just move the content around, and you want to know"
" when NEW content is added, compares new lines against all history for "
"this watch."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340 #: changedetectionio/blueprint/ui/templates/edit.html:340
msgid "" msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
"Helps reduce changes detected caused by sites shuffling lines around, "
"combine with"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340 #: changedetectionio/blueprint/ui/templates/edit.html:340
@@ -1739,9 +1691,7 @@ msgid "text"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386 #: changedetectionio/blueprint/ui/templates/edit.html:386
msgid "" msgid "elements that will be used for the change detection. It automatically fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
"elements that will be used for the change detection. It automatically "
"fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386 #: changedetectionio/blueprint/ui/templates/edit.html:386
@@ -1781,9 +1731,7 @@ msgid "Currently:"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:423 #: changedetectionio/blueprint/ui/templates/edit.html:423
msgid "" msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
"Sorry, this functionality only works with fetchers that support "
"Javascript and screenshots (such as playwright etc)."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:424 #: changedetectionio/blueprint/ui/templates/edit.html:424
@@ -1839,9 +1787,7 @@ msgid "Are you sure you want to clear all history for:"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:496 #: changedetectionio/blueprint/ui/templates/edit.html:496
msgid "" msgid "This will remove all snapshots and previous versions. This action cannot be undone."
"This will remove all snapshots and previous versions. This action cannot "
"be undone."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:497 #: changedetectionio/blueprint/ui/templates/edit.html:497
@@ -1865,9 +1811,7 @@ msgid "Current erroring screenshot from most recent request"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/preview.html:91 #: changedetectionio/blueprint/ui/templates/preview.html:91
msgid "" msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
") that supports screenshots."
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
@@ -1936,9 +1880,7 @@ msgid "Clear Histories"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
msgid "" msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
"<p>Are you sure you want to clear history for the selected "
"items?</p><p>This action cannot be undone.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
@@ -1954,9 +1896,7 @@ msgid "Delete Watches?"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
msgid "" msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
"<p>Are you sure you want to delete the selected "
"watches?</strong></p><p>This action cannot be undone.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
@@ -1989,7 +1929,7 @@ msgid "Changed"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No website watches configured, please add a URL in the box above, or" msgid "No web page change detection watches configured, please add a URL in the box above, or"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
@@ -2213,7 +2153,7 @@ msgid "IMPORT"
msgstr "" msgstr ""
#: changedetectionio/templates/base.html:86 #: changedetectionio/templates/base.html:86
msgid "BACKUPS" msgid "Backups"
msgstr "" msgstr ""
#: changedetectionio/templates/base.html:90 #: changedetectionio/templates/base.html:90
@@ -2253,9 +2193,7 @@ msgid "Select Language"
msgstr "" msgstr ""
#: changedetectionio/templates/base.html:270 #: changedetectionio/templates/base.html:270
msgid "" msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr "" msgstr ""
#: changedetectionio/templates/login.html:10 #: changedetectionio/templates/login.html:10
@@ -2266,3 +2204,150 @@ msgstr ""
msgid "Login" msgid "Login"
msgstr "" msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:18
#: changedetectionio/widgets/ternary_boolean.py:72
msgid "Yes"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:19
#: changedetectionio/widgets/ternary_boolean.py:73
msgid "No"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:20
#: changedetectionio/widgets/ternary_boolean.py:74
msgid "Main settings"
msgstr ""
#: changedetectionio/templates/_common_fields.html:11
msgid "Show token/placeholders"
msgstr ""
#: changedetectionio/templates/_common_fields.html:18
msgid "Token"
msgstr ""
#: changedetectionio/templates/_common_fields.html:19
msgid "Description"
msgstr ""
#: changedetectionio/templates/_common_fields.html:128
msgid "Show advanced help and tips"
msgstr ""
#: changedetectionio/templates/_common_fields.html:138
msgid "Send test notification"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add email"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add an email address"
msgstr ""
#: changedetectionio/templates/_common_fields.html:142
msgid "Notification debug logs"
msgstr ""
#: changedetectionio/templates/_common_fields.html:144
msgid "Processing.."
msgstr ""
#: changedetectionio/templates/_common_fields.html:151
msgid "Title for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html:176
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:9
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
msgstr ""
#: changedetectionio/templates/edit/text-options.html:10
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:11
msgid "Each line is processed separately (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:12
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:23
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:24
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:25
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:26
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:43
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:44
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:45
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:58
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:60
msgid "Regular expression - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:61
msgid "Don't forget to consider the white-space at the start of a line"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "Use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "type flags (more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "information here"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:63
msgid "Keyword example - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "Use groups to extract just that text - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "returns a list of years only"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:65
msgid "Example - match lines containing a keyword"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:68
msgid "One line per regular-expression/string match"
msgstr ""
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.10.3\n" "Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225 #: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
@@ -38,15 +38,11 @@ msgid "Incorrect password"
msgstr "" msgstr ""
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243 #: changedetectionio/forms.py:63 changedetectionio/forms.py:243
msgid "" msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified."
msgstr "" msgstr ""
#: changedetectionio/forms.py:64 #: changedetectionio/forms.py:64
msgid "" msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified when not using global settings."
msgstr "" msgstr ""
#: changedetectionio/forms.py:164 #: changedetectionio/forms.py:164
@@ -589,9 +585,7 @@ msgid "A backup is running!"
msgstr "" msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:13 #: changedetectionio/blueprint/backups/templates/overview.html:13
msgid "" msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
"Here you can download and request a new backup, when a backup is "
"completed you will see it listed below."
msgstr "" msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:19 #: changedetectionio/blueprint/backups/templates/overview.html:19
@@ -611,9 +605,7 @@ msgid "Remove backups"
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/importer.py:45 #: changedetectionio/blueprint/imports/importer.py:45
msgid "" msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
"Importing 5,000 of the first URLs from your list, the rest can be "
"imported again."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/importer.py:78 #: changedetectionio/blueprint/imports/importer.py:78
@@ -648,9 +640,7 @@ msgstr ""
#: changedetectionio/blueprint/imports/importer.py:214 #: changedetectionio/blueprint/imports/importer.py:214
#: changedetectionio/blueprint/imports/importer.py:297 #: changedetectionio/blueprint/imports/importer.py:297
#, python-brace-format #, python-brace-format
msgid "" msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
"Error processing row number {}, check all cell data types are correct, "
"row was skipped."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/importer.py:218 #: changedetectionio/blueprint/imports/importer.py:218
@@ -676,9 +666,7 @@ msgid ".XLSX & Wachete"
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:20 #: changedetectionio/blueprint/imports/templates/import.html:20
msgid "" msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
"Enter one URL per line, and optionally add tags for each URL after a "
"space, delineated by comma (,):"
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:22 #: changedetectionio/blueprint/imports/templates/import.html:22
@@ -690,9 +678,7 @@ msgid "URLs which do not pass validation will stay in the textarea."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:44 #: changedetectionio/blueprint/imports/templates/import.html:44
msgid "" msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
" file."
msgstr "" msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:45 #: changedetectionio/blueprint/imports/templates/import.html:45
@@ -771,12 +757,17 @@ msgstr ""
msgid "Password protection removed." msgid "Password protection removed."
msgstr "" msgstr ""
#: changedetectionio/blueprint/settings/__init__.py:98 #: changedetectionio/blueprint/settings/__init__.py:92
#, python-brace-format
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py:104
#, python-brace-format #, python-brace-format
msgid "Worker count adjusted: {}" msgid "Worker count adjusted: {}"
msgstr "" msgstr ""
#: changedetectionio/blueprint/settings/__init__.py:100 #: changedetectionio/blueprint/settings/__init__.py:106
msgid "Dynamic worker adjustment not supported for sync workers" msgid "Dynamic worker adjustment not supported for sync workers"
msgstr "" msgstr ""
@@ -985,9 +976,7 @@ msgid "Watch group / tag"
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21 #: changedetectionio/blueprint/tags/templates/groups-overview.html:21
msgid "" msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
"Groups allows you to manage filters and notifications for multiple "
"watches under a single organisational tag."
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31 #: changedetectionio/blueprint/tags/templates/groups-overview.html:31
@@ -1013,9 +1002,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59 #: changedetectionio/blueprint/tags/templates/groups-overview.html:59
#, python-format #, python-format
msgid "" msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
"<p>Are you sure you want to delete group "
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60 #: changedetectionio/blueprint/tags/templates/groups-overview.html:60
@@ -1035,10 +1022,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67 #: changedetectionio/blueprint/tags/templates/groups-overview.html:67
#, python-format #, python-format
msgid "" msgid "<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>"
"<p>Are you sure you want to unlink all watches from group "
"<strong>%(title)s</strong>?</p><p>The tag will be kept but watches will "
"be removed from it.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68 #: changedetectionio/blueprint/tags/templates/groups-overview.html:68
@@ -1144,7 +1128,7 @@ msgstr ""
msgid "Queued 1 watch for rechecking." msgid "Queued 1 watch for rechecking."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:257 #: changedetectionio/blueprint/ui/__init__.py:283
#, python-brace-format #, python-brace-format
msgid "Queued {} watches for rechecking." msgid "Queued {} watches for rechecking."
msgstr "" msgstr ""
@@ -1155,9 +1139,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:330 #: changedetectionio/blueprint/ui/__init__.py:330
#, python-brace-format #, python-brace-format
msgid "" msgid "Could not share, something went wrong while communicating with the share server - {}"
"Could not share, something went wrong while communicating with the share "
"server - {}"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/diff.py:93 #: changedetectionio/blueprint/ui/diff.py:93
@@ -1170,9 +1152,7 @@ msgid "No history found for the specified link, bad link?"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/diff.py:98 #: changedetectionio/blueprint/ui/diff.py:98
msgid "" msgid "Not enough history (2 snapshots required) to show difference page for this watch."
"Not enough history (2 snapshots required) to show difference page for "
"this watch."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/edit.py:35 #: changedetectionio/blueprint/ui/edit.py:35
@@ -1220,9 +1200,7 @@ msgid "Watch added."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12 #: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
msgid "" msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
"This will remove version history (snapshots) for ALL watches, but keep "
"your list of URLs!"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13 #: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
@@ -1404,9 +1382,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:144 #: changedetectionio/blueprint/ui/templates/diff.html:144
#: changedetectionio/blueprint/ui/templates/preview.html:80 #: changedetectionio/blueprint/ui/templates/preview.html:80
msgid "" msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
"For now, Differences are performed on text, not graphically, only the "
"latest screenshot is available."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:149 #: changedetectionio/blueprint/ui/templates/diff.html:149
@@ -1468,9 +1444,7 @@ msgid "Organisational tag/group name used in the main listing page"
msgstr "organizational tag/group name used in the main listing page" msgstr "organizational tag/group name used in the main listing page"
#: changedetectionio/blueprint/ui/templates/edit.html:85 #: changedetectionio/blueprint/ui/templates/edit.html:85
msgid "" msgid "Automatically uses the page title if found, you can also use your own title/description here"
"Automatically uses the page title if found, you can also use your own "
"title/description here"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:95 #: changedetectionio/blueprint/ui/templates/edit.html:95
@@ -1478,10 +1452,7 @@ msgid "The interval/amount of time between each check."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:110 #: changedetectionio/blueprint/ui/templates/edit.html:110
msgid "" msgid "Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore."
"Sends a notification when the filter can no longer be seen on the page, "
"good for knowing when the page changed and your filter will not work "
"anymore."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123 #: changedetectionio/blueprint/ui/templates/edit.html:123
@@ -1493,9 +1464,7 @@ msgid "Basic"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123 #: changedetectionio/blueprint/ui/templates/edit.html:123
msgid "" msgid "method (default) where your watched site doesn't need Javascript to render."
"method (default) where your watched site doesn't need Javascript to "
"render."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124 #: changedetectionio/blueprint/ui/templates/edit.html:124
@@ -1507,9 +1476,7 @@ msgid "Chrome/Javascript"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124 #: changedetectionio/blueprint/ui/templates/edit.html:124
msgid "" msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
"method requires a network connection to a running WebDriver+Chrome "
"server, set by the ENV var 'WEBDRIVER_URL'."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:125 #: changedetectionio/blueprint/ui/templates/edit.html:125
@@ -1525,9 +1492,7 @@ msgid "Choose a proxy for this watch"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:143 #: changedetectionio/blueprint/ui/templates/edit.html:143
msgid "" msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
"If you're having trouble waiting for the page to be fully rendered (text "
"missing etc), try increasing the 'wait' time here."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:145 #: changedetectionio/blueprint/ui/templates/edit.html:145
@@ -1548,9 +1513,7 @@ msgid "Show advanced options"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:157 #: changedetectionio/blueprint/ui/templates/edit.html:157
msgid "" msgid "Run this code before performing change detection, handy for filling in fields and other actions"
"Run this code before performing change detection, handy for filling in "
"fields and other actions"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:158 #: changedetectionio/blueprint/ui/templates/edit.html:158
@@ -1607,9 +1570,7 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:253 #: changedetectionio/blueprint/ui/templates/edit.html:253
msgid "" msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
"Sorry, this functionality only works with fetchers that support "
"interactive Javascript (so far only Playwright based fetchers)"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:254 #: changedetectionio/blueprint/ui/templates/edit.html:254
@@ -1627,9 +1588,7 @@ msgid "to one that supports interactive Javascript."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:297 #: changedetectionio/blueprint/ui/templates/edit.html:297
msgid "" msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
"Use the verify (✓) button to test if a condition passes against the "
"current snapshot."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:298 #: changedetectionio/blueprint/ui/templates/edit.html:298
@@ -1657,9 +1616,7 @@ msgid "Limit trigger/ignore/block/extract to;"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326 #: changedetectionio/blueprint/ui/templates/edit.html:326
msgid "" msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
"Note: Depending on the length and similarity of the text on each line, "
"the algorithm may consider an"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326 #: changedetectionio/blueprint/ui/templates/edit.html:326
@@ -1700,16 +1657,11 @@ msgid "Only trigger when unique lines appear"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:332 #: changedetectionio/blueprint/ui/templates/edit.html:332
msgid "" msgid "Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch."
"Good for websites that just move the content around, and you want to know"
" when NEW content is added, compares new lines against all history for "
"this watch."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340 #: changedetectionio/blueprint/ui/templates/edit.html:340
msgid "" msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
"Helps reduce changes detected caused by sites shuffling lines around, "
"combine with"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340 #: changedetectionio/blueprint/ui/templates/edit.html:340
@@ -1739,9 +1691,7 @@ msgid "text"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386 #: changedetectionio/blueprint/ui/templates/edit.html:386
msgid "" msgid "elements that will be used for the change detection. It automatically fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
"elements that will be used for the change detection. It automatically "
"fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386 #: changedetectionio/blueprint/ui/templates/edit.html:386
@@ -1781,9 +1731,7 @@ msgid "Currently:"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:423 #: changedetectionio/blueprint/ui/templates/edit.html:423
msgid "" msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
"Sorry, this functionality only works with fetchers that support "
"Javascript and screenshots (such as playwright etc)."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:424 #: changedetectionio/blueprint/ui/templates/edit.html:424
@@ -1839,9 +1787,7 @@ msgid "Are you sure you want to clear all history for:"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:496 #: changedetectionio/blueprint/ui/templates/edit.html:496
msgid "" msgid "This will remove all snapshots and previous versions. This action cannot be undone."
"This will remove all snapshots and previous versions. This action cannot "
"be undone."
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:497 #: changedetectionio/blueprint/ui/templates/edit.html:497
@@ -1865,9 +1811,7 @@ msgid "Current erroring screenshot from most recent request"
msgstr "" msgstr ""
#: changedetectionio/blueprint/ui/templates/preview.html:91 #: changedetectionio/blueprint/ui/templates/preview.html:91
msgid "" msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
") that supports screenshots."
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
@@ -1936,9 +1880,7 @@ msgid "Clear Histories"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
msgid "" msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
"<p>Are you sure you want to clear history for the selected "
"items?</p><p>This action cannot be undone.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
@@ -1954,9 +1896,7 @@ msgid "Delete Watches?"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
msgid "" msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
"<p>Are you sure you want to delete the selected "
"watches?</strong></p><p>This action cannot be undone.</p>"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
@@ -1989,7 +1929,7 @@ msgid "Changed"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No website watches configured, please add a URL in the box above, or" msgid "No web page change detection watches configured, please add a URL in the box above, or"
msgstr "" msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130 #: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
@@ -2213,7 +2153,7 @@ msgid "IMPORT"
msgstr "" msgstr ""
#: changedetectionio/templates/base.html:86 #: changedetectionio/templates/base.html:86
msgid "BACKUPS" msgid "Backups"
msgstr "" msgstr ""
#: changedetectionio/templates/base.html:90 #: changedetectionio/templates/base.html:90
@@ -2253,9 +2193,7 @@ msgid "Select Language"
msgstr "" msgstr ""
#: changedetectionio/templates/base.html:270 #: changedetectionio/templates/base.html:270
msgid "" msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr "" msgstr ""
#: changedetectionio/templates/login.html:10 #: changedetectionio/templates/login.html:10
@@ -2266,3 +2204,150 @@ msgstr ""
msgid "Login" msgid "Login"
msgstr "" msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:18
#: changedetectionio/widgets/ternary_boolean.py:72
msgid "Yes"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:19
#: changedetectionio/widgets/ternary_boolean.py:73
msgid "No"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:20
#: changedetectionio/widgets/ternary_boolean.py:74
msgid "Main settings"
msgstr ""
#: changedetectionio/templates/_common_fields.html:11
msgid "Show token/placeholders"
msgstr ""
#: changedetectionio/templates/_common_fields.html:18
msgid "Token"
msgstr ""
#: changedetectionio/templates/_common_fields.html:19
msgid "Description"
msgstr ""
#: changedetectionio/templates/_common_fields.html:128
msgid "Show advanced help and tips"
msgstr ""
#: changedetectionio/templates/_common_fields.html:138
msgid "Send test notification"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add email"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add an email address"
msgstr ""
#: changedetectionio/templates/_common_fields.html:142
msgid "Notification debug logs"
msgstr ""
#: changedetectionio/templates/_common_fields.html:144
msgid "Processing.."
msgstr ""
#: changedetectionio/templates/_common_fields.html:151
msgid "Title for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html:176
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:9
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
msgstr ""
#: changedetectionio/templates/edit/text-options.html:10
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:11
msgid "Each line is processed separately (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:12
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:23
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:24
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:25
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:26
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:43
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:44
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:45
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:58
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:60
msgid "Regular expression - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:61
msgid "Don't forget to consider the white-space at the start of a line"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "Use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "type flags (more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "information here"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:63
msgid "Keyword example - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "Use groups to extract just that text - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "returns a list of years only"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:65
msgid "Example - match lines containing a keyword"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:68
msgid "One line per regular-expression/string match"
msgstr ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -64,6 +64,19 @@ def is_safe_valid_url(test_url):
import re import re
import validators import validators
# Validate input type first - must be a non-empty string
if test_url is None:
logger.warning('URL validation failed: URL is None')
return False
if not isinstance(test_url, str):
logger.warning(f'URL validation failed: URL must be a string, got {type(test_url).__name__}')
return False
if not test_url.strip():
logger.warning('URL validation failed: URL is empty or whitespace only')
return False
allow_file_access = strtobool(os.getenv('ALLOW_FILE_URI', 'false')) allow_file_access = strtobool(os.getenv('ALLOW_FILE_URI', 'false'))
safe_protocol_regex = '^(http|https|ftp|file):' if allow_file_access else '^(http|https|ftp):' safe_protocol_regex = '^(http|https|ftp|file):' if allow_file_access else '^(http|https|ftp):'
+9 -9
View File
@@ -1,6 +1,6 @@
from wtforms import Field from wtforms import Field
from wtforms import widgets
from markupsafe import Markup from markupsafe import Markup
from flask_babel import lazy_gettext as _l
class TernaryNoneBooleanWidget: class TernaryNoneBooleanWidget:
""" """
@@ -14,9 +14,9 @@ class TernaryNoneBooleanWidget:
boolean_mode = getattr(field, 'boolean_mode', False) boolean_mode = getattr(field, 'boolean_mode', False)
# Get custom text or use defaults # Get custom text or use defaults
yes_text = getattr(field, 'yes_text', 'Yes') yes_text = getattr(field, 'yes_text', _l('Yes'))
no_text = getattr(field, 'no_text', 'No') no_text = getattr(field, 'no_text', _l('No'))
none_text = getattr(field, 'none_text', 'Main settings') none_text = getattr(field, 'none_text', _l('Main settings'))
# True option # True option
checked_true = ' checked' if field.data is True else '' checked_true = ' checked' if field.data is True else ''
@@ -63,14 +63,14 @@ class TernaryNoneBooleanField(Field):
""" """
widget = TernaryNoneBooleanWidget() widget = TernaryNoneBooleanWidget()
def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False, def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,
yes_text="Yes", no_text="No", none_text="Main settings", **kwargs): yes_text=None, no_text=None, none_text=None, **kwargs):
super(TernaryNoneBooleanField, self).__init__(label, validators, **kwargs) super(TernaryNoneBooleanField, self).__init__(label, validators, **kwargs)
self.boolean_mode = boolean_mode self.boolean_mode = boolean_mode
self.yes_text = yes_text self.yes_text = yes_text if yes_text is not None else _l('Yes')
self.no_text = no_text self.no_text = no_text if no_text is not None else _l('No')
self.none_text = none_text self.none_text = none_text if none_text is not None else _l('Main settings')
if false_values is None: if false_values is None:
self.false_values = {'false', ''} self.false_values = {'false', ''}
+169 -185
View File
@@ -2,19 +2,18 @@
Worker management module for changedetection.io Worker management module for changedetection.io
Handles asynchronous workers for dynamic worker scaling. Handles asynchronous workers for dynamic worker scaling.
Sync worker support has been removed in favor of async-only architecture. Each worker runs in its own thread with its own event loop for isolation.
""" """
import asyncio import asyncio
import os import os
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor
from loguru import logger from loguru import logger
# Global worker state # Global worker state - each worker has its own thread and event loop
running_async_tasks = [] worker_threads = [] # List of WorkerThread objects
async_loop = None
async_loop_thread = None
# Track currently processing UUIDs for async workers - maps {uuid: worker_id} # Track currently processing UUIDs for async workers - maps {uuid: worker_id}
currently_processing_uuids = {} currently_processing_uuids = {}
@@ -22,89 +21,118 @@ currently_processing_uuids = {}
# Configuration - async workers only # Configuration - async workers only
USE_ASYNC_WORKERS = True USE_ASYNC_WORKERS = True
# Custom ThreadPoolExecutor for queue operations with named threads
# Scale executor threads with FETCH_WORKERS to avoid bottleneck at high concurrency
_max_executor_workers = max(50, int(os.getenv("FETCH_WORKERS", "10")))
queue_executor = ThreadPoolExecutor(
max_workers=_max_executor_workers,
thread_name_prefix="QueueGetter-"
)
def start_async_event_loop():
"""Start a dedicated event loop for async workers in a separate thread""" class WorkerThread:
global async_loop """Container for a worker thread with its own event loop"""
logger.info("Starting async event loop for workers") def __init__(self, worker_id, update_q, notification_q, app, datastore):
self.worker_id = worker_id
try: self.update_q = update_q
# Create a new event loop for this thread self.notification_q = notification_q
async_loop = asyncio.new_event_loop() self.app = app
# Set it as the event loop for this thread self.datastore = datastore
asyncio.set_event_loop(async_loop) self.thread = None
self.loop = None
logger.debug(f"Event loop created and set: {async_loop}") self.running = False
# Run the event loop forever def run(self):
async_loop.run_forever() """Run the worker in its own event loop"""
except Exception as e: try:
logger.error(f"Async event loop error: {e}") # Create a new event loop for this thread
finally: self.loop = asyncio.new_event_loop()
# Clean up asyncio.set_event_loop(self.loop)
if async_loop and not async_loop.is_closed(): self.running = True
async_loop.close()
async_loop = None # Run the worker coroutine
logger.info("Async event loop stopped") self.loop.run_until_complete(
start_single_async_worker(
self.worker_id,
self.update_q,
self.notification_q,
self.app,
self.datastore,
queue_executor
)
)
except asyncio.CancelledError:
# Normal shutdown - worker was cancelled
import os
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
if not in_pytest:
logger.info(f"Worker {self.worker_id} shutting down gracefully")
except RuntimeError as e:
# Ignore expected shutdown errors
if "Event loop stopped" not in str(e) and "Event loop is closed" not in str(e):
logger.error(f"Worker {self.worker_id} runtime error: {e}")
except Exception as e:
logger.error(f"Worker {self.worker_id} thread error: {e}")
finally:
# Clean up
if self.loop and not self.loop.is_closed():
self.loop.close()
self.running = False
self.loop = None
def start(self):
"""Start the worker thread"""
self.thread = threading.Thread(
target=self.run,
daemon=True,
name=f"PageFetchAsyncUpdateWorker-{self.worker_id}"
)
self.thread.start()
def stop(self):
"""Stop the worker thread"""
if self.loop and self.running:
try:
# Signal the loop to stop
self.loop.call_soon_threadsafe(self.loop.stop)
except RuntimeError:
pass
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2.0)
def start_async_workers(n_workers, update_q, notification_q, app, datastore): def start_async_workers(n_workers, update_q, notification_q, app, datastore):
"""Start the async worker management system""" """Start async workers, each with its own thread and event loop for isolation"""
global async_loop_thread, async_loop, running_async_tasks, currently_processing_uuids global worker_threads, currently_processing_uuids
# Clear any stale UUID tracking state # Clear any stale state
currently_processing_uuids.clear() currently_processing_uuids.clear()
# Start the event loop in a separate thread # Start each worker in its own thread with its own event loop
async_loop_thread = threading.Thread(target=start_async_event_loop, daemon=True) logger.info(f"Starting {n_workers} async workers (isolated threads)")
async_loop_thread.start()
# Wait for the loop to be available (with timeout for safety)
max_wait_time = 5.0
wait_start = time.time()
while async_loop is None and (time.time() - wait_start) < max_wait_time:
time.sleep(0.1)
if async_loop is None:
logger.error("Failed to start async event loop within timeout")
return
# Additional brief wait to ensure loop is running
time.sleep(0.2)
# Start async workers
logger.info(f"Starting {n_workers} async workers")
for i in range(n_workers): for i in range(n_workers):
try: try:
# Use a factory function to create named worker coroutines worker = WorkerThread(i, update_q, notification_q, app, datastore)
def create_named_worker(worker_id): worker.start()
async def named_worker(): worker_threads.append(worker)
task = asyncio.current_task() # No sleep needed - threads start independently and asynchronously
if task: except Exception as e:
task.set_name(f"async-worker-{worker_id}")
return await start_single_async_worker(worker_id, update_q, notification_q, app, datastore)
return named_worker()
task_future = asyncio.run_coroutine_threadsafe(create_named_worker(i), async_loop)
running_async_tasks.append(task_future)
except RuntimeError as e:
logger.error(f"Failed to start async worker {i}: {e}") logger.error(f"Failed to start async worker {i}: {e}")
continue continue
async def start_single_async_worker(worker_id, update_q, notification_q, app, datastore): async def start_single_async_worker(worker_id, update_q, notification_q, app, datastore, executor=None):
"""Start a single async worker with auto-restart capability""" """Start a single async worker with auto-restart capability"""
from changedetectionio.async_update_worker import async_update_worker from changedetectionio.async_update_worker import async_update_worker
# Check if we're in pytest environment - if so, be more gentle with logging # Check if we're in pytest environment - if so, be more gentle with logging
import os import os
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
while not app.config.exit.is_set(): while not app.config.exit.is_set():
try: try:
if not in_pytest: await async_update_worker(worker_id, update_q, notification_q, app, datastore, executor)
logger.info(f"Starting async worker {worker_id}")
await async_update_worker(worker_id, update_q, notification_q, app, datastore)
# If we reach here, worker exited cleanly # If we reach here, worker exited cleanly
if not in_pytest: if not in_pytest:
logger.info(f"Async worker {worker_id} exited cleanly") logger.info(f"Async worker {worker_id} exited cleanly")
@@ -131,39 +159,38 @@ def start_workers(n_workers, update_q, notification_q, app, datastore):
def add_worker(update_q, notification_q, app, datastore): def add_worker(update_q, notification_q, app, datastore):
"""Add a new async worker (for dynamic scaling)""" """Add a new async worker (for dynamic scaling)"""
global running_async_tasks global worker_threads
if not async_loop: worker_id = len(worker_threads)
logger.error("Async loop not running, cannot add worker")
return False
worker_id = len(running_async_tasks)
logger.info(f"Adding async worker {worker_id}") logger.info(f"Adding async worker {worker_id}")
task_future = asyncio.run_coroutine_threadsafe( try:
start_single_async_worker(worker_id, update_q, notification_q, app, datastore), async_loop worker = WorkerThread(worker_id, update_q, notification_q, app, datastore)
) worker.start()
running_async_tasks.append(task_future) worker_threads.append(worker)
return True return True
except Exception as e:
logger.error(f"Failed to add worker {worker_id}: {e}")
return False
def remove_worker(): def remove_worker():
"""Remove an async worker (for dynamic scaling)""" """Remove an async worker (for dynamic scaling)"""
global running_async_tasks global worker_threads
if not running_async_tasks: if not worker_threads:
return False return False
# Cancel the last worker # Stop the last worker
task_future = running_async_tasks.pop() worker = worker_threads.pop()
task_future.cancel() worker.stop()
logger.info(f"Removed async worker, {len(running_async_tasks)} workers remaining") logger.info(f"Removed async worker, {len(worker_threads)} workers remaining")
return True return True
def get_worker_count(): def get_worker_count():
"""Get current number of async workers""" """Get current number of async workers"""
return len(running_async_tasks) return len(worker_threads)
def get_running_uuids(): def get_running_uuids():
@@ -249,38 +276,21 @@ def queue_item_async_safe(update_q, item, silent=False):
def shutdown_workers(): def shutdown_workers():
"""Shutdown all async workers fast and aggressively""" """Shutdown all async workers fast and aggressively"""
global async_loop, async_loop_thread, running_async_tasks global worker_threads
# Check if we're in pytest environment - if so, be more gentle with logging # Check if we're in pytest environment - if so, be more gentle with logging
import os import os
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
if not in_pytest: if not in_pytest:
logger.info("Fast shutdown of async workers initiated...") logger.info("Fast shutdown of async workers initiated...")
# Cancel all async tasks immediately # Stop all worker threads
for task_future in running_async_tasks: for worker in worker_threads:
if not task_future.done(): worker.stop()
task_future.cancel()
worker_threads.clear()
# Stop the async event loop immediately
if async_loop and not async_loop.is_closed():
try:
async_loop.call_soon_threadsafe(async_loop.stop)
except RuntimeError:
# Loop might already be stopped
pass
running_async_tasks.clear()
async_loop = None
# Give async thread minimal time to finish, then continue
if async_loop_thread and async_loop_thread.is_alive():
async_loop_thread.join(timeout=1.0) # Only 1 second timeout
if async_loop_thread.is_alive() and not in_pytest:
logger.info("Async thread still running after timeout - continuing with shutdown")
async_loop_thread = None
if not in_pytest: if not in_pytest:
logger.info("Async workers fast shutdown complete") logger.info("Async workers fast shutdown complete")
@@ -290,69 +300,57 @@ def shutdown_workers():
def adjust_async_worker_count(new_count, update_q=None, notification_q=None, app=None, datastore=None): def adjust_async_worker_count(new_count, update_q=None, notification_q=None, app=None, datastore=None):
""" """
Dynamically adjust the number of async workers. Dynamically adjust the number of async workers.
Args: Args:
new_count: Target number of workers new_count: Target number of workers
update_q, notification_q, app, datastore: Required for adding new workers update_q, notification_q, app, datastore: Required for adding new workers
Returns: Returns:
dict: Status of the adjustment operation dict: Status of the adjustment operation
""" """
global running_async_tasks global worker_threads
current_count = get_worker_count() current_count = get_worker_count()
if new_count == current_count: if new_count == current_count:
return { return {
'status': 'no_change', 'status': 'no_change',
'message': f'Worker count already at {current_count}', 'message': f'Worker count already at {current_count}',
'current_count': current_count 'current_count': current_count
} }
if new_count > current_count: if new_count > current_count:
# Add workers # Add workers
workers_to_add = new_count - current_count workers_to_add = new_count - current_count
logger.info(f"Adding {workers_to_add} async workers (from {current_count} to {new_count})") logger.info(f"Adding {workers_to_add} async workers (from {current_count} to {new_count})")
if not all([update_q, notification_q, app, datastore]): if not all([update_q, notification_q, app, datastore]):
return { return {
'status': 'error', 'status': 'error',
'message': 'Missing required parameters to add workers', 'message': 'Missing required parameters to add workers',
'current_count': current_count 'current_count': current_count
} }
for i in range(workers_to_add): for i in range(workers_to_add):
worker_id = len(running_async_tasks) add_worker(update_q, notification_q, app, datastore)
task_future = asyncio.run_coroutine_threadsafe(
start_single_async_worker(worker_id, update_q, notification_q, app, datastore),
async_loop
)
running_async_tasks.append(task_future)
return { return {
'status': 'success', 'status': 'success',
'message': f'Added {workers_to_add} workers', 'message': f'Added {workers_to_add} workers',
'previous_count': current_count, 'previous_count': current_count,
'current_count': new_count 'current_count': len(worker_threads)
} }
else: else:
# Remove workers # Remove workers
workers_to_remove = current_count - new_count workers_to_remove = current_count - new_count
logger.info(f"Removing {workers_to_remove} async workers (from {current_count} to {new_count})") logger.info(f"Removing {workers_to_remove} async workers (from {current_count} to {new_count})")
removed_count = 0 removed_count = 0
for _ in range(workers_to_remove): for _ in range(workers_to_remove):
if running_async_tasks: if remove_worker():
task_future = running_async_tasks.pop()
task_future.cancel()
# Wait for the task to actually stop
try:
task_future.result(timeout=5) # 5 second timeout
except Exception:
pass # Task was cancelled, which is expected
removed_count += 1 removed_count += 1
return { return {
'status': 'success', 'status': 'success',
'message': f'Removed {removed_count} workers', 'message': f'Removed {removed_count} workers',
@@ -367,72 +365,58 @@ def get_worker_status():
'worker_type': 'async', 'worker_type': 'async',
'worker_count': get_worker_count(), 'worker_count': get_worker_count(),
'running_uuids': get_running_uuids(), 'running_uuids': get_running_uuids(),
'async_loop_running': async_loop is not None, 'active_threads': sum(1 for w in worker_threads if w.thread and w.thread.is_alive()),
} }
def check_worker_health(expected_count, update_q=None, notification_q=None, app=None, datastore=None): def check_worker_health(expected_count, update_q=None, notification_q=None, app=None, datastore=None):
""" """
Check if the expected number of async workers are running and restart any missing ones. Check if the expected number of async workers are running and restart any missing ones.
Args: Args:
expected_count: Expected number of workers expected_count: Expected number of workers
update_q, notification_q, app, datastore: Required for restarting workers update_q, notification_q, app, datastore: Required for restarting workers
Returns: Returns:
dict: Health check results dict: Health check results
""" """
global running_async_tasks global worker_threads
current_count = get_worker_count() current_count = get_worker_count()
if current_count == expected_count: # Check which workers are actually alive
alive_count = sum(1 for w in worker_threads if w.thread and w.thread.is_alive())
if alive_count == expected_count:
return { return {
'status': 'healthy', 'status': 'healthy',
'expected_count': expected_count, 'expected_count': expected_count,
'actual_count': current_count, 'actual_count': alive_count,
'message': f'All {expected_count} async workers running' 'message': f'All {expected_count} async workers running'
} }
# Check for crashed async workers # Find dead workers
dead_workers = [] dead_workers = []
alive_count = 0 for i, worker in enumerate(worker_threads[:]):
if not worker.thread or not worker.thread.is_alive():
for i, task_future in enumerate(running_async_tasks[:]): dead_workers.append(i)
if task_future.done(): logger.warning(f"Async worker {worker.worker_id} thread is dead")
try:
result = task_future.result()
dead_workers.append(i)
logger.warning(f"Async worker {i} completed unexpectedly")
except Exception as e:
dead_workers.append(i)
logger.error(f"Async worker {i} crashed: {e}")
else:
alive_count += 1
# Remove dead workers from tracking # Remove dead workers from tracking
for i in reversed(dead_workers): for i in reversed(dead_workers):
if i < len(running_async_tasks): if i < len(worker_threads):
running_async_tasks.pop(i) worker_threads.pop(i)
missing_workers = expected_count - alive_count missing_workers = expected_count - alive_count
restarted_count = 0 restarted_count = 0
if missing_workers > 0 and all([update_q, notification_q, app, datastore]): if missing_workers > 0 and all([update_q, notification_q, app, datastore]):
logger.info(f"Restarting {missing_workers} crashed async workers") logger.info(f"Restarting {missing_workers} crashed async workers")
for i in range(missing_workers): for i in range(missing_workers):
worker_id = alive_count + i if add_worker(update_q, notification_q, app, datastore):
try:
task_future = asyncio.run_coroutine_threadsafe(
start_single_async_worker(worker_id, update_q, notification_q, app, datastore),
async_loop
)
running_async_tasks.append(task_future)
restarted_count += 1 restarted_count += 1
except Exception as e:
logger.error(f"Failed to restart worker {worker_id}: {e}")
return { return {
'status': 'repaired' if restarted_count > 0 else 'degraded', 'status': 'repaired' if restarted_count > 0 else 'degraded',
'expected_count': expected_count, 'expected_count': expected_count,
+25 -3
View File
@@ -183,15 +183,30 @@ components:
properties: properties:
weeks: weeks:
type: integer type: integer
minimum: 0
maximum: 52000
nullable: true
days: days:
type: integer type: integer
minimum: 0
maximum: 365000
nullable: true
hours: hours:
type: integer type: integer
minimum: 0
maximum: 8760000
nullable: true
minutes: minutes:
type: integer type: integer
minimum: 0
maximum: 525600000
nullable: true
seconds: seconds:
type: integer type: integer
description: Time intervals between checks minimum: 0
maximum: 31536000000
nullable: true
description: Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.
time_between_check_use_default: time_between_check_use_default:
type: boolean type: boolean
default: true default: true
@@ -200,7 +215,9 @@ components:
type: array type: array
items: items:
type: string type: string
description: Notification URLs for this web page change monitor (watch) maxLength: 1000
maxItems: 100
description: Notification URLs for this web page change monitor (watch). Maximum 100 URLs.
notification_title: notification_title:
type: string type: string
description: Custom notification title description: Custom notification title
@@ -224,14 +241,19 @@ components:
operation: operation:
type: string type: string
maxLength: 5000 maxLength: 5000
nullable: true
selector: selector:
type: string type: string
maxLength: 5000 maxLength: 5000
nullable: true
optional_value: optional_value:
type: string type: string
maxLength: 5000 maxLength: 5000
nullable: true
required: [operation, selector, optional_value] required: [operation, selector, optional_value]
description: Browser automation steps additionalProperties: false
maxItems: 100
description: Browser automation steps. Maximum 100 steps allowed.
processor: processor:
type: string type: string
enum: [restock_diff, text_json_diff] enum: [restock_diff, text_json_diff]
+4 -4
View File
@@ -42,7 +42,7 @@ orjson~=3.11
# jq not available on Windows so must be installed manually # jq not available on Windows so must be installed manually
# Notification library # Notification library
apprise==1.9.5 apprise==1.9.6
diff_match_patch diff_match_patch
@@ -70,7 +70,7 @@ lxml >=4.8.0,!=5.2.0,!=5.2.1,<7
# XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable # XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable
# Consider updating to latest stable version periodically # Consider updating to latest stable version periodically
elementpath==5.0.4 elementpath==5.1.0
# For fast image comparison in screenshot change detection # For fast image comparison in screenshot change detection
# opencv-python-headless is OPTIONAL (excluded from requirements.txt) # opencv-python-headless is OPTIONAL (excluded from requirements.txt)
@@ -91,7 +91,7 @@ jq~=1.3; python_version >= "3.8" and sys_platform == "linux"
# playwright is installed at Dockerfile build time because it's not available on all platforms # playwright is installed at Dockerfile build time because it's not available on all platforms
pyppeteer-ng==2.0.0rc10 pyppeteer-ng==2.0.0rc11
pyppeteerstealth>=0.0.4 pyppeteerstealth>=0.0.4
# Include pytest, so if theres a support issue we can ask them to run these tests on their setup # Include pytest, so if theres a support issue we can ask them to run these tests on their setup
@@ -100,7 +100,7 @@ pytest-flask ~=1.3
pytest-mock ~=3.15 pytest-mock ~=3.15
# Anything 4.0 and up but not 5.0 # Anything 4.0 and up but not 5.0
jsonschema ~= 4.25 jsonschema ~= 4.26
# OpenAPI validation support # OpenAPI validation support
openapi-core[flask] >= 0.19.0 openapi-core[flask] >= 0.19.0