mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-06-17 14:21:05 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fac3c9d71b | |||
| 0479aa9654 | |||
| 746e213398 | |||
| 84d97ec9cf | |||
| c8f13f5084 | |||
| d74b7d5329 | |||
| 31a760c214 | |||
| 43bba5a1b6 | |||
| 7c9eb02df4 | |||
| 0ad4090d68 | |||
| 9a10353d61 | |||
| f8236848ba |
@@ -99,11 +99,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Unit Tests
|
- name: Run Unit Tests
|
||||||
run: |
|
run: |
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
|
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest tests/unit/'
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
|
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
|
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
|
|
||||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
|
|
||||||
|
|
||||||
# Basic pytest tests with ancillary services
|
# Basic pytest tests with ancillary services
|
||||||
basic-tests:
|
basic-tests:
|
||||||
@@ -587,6 +583,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
|
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
|
||||||
|
|
||||||
|
- name: Plugin get_html_head_extras hook injects into base.html
|
||||||
|
run: |
|
||||||
|
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_html_head_extras.py'
|
||||||
|
|
||||||
# Container startup tests
|
# Container startup tests
|
||||||
container-tests:
|
container-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -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.54.7'
|
__version__ = '0.54.8'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
|
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
|
||||||
backup_threads = []
|
backup_threads = []
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@backups_blueprint.route("/request-backup", methods=['GET'])
|
@backups_blueprint.route("/request-backup", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
def request_backup():
|
def request_backup():
|
||||||
if any(thread.is_alive() for thread in backup_threads):
|
if any(thread.is_alive() for thread in backup_threads):
|
||||||
flash(gettext("A backup is already running, check back in a few minutes"), "error")
|
flash(gettext("A backup is already running, check back in a few minutes"), "error")
|
||||||
@@ -141,8 +141,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
|
|
||||||
return backup_info
|
return backup_info
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@backups_blueprint.route("/download/<string:filename>", methods=['GET'])
|
@backups_blueprint.route("/download/<string:filename>", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
def download_backup(filename):
|
def download_backup(filename):
|
||||||
import re
|
import re
|
||||||
filename = filename.strip()
|
filename = filename.strip()
|
||||||
@@ -165,9 +165,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
logger.debug(f"Backup download request for '{full_path}'")
|
logger.debug(f"Backup download request for '{full_path}'")
|
||||||
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
|
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@backups_blueprint.route("/", methods=['GET'])
|
@backups_blueprint.route("/", methods=['GET'])
|
||||||
@backups_blueprint.route("/create", methods=['GET'])
|
@backups_blueprint.route("/create", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
def create():
|
def create():
|
||||||
backups = find_backups()
|
backups = find_backups()
|
||||||
output = render_template("backup_create.html",
|
output = render_template("backup_create.html",
|
||||||
@@ -176,8 +176,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
)
|
)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@backups_blueprint.route("/remove-backups", methods=['GET'])
|
@backups_blueprint.route("/remove-backups", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
def remove_backups():
|
def remove_backups():
|
||||||
|
|
||||||
backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
|
backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ def construct_restore_blueprint(datastore):
|
|||||||
restore_blueprint = Blueprint('restore', __name__, template_folder="templates")
|
restore_blueprint = Blueprint('restore', __name__, template_folder="templates")
|
||||||
restore_threads = []
|
restore_threads = []
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@restore_blueprint.route("/restore", methods=['GET'])
|
@restore_blueprint.route("/restore", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
def restore():
|
def restore():
|
||||||
form = RestoreForm()
|
form = RestoreForm()
|
||||||
return render_template("backup_restore.html",
|
return render_template("backup_restore.html",
|
||||||
@@ -184,8 +184,8 @@ def construct_restore_blueprint(datastore):
|
|||||||
max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024),
|
max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024),
|
||||||
max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024))
|
max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024))
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@restore_blueprint.route("/restore/start", methods=['POST'])
|
@restore_blueprint.route("/restore/start", methods=['POST'])
|
||||||
|
@login_optionally_required
|
||||||
def backups_restore_start():
|
def backups_restore_start():
|
||||||
if any(t.is_alive() for t in restore_threads):
|
if any(t.is_alive() for t in restore_threads):
|
||||||
flash(gettext("A restore is already running, check back in a few minutes"), "error")
|
flash(gettext("A restore is already running, check back in a few minutes"), "error")
|
||||||
|
|||||||
@@ -20,8 +20,7 @@
|
|||||||
<p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>
|
<p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>
|
||||||
<p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>
|
<p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>
|
||||||
<p class="pure-form-message">
|
<p class="pure-form-message">
|
||||||
{{ _('Max upload size: %(upload)s MB · Max decompressed size: %(decomp)s MB',
|
{{ _('Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB', upload=max_upload_mb, decomp=max_decompressed_mb) }}
|
||||||
upload=max_upload_mb, decomp=max_decompressed_mb) }}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="pure-form pure-form-stacked settings"
|
<form class="pure-form pure-form-stacked settings"
|
||||||
|
|||||||
@@ -268,8 +268,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
return browsersteps_start_session
|
return browsersteps_start_session
|
||||||
|
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
|
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
def browsersteps_start_session():
|
def browsersteps_start_session():
|
||||||
# A new session was requested, return sessionID
|
# A new session was requested, return sessionID
|
||||||
import uuid
|
import uuid
|
||||||
@@ -304,8 +304,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
logger.debug("Starting connection with playwright - done")
|
logger.debug("Starting connection with playwright - done")
|
||||||
return {'browsersteps_session_id': browsersteps_session_id}
|
return {'browsersteps_session_id': browsersteps_session_id}
|
||||||
|
|
||||||
@login_optionally_required
|
|
||||||
@browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
|
@browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
def browser_steps_fetch_screenshot_image():
|
def browser_steps_fetch_screenshot_image():
|
||||||
from flask import (
|
from flask import (
|
||||||
make_response,
|
make_response,
|
||||||
@@ -330,8 +330,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
|
return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
|
||||||
|
|
||||||
# A request for an action was received
|
# A request for an action was received
|
||||||
@login_optionally_required
|
|
||||||
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
|
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
|
||||||
|
@login_optionally_required
|
||||||
def browsersteps_ui_update():
|
def browsersteps_ui_update():
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
|
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
|
||||||
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
|
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
|
||||||
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
|
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
|
||||||
|
<li class="tab"><a href="{{url_for('backups.restore.restore')}}">{{ _('Backup Restore') }}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -104,15 +104,17 @@ class fetcher(Fetcher):
|
|||||||
|
|
||||||
from selenium.webdriver.remote.remote_connection import RemoteConnection
|
from selenium.webdriver.remote.remote_connection import RemoteConnection
|
||||||
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
|
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
|
||||||
|
from selenium.webdriver.remote.client_config import ClientConfig
|
||||||
|
from urllib3.util import Timeout
|
||||||
driver = None
|
driver = None
|
||||||
try:
|
try:
|
||||||
# Create the RemoteConnection and set timeout (e.g., 30 seconds)
|
connection_timeout = int(os.getenv("WEBDRIVER_CONNECTION_TIMEOUT", 90))
|
||||||
remote_connection = RemoteConnection(
|
client_config = ClientConfig(
|
||||||
self.browser_connection_url,
|
remote_server_addr=self.browser_connection_url,
|
||||||
|
timeout=Timeout(connect=connection_timeout, total=connection_timeout)
|
||||||
)
|
)
|
||||||
remote_connection.set_timeout(30) # seconds
|
remote_connection = RemoteConnection(client_config=client_config)
|
||||||
|
|
||||||
# Now create the driver with the RemoteConnection
|
|
||||||
driver = RemoteWebDriver(
|
driver = RemoteWebDriver(
|
||||||
command_executor=remote_connection,
|
command_executor=remote_connection,
|
||||||
options=options
|
options=options
|
||||||
|
|||||||
@@ -45,6 +45,36 @@ CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
|
|||||||
# Compiled regex patterns for performance
|
# Compiled regex patterns for performance
|
||||||
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
|
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
|
||||||
|
|
||||||
|
# Regexes built from the constants above — no brittle hardcoded strings
|
||||||
|
_EXTRACT_REMOVED_RE = re.compile(
|
||||||
|
re.escape(REMOVED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(REMOVED_PLACEMARKER_CLOSED)
|
||||||
|
+ r'|' +
|
||||||
|
re.escape(CHANGED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_PLACEMARKER_CLOSED)
|
||||||
|
)
|
||||||
|
_EXTRACT_ADDED_RE = re.compile(
|
||||||
|
re.escape(ADDED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(ADDED_PLACEMARKER_CLOSED)
|
||||||
|
+ r'|' +
|
||||||
|
re.escape(CHANGED_INTO_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_INTO_PLACEMARKER_CLOSED)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_changed_from(raw_diff: str) -> str:
|
||||||
|
"""Extract only the removed/changed-from fragments from a raw diff string.
|
||||||
|
|
||||||
|
Useful for {{diff_changed_from}} — gives just the old value (e.g. old price),
|
||||||
|
not the full surrounding line. Multiple fragments joined with newlines.
|
||||||
|
"""
|
||||||
|
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_REMOVED_RE.finditer(raw_diff))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_changed_to(raw_diff: str) -> str:
|
||||||
|
"""Extract only the added/changed-into fragments from a raw diff string.
|
||||||
|
|
||||||
|
Useful for {{diff_changed_to}} — gives just the new value (e.g. new price),
|
||||||
|
not the full surrounding line. Multiple fragments joined with newlines.
|
||||||
|
"""
|
||||||
|
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_ADDED_RE.finditer(raw_diff))
|
||||||
|
|
||||||
|
|
||||||
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:
|
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ def _is_safe_valid_url(test_url):
|
|||||||
from .validate_url import is_safe_valid_url
|
from .validate_url import is_safe_valid_url
|
||||||
return is_safe_valid_url(test_url)
|
return is_safe_valid_url(test_url)
|
||||||
|
|
||||||
|
@app.template_global('get_html_head_extras')
|
||||||
|
def _get_html_head_extras():
|
||||||
|
from .pluggy_interface import collect_html_head_extras
|
||||||
|
return collect_html_head_extras()
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter('format_number_locale')
|
@app.template_filter('format_number_locale')
|
||||||
def _jinja2_filter_format_number_locale(value: float) -> str:
|
def _jinja2_filter_format_number_locale(value: float) -> str:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def get_timeago_locale(flask_locale):
|
|||||||
'no': 'nb_NO', # Norwegian Bokmål
|
'no': 'nb_NO', # Norwegian Bokmål
|
||||||
'hi': 'in_HI', # Hindi
|
'hi': 'in_HI', # Hindi
|
||||||
'cs': 'en', # Czech not supported by timeago, fallback to English
|
'cs': 'en', # Czech not supported by timeago, fallback to English
|
||||||
|
'ja': 'ja', # Japanese
|
||||||
'uk': 'uk', # Ukrainian
|
'uk': 'uk', # Ukrainian
|
||||||
'en_GB': 'en', # British English - timeago uses 'en'
|
'en_GB': 'en', # British English - timeago uses 'en'
|
||||||
'en_US': 'en', # American English - timeago uses 'en'
|
'en_US': 'en', # American English - timeago uses 'en'
|
||||||
|
|||||||
@@ -88,6 +88,28 @@ class FormattableTimestamp(str):
|
|||||||
return self._dt.isoformat()
|
return self._dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class FormattableExtract(str):
|
||||||
|
"""
|
||||||
|
A str subclass that holds only the extracted changed fragments from a diff.
|
||||||
|
Used for {{diff_changed_from}} and {{diff_changed_to}} tokens.
|
||||||
|
|
||||||
|
{{ diff_changed_from }} → old value(s) only, e.g. "$99.99"
|
||||||
|
{{ diff_changed_to }} → new value(s) only, e.g. "$109.99"
|
||||||
|
|
||||||
|
Multiple changed fragments are joined with newlines.
|
||||||
|
Being a str subclass means it is natively JSON serializable.
|
||||||
|
"""
|
||||||
|
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
|
||||||
|
if prev_snapshot or current_snapshot:
|
||||||
|
from changedetectionio import diff as diff_module
|
||||||
|
raw = diff_module.render_diff(prev_snapshot, current_snapshot, word_diff=True)
|
||||||
|
extracted = extract_fn(raw)
|
||||||
|
else:
|
||||||
|
extracted = ''
|
||||||
|
instance = super().__new__(cls, extracted)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class FormattableDiff(str):
|
class FormattableDiff(str):
|
||||||
"""
|
"""
|
||||||
A str subclass representing a rendered diff. As a plain string it renders
|
A str subclass representing a rendered diff. As a plain string it renders
|
||||||
@@ -161,6 +183,8 @@ class NotificationContextData(dict):
|
|||||||
'diff_patch': FormattableDiff('', '', patch_format=True),
|
'diff_patch': FormattableDiff('', '', patch_format=True),
|
||||||
'diff_removed': FormattableDiff('', '', include_added=False),
|
'diff_removed': FormattableDiff('', '', include_added=False),
|
||||||
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
|
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
|
||||||
|
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||||
|
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||||
'diff_url': None,
|
'diff_url': None,
|
||||||
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
||||||
'notification_timestamp': time.time(),
|
'notification_timestamp': time.time(),
|
||||||
@@ -244,16 +268,27 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
|||||||
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
|
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from changedetectionio.diff import extract_changed_from, extract_changed_to
|
||||||
|
extract_specs = {
|
||||||
|
'diff_changed_from': extract_changed_from,
|
||||||
|
'diff_changed_to': extract_changed_to,
|
||||||
|
}
|
||||||
|
|
||||||
ret = {}
|
ret = {}
|
||||||
rendered_count = 0
|
rendered_count = 0
|
||||||
# Only create FormattableDiff objects for diff keys actually used in the notification text
|
# Only create FormattableDiff/FormattableExtract objects for diff keys actually used in the notification text
|
||||||
for key in NotificationContextData().keys():
|
for key in NotificationContextData().keys():
|
||||||
if key.startswith('diff') and key in diff_specs:
|
if not key.startswith('diff'):
|
||||||
# Check if this placeholder is actually used in the notification text
|
continue
|
||||||
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
||||||
if re.search(pattern, notification_scan_text, re.IGNORECASE):
|
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
|
||||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
continue
|
||||||
rendered_count += 1
|
if key in diff_specs:
|
||||||
|
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||||
|
rendered_count += 1
|
||||||
|
elif key in extract_specs:
|
||||||
|
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
|
||||||
|
rendered_count += 1
|
||||||
|
|
||||||
if rendered_count:
|
if rendered_count:
|
||||||
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")
|
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")
|
||||||
|
|||||||
@@ -174,6 +174,64 @@ class ChangeDetectionSpec:
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def get_html_head_extras():
|
||||||
|
"""Return HTML to inject into the <head> of every page via base.html.
|
||||||
|
|
||||||
|
Plugins can use this to add <script>, <style>, or <link> tags that should
|
||||||
|
be present on all pages. Return a raw HTML string or None.
|
||||||
|
|
||||||
|
IMPORTANT: Always use Flask's url_for() for any src/href URLs so that
|
||||||
|
sub-path deployments (nginx reverse proxy with USE_X_SETTINGS / X-Forwarded-Prefix)
|
||||||
|
work correctly. This hook is called inside a request context so url_for() is
|
||||||
|
always available.
|
||||||
|
|
||||||
|
For small amounts of CSS/JS, return them inline — no file-serving needed::
|
||||||
|
|
||||||
|
from changedetectionio.pluggy_interface import hookimpl
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def get_html_head_extras(self):
|
||||||
|
return (
|
||||||
|
'<style>.my-module-banner { color: red; }</style>\\n'
|
||||||
|
'<script>console.log("my_module_content loaded");</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
For larger assets, register your own lightweight Flask routes in the plugin
|
||||||
|
module and point to them with url_for() so the sub-path prefix is handled
|
||||||
|
automatically::
|
||||||
|
|
||||||
|
from flask import url_for, Response
|
||||||
|
from changedetectionio.pluggy_interface import hookimpl
|
||||||
|
from changedetectionio.flask_app import app as _app
|
||||||
|
|
||||||
|
MY_CSS = ".my-module-example { color: red; }"
|
||||||
|
MY_JS = "console.log('my_module_content loaded');"
|
||||||
|
|
||||||
|
@_app.route('/my_module_content/css')
|
||||||
|
def my_module_content_css():
|
||||||
|
return Response(MY_CSS, mimetype='text/css',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
@_app.route('/my_module_content/js')
|
||||||
|
def my_module_content_js():
|
||||||
|
return Response(MY_JS, mimetype='application/javascript',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def get_html_head_extras(self):
|
||||||
|
css = url_for('my_module_content_css')
|
||||||
|
js = url_for('my_module_content_js')
|
||||||
|
return (
|
||||||
|
f'<link rel="stylesheet" href="{css}">\\n'
|
||||||
|
f'<script src="{js}" defer></script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str or None: Raw HTML string to inject inside <head>, or None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Set up Plugin Manager
|
# Set up Plugin Manager
|
||||||
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
||||||
@@ -606,4 +664,20 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't let plugin errors crash the worker
|
# Don't let plugin errors crash the worker
|
||||||
logger.error(f"Error in update_finalize hook: {e}")
|
logger.error(f"Error in update_finalize hook: {e}")
|
||||||
logger.exception(f"update_finalize hook exception details:")
|
logger.exception(f"update_finalize hook exception details:")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_html_head_extras():
|
||||||
|
"""Collect and combine HTML head extras from all plugins.
|
||||||
|
|
||||||
|
Called from a Flask template global so it always runs inside a request context.
|
||||||
|
This means url_for() works correctly in plugin implementations, including when the
|
||||||
|
app is deployed under a sub-path via USE_X_SETTINGS / X-Forwarded-Prefix (ProxyFix
|
||||||
|
sets SCRIPT_NAME so url_for() automatically prepends the prefix).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Combined HTML string to inject inside <head>, or empty string
|
||||||
|
"""
|
||||||
|
results = plugin_manager.hook.get_html_head_extras()
|
||||||
|
parts = [r for r in results if r]
|
||||||
|
return "\n".join(parts) if parts else ""
|
||||||
@@ -98,6 +98,14 @@
|
|||||||
<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>
|
||||||
|
<td><code>{{ '{{diff_changed_from}}' }}</code></td>
|
||||||
|
<td>{{ _('Only the changed fragments from the previous version — e.g. the old price. Multiple changes joined by newline.') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ '{{diff_changed_to}}' }}</code></td>
|
||||||
|
<td>{{ _('Only the changed fragments from the new version — e.g. the new price. Multiple changes joined by newline.') }}</td>
|
||||||
|
</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') }}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@
|
|||||||
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
|
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{%- set _html_head_extras = get_html_head_extras() -%}
|
||||||
|
{%- if _html_head_extras %}
|
||||||
|
{{ _html_head_extras | safe }}
|
||||||
|
{%- endif %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="{{extra_classes}}">
|
<body class="{{extra_classes}}">
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Test that plugins can inject HTML into base.html <head> via get_html_head_extras hookimpl."""
|
||||||
|
import pytest
|
||||||
|
from flask import url_for, Response
|
||||||
|
|
||||||
|
from changedetectionio.pluggy_interface import hookimpl, plugin_manager
|
||||||
|
|
||||||
|
_MY_JS = "console.log('my_module_content loaded');"
|
||||||
|
_MY_CSS = ".my-module-example { color: red; }"
|
||||||
|
|
||||||
|
|
||||||
|
class _HeadExtrasPlugin:
|
||||||
|
"""Test plugin that injects tags pointing at its own Flask routes."""
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def get_html_head_extras(self):
|
||||||
|
css_url = url_for('test_plugin_my_module_content_css')
|
||||||
|
js_url = url_for('test_plugin_my_module_content_js')
|
||||||
|
return (
|
||||||
|
f'<link rel="stylesheet" id="test-head-extra-css" href="{css_url}">\n'
|
||||||
|
f'<script id="test-head-extra-js" src="{js_url}" defer></script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='module')
|
||||||
|
def plugin_routes(live_server):
|
||||||
|
"""Register plugin asset routes once per module (Flask routes can't be added twice)."""
|
||||||
|
app = live_server.app
|
||||||
|
|
||||||
|
@app.route('/test-plugin/my_module_content/css')
|
||||||
|
def test_plugin_my_module_content_css():
|
||||||
|
return Response(_MY_CSS, mimetype='text/css',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
@app.route('/test-plugin/my_module_content/js')
|
||||||
|
def test_plugin_my_module_content_js():
|
||||||
|
return Response(_MY_JS, mimetype='application/javascript',
|
||||||
|
headers={'Cache-Control': 'max-age=3600'})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def head_extras_plugin(plugin_routes):
|
||||||
|
"""Register the hookimpl for one test then unregister it — function-scoped for clean isolation."""
|
||||||
|
plugin = _HeadExtrasPlugin()
|
||||||
|
plugin_manager.register(plugin, name="test_head_extras")
|
||||||
|
yield plugin
|
||||||
|
plugin_manager.unregister(name="test_head_extras")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_html_injected_into_head(client, live_server, measure_memory_usage, datastore_path, head_extras_plugin):
|
||||||
|
"""get_html_head_extras output must appear inside <head> in the rendered page."""
|
||||||
|
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'id="test-head-extra-css"' in res.data, "Plugin <link> tag missing from rendered page"
|
||||||
|
assert b'id="test-head-extra-js"' in res.data, "Plugin <script> tag missing from rendered page"
|
||||||
|
|
||||||
|
head_end = res.data.find(b'</head>')
|
||||||
|
assert head_end != -1
|
||||||
|
for marker in (b'id="test-head-extra-css"', b'id="test-head-extra-js"'):
|
||||||
|
pos = res.data.find(marker)
|
||||||
|
assert pos != -1 and pos < head_end, f"{marker} must appear before </head>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_js_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
|
||||||
|
"""The plugin-registered JS route must return JS with the right Content-Type."""
|
||||||
|
res = client.get(url_for('test_plugin_my_module_content_js'))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert 'javascript' in res.content_type
|
||||||
|
assert _MY_JS.encode() in res.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_css_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
|
||||||
|
"""The plugin-registered CSS route must return CSS with the right Content-Type."""
|
||||||
|
res = client.get(url_for('test_plugin_my_module_content_css'))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert 'css' in res.content_type
|
||||||
|
assert _MY_CSS.encode() in res.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_extras_without_plugin(client, live_server, measure_memory_usage, datastore_path):
|
||||||
|
"""With no hookimpl registered the markers must not appear (isolation check)."""
|
||||||
|
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||||
|
assert b'id="test-head-extra-css"' not in res.data
|
||||||
|
assert b'id="test-head-extra-js"' not in res.data
|
||||||
@@ -11,10 +11,10 @@ from changedetectionio.tests.util import set_original_response, set_modified_res
|
|||||||
set_longer_modified_response, delete_all_watches
|
set_longer_modified_response, delete_all_watches
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
# NOTE - RELIES ON mailserver as hostname running, see github build recipes
|
# NOTE - RELIES ON mailserver as hostname running, see github build recipes
|
||||||
smtp_test_server = 'mailserver'
|
smtp_test_server = os.getenv('SMTP_TEST_MAILSERVER', 'mailserver')
|
||||||
|
|
||||||
ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys())
|
ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Static analysis test: verify @login_optionally_required is always applied
|
||||||
|
AFTER (inner to) @blueprint.route(), not before it.
|
||||||
|
|
||||||
|
In Flask, @route() must be the outermost decorator because it registers
|
||||||
|
whatever function it receives. If @login_optionally_required is placed
|
||||||
|
above @route(), the raw unprotected function gets registered and auth is
|
||||||
|
silently bypassed (GHSA-jmrh-xmgh-x9j4).
|
||||||
|
|
||||||
|
Correct order (route outermost, auth inner):
|
||||||
|
@blueprint.route('/path')
|
||||||
|
@login_optionally_required
|
||||||
|
def view(): ...
|
||||||
|
|
||||||
|
Wrong order (auth never called):
|
||||||
|
@login_optionally_required ← registered by route, then discarded
|
||||||
|
@blueprint.route('/path')
|
||||||
|
def view(): ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).parents[3] # …/changedetection.io/
|
||||||
|
SOURCE_ROOT = REPO_ROOT / "changedetectionio"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_route_decorator(node: ast.expr) -> bool:
|
||||||
|
"""Return True if the decorator looks like @something.route(...)."""
|
||||||
|
return (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Attribute)
|
||||||
|
and node.func.attr == "route"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_auth_decorator(node: ast.expr) -> bool:
|
||||||
|
"""Return True if the decorator is @login_optionally_required."""
|
||||||
|
return isinstance(node, ast.Name) and node.id == "login_optionally_required"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_violations() -> list[str]:
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
for path in SOURCE_ROOT.rglob("*.py"):
|
||||||
|
try:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
except SyntaxError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
decorators = node.decorator_list
|
||||||
|
auth_indices = [i for i, d in enumerate(decorators) if _is_auth_decorator(d)]
|
||||||
|
route_indices = [i for i, d in enumerate(decorators) if _is_route_decorator(d)]
|
||||||
|
|
||||||
|
# Bad order: auth decorator appears at a lower index (higher up) than a route decorator
|
||||||
|
for auth_idx in auth_indices:
|
||||||
|
for route_idx in route_indices:
|
||||||
|
if auth_idx < route_idx:
|
||||||
|
rel = path.relative_to(REPO_ROOT)
|
||||||
|
violations.append(
|
||||||
|
f"{rel}:{node.lineno} — `{node.name}`: "
|
||||||
|
f"@login_optionally_required (line {decorators[auth_idx].lineno}) "
|
||||||
|
f"is above @route (line {decorators[route_idx].lineno}); "
|
||||||
|
f"auth wrapper will never be called"
|
||||||
|
)
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_decorator_order():
|
||||||
|
violations = collect_violations()
|
||||||
|
if violations:
|
||||||
|
msg = (
|
||||||
|
"\n\nFound routes where @login_optionally_required is placed ABOVE @blueprint.route().\n"
|
||||||
|
"This silently disables authentication — @route() registers the raw function\n"
|
||||||
|
"and the auth wrapper is never called.\n\n"
|
||||||
|
"Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n\n"
|
||||||
|
+ "\n".join(f" • {v}" for v in violations)
|
||||||
|
)
|
||||||
|
pytest.fail(msg)
|
||||||
@@ -64,7 +64,7 @@ class TestTriggerConditions(unittest.TestCase):
|
|||||||
"conditions": [
|
"conditions": [
|
||||||
{"operator": ">=", "field": "extracted_number", "value": "10"},
|
{"operator": ">=", "field": "extracted_number", "value": "10"},
|
||||||
{"operator": "<=", "field": "extracted_number", "value": "5000"},
|
{"operator": "<=", "field": "extracted_number", "value": "5000"},
|
||||||
{"operator": "in", "field": "page_text", "value": "rock"},
|
{"operator": "in", "field": "page_filtered_text", "value": "rock"},
|
||||||
#{"operator": "starts_with", "field": "page_text", "value": "I saw"},
|
#{"operator": "starts_with", "field": "page_text", "value": "I saw"},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from changedetectionio.diff import (
|
|||||||
CHANGED_PLACEMARKER_OPEN,
|
CHANGED_PLACEMARKER_OPEN,
|
||||||
CHANGED_PLACEMARKER_CLOSED,
|
CHANGED_PLACEMARKER_CLOSED,
|
||||||
CHANGED_INTO_PLACEMARKER_OPEN,
|
CHANGED_INTO_PLACEMARKER_OPEN,
|
||||||
CHANGED_INTO_PLACEMARKER_CLOSED
|
CHANGED_INTO_PLACEMARKER_CLOSED,
|
||||||
|
extract_changed_from,
|
||||||
|
extract_changed_to,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -381,5 +383,72 @@ Line 3 with tabs and spaces"""
|
|||||||
self.assertNotIn('[-Line 2-]', output)
|
self.assertNotIn('[-Line 2-]', output)
|
||||||
self.assertNotIn('[+Line 2+]', output)
|
self.assertNotIn('[+Line 2+]', output)
|
||||||
|
|
||||||
|
def test_diff_changed_from_to_word_level(self):
|
||||||
|
"""Primary use case: extract just the old/new value from a changed line (e.g. price monitoring)"""
|
||||||
|
before = "Widget costs $99.99 per month"
|
||||||
|
after = "Widget costs $109.99 per month"
|
||||||
|
|
||||||
|
raw = diff.render_diff(before, after, word_diff=True)
|
||||||
|
|
||||||
|
self.assertEqual(extract_changed_from(raw), "$99.99")
|
||||||
|
self.assertEqual(extract_changed_to(raw), "$109.99")
|
||||||
|
|
||||||
|
def test_diff_changed_from_to_multiple_changes(self):
|
||||||
|
"""Multiple changed fragments on different lines are joined with newline.
|
||||||
|
An unchanged line between the two changes ensures each is a 1-to-1 replace,
|
||||||
|
so word_diff fires per line rather than falling back to multi-line block mode."""
|
||||||
|
before = "Price $99\nunchanged\nTax $5"
|
||||||
|
after = "Price $149\nunchanged\nTax $12"
|
||||||
|
|
||||||
|
raw = diff.render_diff(before, after, word_diff=True)
|
||||||
|
|
||||||
|
self.assertEqual(extract_changed_from(raw), "$99\n$5")
|
||||||
|
self.assertEqual(extract_changed_to(raw), "$149\n$12")
|
||||||
|
|
||||||
|
def test_diff_changed_from_to_pure_insert_delete(self):
|
||||||
|
"""Pure line additions/deletions (no inline word diff) are also captured"""
|
||||||
|
before = "old line"
|
||||||
|
after = "new line"
|
||||||
|
|
||||||
|
# word_diff=False forces line-level CHANGED markers
|
||||||
|
raw = diff.render_diff(before, after, word_diff=False)
|
||||||
|
|
||||||
|
self.assertEqual(extract_changed_from(raw), "old line")
|
||||||
|
self.assertEqual(extract_changed_to(raw), "new line")
|
||||||
|
|
||||||
|
def test_diff_changed_from_to_similar_numbers(self):
|
||||||
|
"""$90.00 → $9.00 must not produce a partial match like '0.00'.
|
||||||
|
The tokenizer splits on whitespace only, so '$90.00' and '$9.00' are
|
||||||
|
each a single atomic token — diff never sees their internal characters."""
|
||||||
|
before = "for sale $90.00"
|
||||||
|
after = "for sale $9.00"
|
||||||
|
|
||||||
|
raw = diff.render_diff(before, after, word_diff=True)
|
||||||
|
|
||||||
|
self.assertEqual(extract_changed_from(raw), "$90.00")
|
||||||
|
self.assertEqual(extract_changed_to(raw), "$9.00")
|
||||||
|
|
||||||
|
def test_diff_changed_from_to_whole_line_replaced(self):
|
||||||
|
"""When every token on the line changed (no common tokens), render_inline_word_diff
|
||||||
|
takes the whole_line_replaced path using CHANGED/CHANGED_INTO markers instead of
|
||||||
|
REMOVED/ADDED. Extraction must still work via the alternation in the regex."""
|
||||||
|
before = "$99"
|
||||||
|
after = "$109"
|
||||||
|
|
||||||
|
raw = diff.render_diff(before, after, word_diff=True)
|
||||||
|
|
||||||
|
self.assertEqual(extract_changed_from(raw), "$99")
|
||||||
|
self.assertEqual(extract_changed_to(raw), "$109")
|
||||||
|
|
||||||
|
def test_diff_changed_from_to_no_change(self):
|
||||||
|
"""No changes → empty string"""
|
||||||
|
content = "nothing changed here"
|
||||||
|
|
||||||
|
raw = diff.render_diff(content, content, word_diff=True)
|
||||||
|
|
||||||
|
self.assertEqual(extract_changed_from(raw), "")
|
||||||
|
self.assertEqual(extract_changed_to(raw), "")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ These commands read settings from `../../setup.cfg` automatically.
|
|||||||
- `en_US` - English (US)
|
- `en_US` - English (US)
|
||||||
- `fr` - French (Français)
|
- `fr` - French (Français)
|
||||||
- `it` - Italian (Italiano)
|
- `it` - Italian (Italiano)
|
||||||
|
- `ja` - Japanese (日本語)
|
||||||
- `ko` - Korean (한국어)
|
- `ko` - Korean (한국어)
|
||||||
- `zh` - Chinese Simplified (中文简体)
|
- `zh` - Chinese Simplified (中文简体)
|
||||||
- `zh_Hant_TW` - Chinese Traditional (繁體中文)
|
- `zh_Hant_TW` - Chinese Traditional (繁體中文)
|
||||||
|
|||||||
Binary file not shown.
@@ -1617,7 +1617,7 @@ msgstr "Bereich zeichnen"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||||
msgid "Clear selection"
|
msgid "Clear selection"
|
||||||
msgstr "Klare Auswahl"
|
msgstr "Auswahl löschen"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||||
msgid "One moment, fetching screenshot and element information.."
|
msgid "One moment, fetching screenshot and element information.."
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -28,7 +28,7 @@ services:
|
|||||||
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
|
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well)
|
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well, Can't handle custom headers etc)
|
||||||
# - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub
|
# - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub
|
||||||
#
|
#
|
||||||
# WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,
|
# WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ pytest-flask ~=1.3
|
|||||||
pytest-mock ~=3.15
|
pytest-mock ~=3.15
|
||||||
|
|
||||||
# OpenAPI validation support
|
# OpenAPI validation support
|
||||||
openapi-core[flask] ~= 0.22
|
openapi-core[flask] ~= 0.23
|
||||||
|
|
||||||
loguru
|
loguru
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user