From ee35b3da1ad120a9b2111643753b94cd68f0c1e7 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Thu, 16 Jul 2026 21:30:52 +0200 Subject: [PATCH] UI - Another attempt at configurable browsers --- changedetectionio/api/Import.py | 5 +- .../blueprint/settings/__init__.py | 12 + changedetectionio/blueprint/tags/__init__.py | 16 + changedetectionio/blueprint/tags/form.py | 8 + .../blueprint/tags/templates/edit-tag.html | 25 ++ changedetectionio/blueprint/ui/__init__.py | 15 +- .../blueprint/ui/browser_config.py | 216 +++++++++++++ changedetectionio/blueprint/ui/edit.py | 19 +- .../blueprint/ui/form_browseroptions.py | 95 ++++++ .../ui/templates/_browser_config_fields.html | 86 +++++ .../ui/templates/browser-config-edit.html | 21 ++ .../ui/templates/browsers-overview.html | 139 +++++++++ .../blueprint/ui/templates/edit.html | 16 + .../blueprint/watchlist/__init__.py | 8 +- .../watchlist/templates/watch-overview.html | 5 +- .../content_fetchers/__init__.py | 44 ++- changedetectionio/content_fetchers/base.py | 4 + .../content_fetchers/playwright.py | 1 + .../content_fetchers/puppeteer.py | 1 + changedetectionio/flask_app.py | 5 +- changedetectionio/model/Watch.py | 11 +- changedetectionio/model/browser_config.py | 294 ++++++++++++++++++ changedetectionio/pluggy_interface.py | 10 +- changedetectionio/processors/base.py | 5 +- changedetectionio/store/__init__.py | 6 + changedetectionio/templates/sidebar-nav.html | 9 +- .../tests/test_browser_config.py | 268 ++++++++++++++++ changedetectionio/translations/messages.pot | 2 +- 28 files changed, 1304 insertions(+), 42 deletions(-) create mode 100644 changedetectionio/blueprint/ui/browser_config.py create mode 100644 changedetectionio/blueprint/ui/form_browseroptions.py create mode 100644 changedetectionio/blueprint/ui/templates/_browser_config_fields.html create mode 100644 changedetectionio/blueprint/ui/templates/browser-config-edit.html create mode 100644 changedetectionio/blueprint/ui/templates/browsers-overview.html create mode 100644 changedetectionio/model/browser_config.py create mode 100644 changedetectionio/tests/test_browser_config.py diff --git a/changedetectionio/api/Import.py b/changedetectionio/api/Import.py index 9b0cec4e4..d622a6d58 100644 --- a/changedetectionio/api/Import.py +++ b/changedetectionio/api/Import.py @@ -158,11 +158,12 @@ class Import(Resource): if 'fetch_backend' in extras: from changedetectionio.content_fetchers import available_fetchers available = [f[0] for f in available_fetchers()] - # Also allow 'system' and extra_browser_* patterns + # Also allow 'system', extra_browser_* patterns, and a saved browser-config id. is_valid = ( extras['fetch_backend'] == 'system' or extras['fetch_backend'] in available or - extras['fetch_backend'].startswith('extra_browser_') + extras['fetch_backend'].startswith('extra_browser_') or + bool(self.datastore.browser_config_store.get(extras['fetch_backend'])) ) if not is_valid: return f"Invalid fetch_backend '{extras['fetch_backend']}'. Available: system, {', '.join(available)}", 400 diff --git a/changedetectionio/blueprint/settings/__init__.py b/changedetectionio/blueprint/settings/__init__.py index d00205d52..164436a6d 100644 --- a/changedetectionio/blueprint/settings/__init__.py +++ b/changedetectionio/blueprint/settings/__init__.py @@ -66,6 +66,18 @@ def construct_blueprint(datastore: ChangeDetectionStore): extra_notification_tokens=datastore.get_unique_notification_tokens_available() ) + # The global default fetch method is now the "Default browser": the always-present + # built-in engines + the user's saved browsers (no 'system' - this IS the system + # default). Everything else in the app resolves 'system' back to this value. + from changedetectionio.model.browser_config import list_builtin_browsers + default_browser_choices = [(b['id'], b['label']) for b in list_builtin_browsers()] + default_browser_choices += [(cid, e.get('label') or cid) + for cid, e in datastore.browser_config_store.all().items()] + form.application.form.fetch_backend.choices = default_browser_choices + form.application.form.fetch_backend.label.text = gettext('Default browser') + # Accept legacy raw-engine values / ids not in the (dynamic) choice list. + form.application.form.fetch_backend.validate_choice = False + # Remove the last option 'System default' form.application.form.notification_format.choices.pop() diff --git a/changedetectionio/blueprint/tags/__init__.py b/changedetectionio/blueprint/tags/__init__.py index d20603e96..74a2dd3c9 100644 --- a/changedetectionio/blueprint/tags/__init__.py +++ b/changedetectionio/blueprint/tags/__init__.py @@ -8,6 +8,20 @@ from changedetectionio.flask_app import login_optionally_required from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config +def _browser_config_choices(datastore): + """Browsers the group can override with: the always-present built-in engines, then the + user's saved browsers - the same set a watch can pick. No 'None' entry: the enabler + checkbox (browser_config_overrides_watch) is what turns the override on/off, and the + select is disabled when it's unchecked.""" + from changedetectionio.model.browser_config import list_builtin_browsers + choices = [] + for b in list_builtin_browsers(): + choices.append((b['id'], b['label'])) + for cid, entry in datastore.browser_config_store.all().items(): + choices.append((cid, entry.get('label') or cid)) + return choices + + def construct_blueprint(datastore: ChangeDetectionStore): tags_blueprint = Blueprint('tags', __name__, template_folder="templates") @@ -163,6 +177,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): extra_notification_tokens=datastore.get_unique_notification_tokens_available(), default_system_settings = datastore.data['settings'], ) + form.browser_config.choices = _browser_config_choices(datastore) # Bridge API-stored processor_config_* values into the form's FormField sub-forms. # The API stores processor_config_restock_diff in the tag dict; find the matching @@ -244,6 +259,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): data=tag, extra_notification_tokens=datastore.get_unique_notification_tokens_available() ) + form.browser_config.choices = _browser_config_choices(datastore) # @todo subclass form so validation works #if not form.validate(): # for widget, l in form.errors.items(): diff --git a/changedetectionio/blueprint/tags/form.py b/changedetectionio/blueprint/tags/form.py index 543ab7b0a..6d8c8f21c 100644 --- a/changedetectionio/blueprint/tags/form.py +++ b/changedetectionio/blueprint/tags/form.py @@ -1,5 +1,6 @@ from wtforms import ( Form, + SelectField, StringField, SubmitField, TextAreaField, @@ -14,6 +15,13 @@ from changedetectionio.llm.evaluator import DEFAULT_CHANGE_SUMMARY_PROMPT class group_restock_settings_form(restock_settings_form): overrides_watch = BooleanField(_l('Activate for individual watches in this tag/group?'), default=False) + + # Browser config override for this group. Its own dedicated enabler (NOT the coarse + # legacy `overrides_watch` above) - see resolve_browser_config_override(). When the + # enabler is on and a config is chosen, member watches use this group's browser config + # and their own Fetch Method selector is hidden. Choices are set in the tags blueprint. + browser_config_overrides_watch = BooleanField(_l("Override each watch's browser with"), default=False) + browser_config = SelectField(_l('Browser config'), validators=[validators.Optional()], choices=[]) url_match_pattern = StringField(_l('Auto-apply to watches with URLs matching'), render_kw={"placeholder": _l("e.g. *://example.com/* or github.com/myorg")}) tag_colour = StringField(_l('Tag colour'), default='') diff --git a/changedetectionio/blueprint/tags/templates/edit-tag.html b/changedetectionio/blueprint/tags/templates/edit-tag.html index a6811a535..a614f0403 100644 --- a/changedetectionio/blueprint/tags/templates/edit-tag.html +++ b/changedetectionio/blueprint/tags/templates/edit-tag.html @@ -27,6 +27,7 @@
+
+
+
+
+ {{ form.browser_config_overrides_watch() }} + + {{ form.browser_config() }} +
+ + {{ _('When enabled, watches in this group use the selected browser and their own Browser choice is hidden.') }} + {{ _('Manage browsers') }} + + +
+
+
+ {% if llm_configured %}
{% include "edit/include_llm_intent.html" %} diff --git a/changedetectionio/blueprint/ui/__init__.py b/changedetectionio/blueprint/ui/__init__.py index 9804564e8..32c097c6c 100644 --- a/changedetectionio/blueprint/ui/__init__.py +++ b/changedetectionio/blueprint/ui/__init__.py @@ -9,7 +9,7 @@ from changedetectionio.blueprint.ui.edit import construct_blueprint as construct from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint from changedetectionio.blueprint.ui.queue import construct_blueprint as construct_queue_blueprint -from changedetectionio.blueprint.ui import diff, preview +from changedetectionio.blueprint.ui import diff, preview, browser_config def _handle_operations(op, uuids, datastore, worker_pool, update_q, queuedWatchMetaData, watch_check_update, extra_data=None, emit_flash=True): """Apply a bulk operation to the given watch uuids. @@ -98,11 +98,12 @@ def _handle_operations(op, uuids, datastore, worker_pool, update_q, queuedWatchM result_message = gettext("{} watches set to use default notification settings").format(len(uuids)) elif (op == 'set-fetch-backend'): - # extra_data = the chosen fetch backend key (e.g. 'html_requests', 'html_webdriver') + # extra_data = the chosen "Browser": 'system', a built-in engine name, an + # extra_browser_* endpoint, or a saved browser-config id - same set as the edit picker. from changedetectionio import content_fetchers - # 'system' = inherit the global default fetch method (resolved at fetch time), same as the edit page - valid = [f[0] for f in content_fetchers.available_fetchers()] + ['system'] - if extra_data in valid: + valid = set(f[0] for f in content_fetchers.available_fetchers()) | {'system'} \ + | set(datastore.browser_config_store.all().keys()) + if extra_data in valid or (extra_data or '').startswith('extra_browser_'): for uuid in uuids: if datastore.data['watching'].get(uuid): datastore.data['watching'][uuid]['fetch_backend'] = extra_data @@ -178,6 +179,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, queue_blueprint = construct_queue_blueprint(datastore, update_q) ui_blueprint.register_blueprint(queue_blueprint) + # Register the browser configs ("Browsers") blueprint + browser_config_blueprint = browser_config.construct_blueprint(datastore) + ui_blueprint.register_blueprint(browser_config_blueprint) + # Import the login decorator from changedetectionio.auth_decorator import login_optionally_required diff --git a/changedetectionio/blueprint/ui/browser_config.py b/changedetectionio/blueprint/ui/browser_config.py new file mode 100644 index 000000000..a96790237 --- /dev/null +++ b/changedetectionio/blueprint/ui/browser_config.py @@ -0,0 +1,216 @@ +""" +Browsers blueprint - manage named browser configs ("profiles"). + +Lists the available base content fetchers (engines) with their capabilities, and lets the +user add / edit / remove / set-default browser configs, persisted in browsers.json via +datastore.browser_config_store. + +Registered as a sub-blueprint of `ui`, so endpoints are `ui.browser_config.*`. +""" +from flask import Blueprint, request, render_template, redirect, url_for, flash +from flask_babel import gettext +from loguru import logger +from pydantic import ValidationError + +from changedetectionio.store import ChangeDetectionStore +from changedetectionio.flask_app import login_optionally_required + + +def _base_fetchers(datastore): + """List of built-in engine 'browsers' with capabilities and any stored overrides. + + Each built-in may have a browsers.json entry keyed by the engine name (created when the + user edits it); if so we surface its stored browser_config so the overview shows it. + """ + from changedetectionio import content_fetchers + from changedetectionio.content_fetchers.base import FetcherCapabilities + out = [] + for name, description in content_fetchers.available_fetchers(): + caps = FetcherCapabilities.from_fetcher(getattr(content_fetchers, name, None)) + stored = datastore.browser_config_store.get(name) or {} + out.append({ + 'name': name, + 'description': description, + 'capabilities': caps.model_dump(), + 'browser_config': stored.get('browser_config') or {}, + }) + return out + + +def _caps_for(base_name): + """Capability dict for an engine, so the form renders only the fields it can honour + (e.g. html_requests has no screenshots -> no viewport/locale/timezone).""" + from changedetectionio import content_fetchers + from changedetectionio.content_fetchers.base import FetcherCapabilities + return FetcherCapabilities.from_fetcher(getattr(content_fetchers, base_name, None)).model_dump() + + +def _entry_to_formdata(entry): + """Flatten a browsers.json entry into flat form field values.""" + data = { + 'label': entry.get('label'), + 'base_fetcher': entry.get('base_fetcher'), + } + data.update(entry.get('browser_config') or {}) + return data + + +def construct_blueprint(datastore: ChangeDetectionStore): + browser_config_blueprint = Blueprint('browser_config', __name__, template_folder="templates") + + def _render_overview(add_form=None, open_add_form=False): + from .form_browseroptions import BrowserOptionsForm + if add_form is None: + add_form = BrowserOptionsForm() + # A browser config only makes sense if a browser-capable engine exists; the form's + # base_fetcher choices are already filtered to those, so an empty list => none. + browser_engine_available = bool(add_form.base_fetcher.choices) + # Gate the add form's fields by its selected (default first) base engine's capabilities. + add_base = add_form.base_fetcher.data or (add_form.base_fetcher.choices[0][0] + if add_form.base_fetcher.choices else None) + return render_template( + "browsers-overview.html", + base_fetchers=_base_fetchers(datastore), + browser_configs=datastore.browser_config_store.all(), + add_form=add_form, + open_add_form=open_add_form, + browser_engine_available=browser_engine_available, + # The default browser is the global system fetch_backend (single source of truth). + default_browser_id=datastore.data['settings']['application'].get('fetch_backend'), + caps=_caps_for(add_base) if add_base else {}, + ) + + @browser_config_blueprint.route("/browsers", methods=['GET']) + @login_optionally_required + def browsers_overview(): + return _render_overview() + + def _label_is_taken(label, exclude_id=None): + """True if another browser config already uses this label (case-insensitive). + Also guards against colliding with a built-in engine's name.""" + from changedetectionio.model.browser_config import list_builtin_browsers + norm = (label or '').strip().lower() + for b in list_builtin_browsers(): + if b['label'].strip().lower() == norm: + return True + for cid, entry in datastore.browser_config_store.all().items(): + if cid != exclude_id and (entry.get('label') or '').strip().lower() == norm: + return True + return False + + def _validate_and_build_config(form): + """Return a validated FetcherConfig, or None (with errors attached to form).""" + from changedetectionio.model.browser_config import FetcherConfig + try: + return FetcherConfig(**form.to_fetcher_config_dict()) + except ValidationError as e: + for err in e.errors(): + loc = err['loc'][0] if err['loc'] else '' + field = getattr(form, str(loc), None) + if field is not None: + field.errors.append(err['msg']) + else: + flash(err['msg'], 'error') + return None + + @browser_config_blueprint.route("/browsers/add", methods=['GET', 'POST']) + @login_optionally_required + def browser_config_add(): + from .form_browseroptions import BrowserOptionsForm + # The add form lives inline on the overview page; a bare GET just goes there. + if request.method == 'GET': + return redirect(url_for('ui.browser_config.browsers_overview')) + + form = BrowserOptionsForm(request.form) + if form.validate(): + if _label_is_taken(form.label.data): + form.label.errors.append(gettext("A browser with this name already exists")) + else: + cfg = _validate_and_build_config(form) + if cfg is not None: + datastore.browser_config_store.add( + label=form.label.data, + base_fetcher=form.base_fetcher.data, + browser_config=cfg.model_dump(exclude_defaults=True), + ) + flash(gettext("Browser added")) + return redirect(url_for('ui.browser_config.browsers_overview')) + + # Invalid - re-render the overview with the form open and errors shown inline. + return _render_overview(add_form=form, open_add_form=True) + + @browser_config_blueprint.route("/browsers/edit/", methods=['GET', 'POST']) + @login_optionally_required + def browser_config_edit(config_id): + from .form_browseroptions import BrowserOptionsForm + from changedetectionio.model.browser_config import list_builtin_browsers + + # Built-in engine browsers are editable too: their config is stored in browsers.json + # keyed by the engine name (e.g. 'html_webdriver'), and the resolver picks it up when a + # watch/global uses that engine. The engine is fixed (base_fetcher == the id). + builtins = {b['id']: b for b in list_builtin_browsers()} + is_builtin = config_id in builtins + entry = datastore.browser_config_store.get(config_id) + if not entry and not is_builtin: + flash(gettext("Browser config not found"), 'error') + return redirect(url_for('ui.browser_config.browsers_overview')) + + if request.method == 'POST': + form = BrowserOptionsForm(request.form) + if is_builtin: + # Lock the engine to this built-in; the label is fixed too. + form.base_fetcher.choices = [(config_id, builtins[config_id]['label'])] + if form.validate(): + if (not is_builtin) and _label_is_taken(form.label.data, exclude_id=config_id): + form.label.errors.append(gettext("A browser with this name already exists")) + else: + cfg = _validate_and_build_config(form) + if cfg is not None: + base = config_id if is_builtin else form.base_fetcher.data + datastore.browser_config_store.upsert( + config_id, + # built-in labels come from lazy_gettext - coerce to a plain str for storage + label=(str(builtins[config_id]['label']) if is_builtin else form.label.data), + base_fetcher=base, + browser_config=cfg.model_dump(exclude_defaults=True), + ) + flash(gettext("Browser config updated")) + return redirect(url_for('ui.browser_config.browsers_overview')) + else: + if entry: + form = BrowserOptionsForm(data=_entry_to_formdata(entry)) + else: + # Built-in with no overrides yet - prefill with its identity. + form = BrowserOptionsForm(data={'label': builtins[config_id]['label'], 'base_fetcher': config_id}) + if is_builtin: + form.base_fetcher.choices = [(config_id, builtins[config_id]['label'])] + + edit_base = config_id if is_builtin else ((entry or {}).get('base_fetcher') or form.base_fetcher.data) + return render_template("browser-config-edit.html", form=form, mode='edit', + config_id=config_id, is_builtin=is_builtin, + caps=_caps_for(edit_base) if edit_base else {}) + + @browser_config_blueprint.route("/browsers/remove/", methods=['POST']) + @login_optionally_required + def browser_config_remove(config_id): + if datastore.browser_config_store.delete(config_id): + flash(gettext("Browser config removed")) + else: + flash(gettext("Browser config not found"), 'error') + return redirect(url_for('ui.browser_config.browsers_overview')) + + @browser_config_blueprint.route("/browsers/set-default/", methods=['POST']) + @login_optionally_required + def browser_config_set_default(config_id): + # "Default" is the global system fetch_backend - the single source of truth that a + # watch/group set to 'system' resolves to. This is settable from here or from Settings. + from changedetectionio.model.browser_config import list_builtin_browsers + builtins = {b['id'] for b in list_builtin_browsers()} + if config_id in builtins or datastore.browser_config_store.get(config_id): + datastore.data['settings']['application']['fetch_backend'] = config_id + flash(gettext("Default browser set")) + else: + flash(gettext("Browser config not found"), 'error') + return redirect(url_for('ui.browser_config.browsers_overview')) + + return browser_config_blueprint diff --git a/changedetectionio/blueprint/ui/edit.py b/changedetectionio/blueprint/ui/edit.py index 86b71b94d..691d66425 100644 --- a/changedetectionio/blueprint/ui/edit.py +++ b/changedetectionio/blueprint/ui/edit.py @@ -121,6 +121,18 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe default_system_settings=datastore.data['settings'] ) + # The watch-level fetch selector is now a "Browser" picker: system default + the + # user's saved browsers (browsers.json). Raw engines are chosen only as a browser's + # base in the Add Browser form. Set choices per-request (datastore-dependent). + from changedetectionio.model.browser_config import list_watch_browser_choices + form.fetch_backend.choices = list_watch_browser_choices(datastore) + form.fetch_backend.label.text = gettext('Browser') + # Only offer Default + user browsers, but still ACCEPT legacy raw-engine values + # ('html_requests'/'html_webdriver'/'extra_browser_*') that existing watches/API + # clients store - resolve_content_fetcher handles them. Without this, editing a + # pre-existing watch would fail RadioField choice validation. + form.fetch_backend.validate_choice = False + # For the form widget tag UUID back to "string name" for the field form.tags.datastore = datastore @@ -161,11 +173,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe except Exception as e: logger.warning(f"Failed to load processor config: {e}") + # extra_browsers (CDP endpoints) are also selectable browsers. The 'system' / + # "Default (system settings)" entry is already provided by list_watch_browser_choices() + # above, so do NOT append another system option here (was a duplicate). for p in datastore.extra_browsers: form.fetch_backend.choices.append(p) - form.fetch_backend.choices.append(("system", gettext('System settings default'))) - # form.browser_steps[0] can be assumed that we 'goto url' first if datastore.proxy_list is None: @@ -315,9 +328,11 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe if worker_pool.is_watch_running(uuid): c.append('checking-now') + from changedetectionio.model.browser_config import resolve_browser_config_override template_args = { 'available_processors': processors.available_processors(), 'available_timezones': sorted(available_timezones()), + 'browser_config_group_override': resolve_browser_config_override(watch, datastore), 'browser_steps_config': browser_step_ui_config, 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), 'extra_classes': ' '.join(c), diff --git a/changedetectionio/blueprint/ui/form_browseroptions.py b/changedetectionio/blueprint/ui/form_browseroptions.py new file mode 100644 index 000000000..1dfed5d22 --- /dev/null +++ b/changedetectionio/blueprint/ui/form_browseroptions.py @@ -0,0 +1,95 @@ +""" +WTForms for the Browsers blueprint (browser configs / profiles). + +Kept out of the top-level forms.py (which is already large). The field names match the +FetcherConfig pydantic model exactly, so the blueprint can map form -> FetcherConfig with a +plain dict comprehension. Value validation for locale/timezone lives in the pydantic model +(single source of truth); this form only enforces the UI-level rules (label required, types). +""" +from flask_babel import lazy_gettext as _l +from wtforms import ( + BooleanField, + Form, + IntegerField, + SelectField, + SelectMultipleField, + StringField, + SubmitField, + validators, + widgets, +) + +from changedetectionio import content_fetchers +from changedetectionio.forms import StringListField + +# Playwright/puppeteer request.resource_type values worth blocking to save proxy bandwidth. +BLOCKABLE_RESOURCE_TYPES = [ + ('image', _l('Images')), + ('font', _l('Fonts')), + ('media', _l('Media (audio/video)')), + ('stylesheet', _l('Stylesheets (CSS)')), + ('script', _l('Scripts (JS)')), + ('xhr', _l('XHR / fetch')), + ('websocket', _l('WebSockets')), +] + +SCREENSHOT_FORMATS = [('JPEG', 'JPEG'), ('PNG', 'PNG')] + + +class MultiCheckboxField(SelectMultipleField): + """Render a SelectMultipleField as a list of checkboxes.""" + widget = widgets.ListWidget(prefix_label=False) + option_widget = widgets.CheckboxInput() + + +class BrowserOptionsForm(Form): + # Human name shown in the list - REQUIRED, this is the label a watch selects by. + label = StringField(_l('Name'), validators=[ + validators.DataRequired(message=_l("Give this browser config a name")), + validators.Length(min=1, max=120), + ], render_kw={"placeholder": _l("e.g. Desktop Full-HD, Mobile de-DE")}) + + # Which base engine/content_fetcher this config layers on top of. + base_fetcher = SelectField(_l('Base browser'), validators=[validators.DataRequired()]) + + # --- FetcherConfig fields (names match the pydantic model) --- + viewport_width = IntegerField(_l('Viewport width'), validators=[ + validators.Optional(), validators.NumberRange(min=1, max=10000)]) + viewport_height = IntegerField(_l('Viewport height'), validators=[ + validators.Optional(), validators.NumberRange(min=1, max=10000)]) + + locale = StringField(_l('Locale'), validators=[validators.Optional(), validators.Length(max=35)], + render_kw={"placeholder": "de-DE"}) + timezone_id = StringField(_l('Timezone'), validators=[validators.Optional(), validators.Length(max=64)], + render_kw={"placeholder": "Europe/Berlin"}) + + screenshot_format = SelectField(_l('Screenshot format'), choices=SCREENSHOT_FORMATS, default='JPEG') + + block_resource_types = MultiCheckboxField(_l('Block asset types'), choices=BLOCKABLE_RESOURCE_TYPES) + block_url_patterns = StringListField(_l('Block URL patterns'), validators=[validators.Optional()], + render_kw={"placeholder": "*.ttf\n*/analytics/*"}) + + save = SubmitField(_l('Save')) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Only browser-capable fetchers make sense as a base for these settings. + from changedetectionio.content_fetchers.base import FetcherCapabilities + choices = [] + for name, description in content_fetchers.available_fetchers(): + caps = FetcherCapabilities.from_fetcher(getattr(content_fetchers, name, None)) + if caps.supports_screenshots or caps.supports_xpath_element_data: + choices.append((name, description)) + self.base_fetcher.choices = choices + + def to_fetcher_config_dict(self): + """Map the FetcherConfig-named fields to a plain dict for FetcherConfig(**...).""" + return { + 'viewport_width': self.viewport_width.data, + 'viewport_height': self.viewport_height.data, + 'locale': (self.locale.data or None), + 'timezone_id': (self.timezone_id.data or None), + 'screenshot_format': self.screenshot_format.data, + 'block_resource_types': self.block_resource_types.data or [], + 'block_url_patterns': self.block_url_patterns.data or [], + } diff --git a/changedetectionio/blueprint/ui/templates/_browser_config_fields.html b/changedetectionio/blueprint/ui/templates/_browser_config_fields.html new file mode 100644 index 000000000..baa0bb862 --- /dev/null +++ b/changedetectionio/blueprint/ui/templates/_browser_config_fields.html @@ -0,0 +1,86 @@ +{# Browser config form fields, shared by the inline add form (browsers-overview) and the + standalone edit page (browser-config-edit). Expects `form` and `caps` (the selected base + engine's capability dict) in context. Fields are gated server-side by capability - e.g. + html_requests (no screenshots) doesn't render viewport/locale/timezone/screenshot. #} +{% from '_helpers.html' import render_field, render_checkbox_field %} + +
+
+ {{ render_field(form.label) }} + {{ _('A friendly name, e.g. “Mobile” or “German desktop”.') }} +
+
+ {{ render_field(form.base_fetcher) }} +
+
+ +{% if caps.supports_screenshots %} +
+ {{ _('Screen size') }} +
+
+ + + + + +
+
+
{{ render_field(form.viewport_width) }}
+
{{ render_field(form.viewport_height) }}
+
+ +
+ {{ _('Language & timezone') }} +
+ {{ render_field(form.locale) }} + {{ _('e.g. de-DE, en-GB.')|safe }} +
+
+ {{ render_field(form.timezone_id) }} + {{ _('e.g. Europe/Berlin.')|safe }} +
+
+{% endif %} + +{% if caps.supports_request_blocking %} +
+ {{ _('Skip downloads (save bandwidth)') }} +
+ +
+ {% for opt in form.block_resource_types %} + {{ opt() }} + {% endfor %} +
+
+
+ {{ render_field(form.block_url_patterns) }} + {{ _('One address pattern per line, e.g. *.ttf.')|safe }} +
+
+{% endif %} + +{% if caps.supports_screenshots %} +
+
{{ render_field(form.screenshot_format) }}
+
+{% endif %} + + + diff --git a/changedetectionio/blueprint/ui/templates/browser-config-edit.html b/changedetectionio/blueprint/ui/templates/browser-config-edit.html new file mode 100644 index 000000000..738a6190a --- /dev/null +++ b/changedetectionio/blueprint/ui/templates/browser-config-edit.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} +{% block content %} + +
+
+
+ + +

{{ _('Edit browser') }}

+ + {% include "_browser_config_fields.html" %} + +
+ {{ form.save(class="pure-button pure-button-primary") }} + {{ _('Cancel') }} +
+
+
+
+{% endblock %} diff --git a/changedetectionio/blueprint/ui/templates/browsers-overview.html b/changedetectionio/blueprint/ui/templates/browsers-overview.html new file mode 100644 index 000000000..c9aa4b75f --- /dev/null +++ b/changedetectionio/blueprint/ui/templates/browsers-overview.html @@ -0,0 +1,139 @@ +{% extends 'base.html' %} +{% block content %} + +
+
+ +

{{ _('Browsers') }}

+

+ {{ _('Save a browser once (its screen size, language, timezone…) and reuse it on any watch or group.') }} +

+ +
+
{{ _('Your browsers') }}
+
+ + + + + + + + + + + + {# Built-in engine browsers - always present, zero-override, not editable/removable #} + {% for f in base_fetchers %} + {% set fbc = f.browser_config or {} %} + + + + + + + {% endfor %} + {# User-defined browsers #} + {% for cid, entry in browser_configs.items() %} + {% set bc = entry.get('browser_config') or {} %} + + + + + + + {% endfor %} + +
{{ _('Name') }}{{ _('Setup') }}
+ {{ f.description }} + {{ _('Built-in') }} + {% if f.name == default_browser_id %}{{ _('Default') }}{% endif %} + + {% if fbc.get('viewport_width') %}{{ fbc.viewport_width }}×{{ fbc.viewport_height or '?' }}{% endif %} + {% if fbc.get('locale') %}{{ fbc.locale }}{% endif %} + {% if fbc.get('timezone_id') %}{{ fbc.timezone_id }}{% endif %} + {% if fbc.get('block_resource_types') %}{{ _('no') }} {{ fbc.block_resource_types|join(', ') }}{% endif %} + {% for cap, on in f.capabilities.items() %} + {% if on %}{{ cap.replace('supports_', '')|replace('_', ' ') }}{% endif %} + {% endfor %} + + {% if f.name != default_browser_id %} +
+ + +
+ {% endif %} +
+ {{ _('Edit') }} +
+ {{ entry.get('label') }} + {% if cid == default_browser_id %}{{ _('Default') }}{% endif %} + + {% if bc.get('viewport_width') %}{{ bc.viewport_width }}×{{ bc.viewport_height or '?' }}{% endif %} + {% if bc.get('locale') %}{{ bc.locale }}{% endif %} + {% if bc.get('timezone_id') %}{{ bc.timezone_id }}{% endif %} + {% if bc.get('block_resource_types') %}{{ _('no') }} {{ bc.block_resource_types|join(', ') }}{% endif %} + + {% if cid != default_browser_id %} +
+ + +
+ {% endif %} +
+ {{ _('Edit') }} +
+ + +
+
+ + {% if browser_engine_available %} +
+ + {{ _('Browser') }} +
+
+ + {% with form = add_form %} + {% include "_browser_config_fields.html" %} + {% endwith %} +
+ {{ add_form.save(class="pure-button pure-button-primary") }} +
+
+
+
+ {% else %} +
+

{{ _("No interactive browser is connected, so there's nothing to configure yet.") }}

+

{{ _('Connect a Chrome/Playwright browser (set the PLAYWRIGHT_DRIVER_URL environment variable) to add custom browsers here.')|safe }}

+
+ {% endif %} + +
+
+ + +{% endblock %} diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index aead58add..4aed6b6f0 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -141,6 +141,21 @@ {% if capabilities.supports_request_type %}
+ {% if browser_config_group_override %} +
+
+ {{ _('Using browser config') }} + {{ browser_config_group_override.label }} + {{ _('from group') }} + {{ browser_config_group_override.group_title }}. +
+ + {{ _('This group overrides the fetch method for its watches. Change it in the group settings, or turn off the override there to choose per-watch.') }} + + {# Keep the value submitted so nothing is lost, but hidden - the group config wins at resolve time. #} +
{{ form.fetch_backend(class="fetch-backend") }}
+
+ {% else %}
{{ render_field(form.fetch_backend, class="fetch-backend") }} @@ -149,6 +164,7 @@ {{ _('Tip:') }} {{ _('Connect using Bright Data and Oxylabs Proxies, find out more here.') }}
+ {% endif %} {% if form.proxy %}
{{ form.proxy.label }} {{ _('Check/Scan all') }}
diff --git a/changedetectionio/blueprint/watchlist/__init__.py b/changedetectionio/blueprint/watchlist/__init__.py index 58531cef4..e702fa0d7 100644 --- a/changedetectionio/blueprint/watchlist/__init__.py +++ b/changedetectionio/blueprint/watchlist/__init__.py @@ -98,8 +98,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe proxy_list = datastore.proxy_list - from changedetectionio import content_fetchers - available_fetchers = content_fetchers.available_fetchers() + # Same "Browser" choices as the watch edit picker (system default + built-ins + user + # browsers) for the bulk "Set browser" modal, so it stays consistent. + from changedetectionio.model.browser_config import list_watch_browser_choices + browser_choices = list_watch_browser_choices(datastore) from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER @@ -123,7 +125,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe wcag_text_color=processors.wcag_text_color, guid=datastore.data['app_guid'], has_proxies=proxy_list, - available_fetchers=available_fetchers, + browser_choices=browser_choices, #header=_("todo - tag name etc"), hosted_sticky=os.getenv("SALTED_PASS", False) == False, now_time_server=round(time.time()), diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index 3a4217e0e..effc24270 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -178,9 +178,8 @@ window.watchOverviewI18n = { {# Option lists for the bulk Browser / Proxy modals (rendered by watch-overview.js into a ModalDialog). Mirrors the choices on the watch edit page. Kept inert inside