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 @@
{% 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 %}
+
+
+
+{% if caps.supports_screenshots %}
+
+
+
+{% endif %}
+
+{% if caps.supports_request_blocking %}
+
+{% endif %}
+
+{% if caps.supports_screenshots %}
+
+{% 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 %}
+
+
+
+ {{ _('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. #}
+
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 until the modal is opened. #}
-
- {% for key, label in available_fetchers %}
-
+ {% for key, label in browser_choices %}
+
{% endfor %}
{% if has_proxies %}
diff --git a/changedetectionio/content_fetchers/__init__.py b/changedetectionio/content_fetchers/__init__.py
index 8f6e7303e..431720b56 100644
--- a/changedetectionio/content_fetchers/__init__.py
+++ b/changedetectionio/content_fetchers/__init__.py
@@ -120,18 +120,44 @@ def resolve_content_fetcher(watch, datastore):
- html_webdriver + browser_steps -> playwright override (puppeteer steps incomplete)
Returns:
- tuple: (fetcher_class, backend_name, custom_browser_connection_url)
- where `backend_name` is the fully-resolved concrete backend name the
- caller should stamp onto the fetcher instance as `.backend_name`.
+ tuple: (fetcher_class, backend_name, custom_browser_connection_url, browser_config)
+ where `backend_name` is the fully-resolved concrete backend name the caller should
+ stamp onto the fetcher instance as `.backend_name`, and `browser_config` is the
+ resolved FetcherConfig to inject as `.browser_config`.
"""
this_module = sys.modules[__name__]
+ from changedetectionio.model.browser_config import (
+ FetcherConfig, resolve_browser_config_override, BrowserConfigDoesntExist,
+ )
- # 1. Watch preference (later: watch -> group -> system)
- prefer_fetch_backend = watch.get('fetch_backend', 'system')
+ # Default behaviour = empty config (built-in engines / system default).
+ browser_config = FetcherConfig()
- # 2/3. Fall back to the global/system default
- if not prefer_fetch_backend or prefer_fetch_backend == 'system':
- prefer_fetch_backend = datastore.data['settings']['application'].get('fetch_backend')
+ # Selection order: a group override wins, else the watch's own selector value.
+ # The value is either a built-in engine name ('html_requests', 'html_webdriver',
+ # 'extra_browser_*'), the sentinel 'system', or the stable id of a user browser config.
+ override = resolve_browser_config_override(watch, datastore)
+ selected = override['config_id'] if override else watch.get('fetch_backend', 'system')
+
+ # 'system' -> the global default, which may itself be a browser-config id or an engine name.
+ if not selected or selected == 'system':
+ selected = datastore.data['settings']['application'].get('fetch_backend')
+
+ store = getattr(datastore, 'browser_config_store', None)
+ entry = store.get(selected) if (store and selected) else None
+ if entry:
+ # A user-defined browser: its base_fetcher is the engine, plus its behaviour config.
+ prefer_fetch_backend = entry.get('base_fetcher') or 'html_webdriver'
+ browser_config = FetcherConfig(**(entry.get('browser_config') or {}))
+ else:
+ # Not a stored browser config. The only valid non-config values are a built-in engine
+ # name or 'extra_browser_*'. Anything else is a reference to a browser config that has
+ # been deleted - fail loudly instead of silently defaulting.
+ if selected and selected != 'system' \
+ and not selected.startswith('extra_browser_') \
+ and not hasattr(this_module, selected):
+ raise BrowserConfigDoesntExist(config_id=selected, uuid=watch.get('uuid'))
+ prefer_fetch_backend = selected
# Custom browser endpoint (extra_browser_) -> webdriver with a specific connection URL
custom_browser_connection_url = None
@@ -172,7 +198,7 @@ def resolve_content_fetcher(watch, datastore):
_log_fetcher_capabilities(fetcher_obj, prefer_fetch_backend, uuid=watch.get('uuid'))
- return fetcher_obj, prefer_fetch_backend, custom_browser_connection_url
+ return fetcher_obj, prefer_fetch_backend, custom_browser_connection_url, browser_config
# Decide which is the 'real' HTML webdriver, this is more a system wide config
diff --git a/changedetectionio/content_fetchers/base.py b/changedetectionio/content_fetchers/base.py
index 12a1c6803..c52a951a1 100644
--- a/changedetectionio/content_fetchers/base.py
+++ b/changedetectionio/content_fetchers/base.py
@@ -17,6 +17,7 @@ class FetcherCapabilities(BaseModel):
supports_browser_steps: bool = False # Can execute browser automation steps
supports_screenshots: bool = False # Can capture page screenshots
supports_xpath_element_data: bool = False # Can extract xpath element positions for visual selector
+ supports_request_blocking: bool = False # Can block requests by resource-type / URL pattern
@classmethod
def from_fetcher(cls, fetcher_class):
@@ -65,6 +66,9 @@ class Fetcher():
# (e.g. 'html_requests', 'html_webdriver'). Set by resolve_content_fetcher()
# so downstream consumers don't have to re-derive it from the class name.
backend_name = None
+ # Resolved per-watch browser behaviour (FetcherConfig), injected after construction.
+ # None by default so a fetcher that never reads it is completely unaffected.
+ browser_config = None
browser_connection_is_custom = None
browser_connection_url = None
browser_steps = None
diff --git a/changedetectionio/content_fetchers/playwright.py b/changedetectionio/content_fetchers/playwright.py
index 6c5256b42..6d5e152cd 100644
--- a/changedetectionio/content_fetchers/playwright.py
+++ b/changedetectionio/content_fetchers/playwright.py
@@ -170,6 +170,7 @@ class fetcher(Fetcher):
supports_browser_steps = True
supports_screenshots = True
supports_xpath_element_data = True
+ supports_request_blocking = True
@classmethod
def get_status_icon_data(cls):
diff --git a/changedetectionio/content_fetchers/puppeteer.py b/changedetectionio/content_fetchers/puppeteer.py
index 39699645f..b163332a0 100644
--- a/changedetectionio/content_fetchers/puppeteer.py
+++ b/changedetectionio/content_fetchers/puppeteer.py
@@ -184,6 +184,7 @@ class fetcher(Fetcher):
supports_browser_steps = True
supports_screenshots = True
supports_xpath_element_data = True
+ supports_request_blocking = True
@classmethod
def get_status_icon_data(cls):
diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py
index ffa17b45b..a2555970c 100644
--- a/changedetectionio/flask_app.py
+++ b/changedetectionio/flask_app.py
@@ -521,8 +521,11 @@ def clean_startup_state(datastore):
try:
from changedetectionio import content_fetchers
valid_fetchers = {name for name, _desc in content_fetchers.available_fetchers()}
+ # A user browser-config id is also a valid default (it maps to an engine).
+ valid_fetchers |= set(datastore.browser_config_store.all().keys())
cur_default = datastore.data['settings']['application'].get('fetch_backend')
- if cur_default and cur_default != 'system' and cur_default not in valid_fetchers:
+ if cur_default and cur_default != 'system' and cur_default not in valid_fetchers \
+ and not cur_default.startswith('extra_browser_'):
logger.warning(
f"Configured default fetch_backend '{cur_default}' is not an available fetcher "
f"(plugin uninstalled?) - resetting default to 'html_requests'."
diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py
index dc7fbe8a6..f30c86830 100644
--- a/changedetectionio/model/Watch.py
+++ b/changedetectionio/model/Watch.py
@@ -405,10 +405,15 @@ class model(EntityPersistenceMixin, watch_base):
"""
from changedetectionio import content_fetchers
from changedetectionio.content_fetchers.base import FetcherCapabilities
+ from changedetectionio.model.browser_config import base_fetcher_for
- fetcher_name = self.get_fetch_backend # already handles is_pdf → html_requests
- if not fetcher_name or fetcher_name == 'system':
- fetcher_name = self._datastore['settings']['application'].get('fetch_backend', 'html_requests')
+ # get_fetch_backend handles is_pdf; base_fetcher_for maps a browser-config id / 'system'
+ # to the concrete engine name (and PDF -> html_requests is preserved by get_fetch_backend).
+ fetch_backend = self.get_fetch_backend
+ if fetch_backend == 'html_requests':
+ fetcher_name = 'html_requests'
+ else:
+ fetcher_name = base_fetcher_for(fetch_backend, self._datastore)
# Shared capability model - handles an unknown/None fetcher class (all-False)
fetcher_class = getattr(content_fetchers, fetcher_name, None)
diff --git a/changedetectionio/model/browser_config.py b/changedetectionio/model/browser_config.py
new file mode 100644
index 000000000..5d89a3f94
--- /dev/null
+++ b/changedetectionio/model/browser_config.py
@@ -0,0 +1,294 @@
+"""
+Browser configs ("browser profiles") - named, engine-agnostic browser behaviour that a
+watch can select (viewport / locale / timezone / asset-blocking ...).
+
+Two layers live here:
+
+ * FetcherConfig - the behaviour schema. A watch resolves one of these (watch -> group
+ -> system default) and it is injected onto the content fetcher as
+ `.browser_config`; an updated fetcher applies the fields it can
+ honour and ignores the rest (capability-gated, e.g. block_* needs
+ FetcherCapabilities.supports_request_blocking). It is ALSO the on-disk
+ schema for each browsers.json entry.
+
+ * BrowserConfigStore - persistence manager for browsers.json, mirroring proxies.json: an
+ optional file loaded lazily, with CRUD + a single default. Absent
+ file == no user configs == built-in default behaviour, so there is
+ nothing to create at startup.
+
+browsers.json shape:
+ { "": {"label": str, "base_fetcher": str, "is_default": bool,
+ "browser_config": { }}, ... }
+
+The id is a stable uuid assigned once (NOT a hash of the settings) so editing a config never
+orphans the watches that reference it.
+"""
+import os
+import uuid as uuid_builder
+from os import path
+from typing import List, Optional
+
+from loguru import logger
+from pydantic import BaseModel, Field, field_validator
+
+try:
+ import orjson
+ HAS_ORJSON = True
+except ImportError:
+ import json
+ HAS_ORJSON = False
+
+
+class BrowserConfigDoesntExist(Exception):
+ """A watch/group references a browser config id that no longer exists in browsers.json."""
+ def __init__(self, config_id, uuid=None):
+ self.config_id = config_id
+ self.uuid = uuid
+ super().__init__(
+ f"Browser config '{config_id}' no longer exists"
+ f"{f' (watch {uuid})' if uuid else ''} - edit the watch/group and choose a browser."
+ )
+
+
+def _available_timezones():
+ # Cached IANA tz set (zoneinfo builds it fresh each call, ~1ms).
+ global _TZ_CACHE
+ try:
+ return _TZ_CACHE
+ except NameError:
+ from zoneinfo import available_timezones
+ _TZ_CACHE = available_timezones()
+ return _TZ_CACHE
+
+
+class FetcherConfig(BaseModel):
+ """Engine-agnostic per-instance browser behaviour.
+
+ Unlike LLMSettings we deliberately do NOT use extra='forbid': this is a schema that gets
+ written to disk (browsers.json) and backed up/restored across app versions, so it must
+ tolerate version skew (a key removed in a future release should still load). There are no
+ privileged fields here to protect against mass-assignment - everything is user-settable.
+ Keep every field optional with a sensible default.
+ """
+ # Rendering / device
+ viewport_width: Optional[int] = None # px; None -> engine default
+ viewport_height: Optional[int] = None
+ # Identity / locale
+ locale: Optional[str] = None # e.g. 'de-DE' -> Accept-Language + navigator.language
+ timezone_id: Optional[str] = None # e.g. 'Europe/Berlin'
+ # Screenshot
+ screenshot_format: str = 'JPEG'
+ # Cost / bandwidth - block assets (capability-gated by supports_request_blocking)
+ block_resource_types: List[str] = Field(default_factory=list) # e.g. ['image', 'font', 'media']
+ block_url_patterns: List[str] = Field(default_factory=list) # globs, e.g. ['*.ttf', '*/analytics/*']
+
+ @field_validator('locale')
+ @classmethod
+ def _validate_locale(cls, v):
+ """BCP-47 tag validated against babel's CLDR data (e.g. 'de-DE', 'en-GB')."""
+ if not v:
+ return v
+ from babel import Locale, UnknownLocaleError
+ try:
+ Locale.parse(v.strip(), sep='-')
+ except (UnknownLocaleError, ValueError):
+ raise ValueError(f"Unknown locale '{v}' - use a BCP-47 tag like 'de-DE' or 'en-GB'")
+ return v.strip()
+
+ @field_validator('timezone_id')
+ @classmethod
+ def _validate_timezone(cls, v):
+ """IANA timezone name validated against the stdlib zoneinfo database."""
+ if not v:
+ return v
+ if v.strip() not in _available_timezones():
+ raise ValueError(f"Unknown IANA timezone '{v}' - e.g. 'Europe/Berlin', 'UTC'")
+ return v.strip()
+
+
+class BrowserConfigEntry(BaseModel):
+ """One browsers.json entry (the id is the dict key, not stored in the entry).
+
+ Note: there is deliberately no `is_default` here. The default browser is the global
+ application `fetch_backend` (a single source of truth that can point at a built-in engine
+ OR a browser-config id), so it doesn't live per-entry - see base_fetcher_for().
+ """
+ label: str = ''
+ base_fetcher: str = 'html_webdriver'
+ browser_config: FetcherConfig = Field(default_factory=FetcherConfig)
+
+
+class BrowserConfigStore:
+ """Persistence manager for browsers.json. Thin, self-contained, backup-friendly."""
+
+ def __init__(self, datastore_path, lock):
+ self._path = os.path.join(datastore_path, 'browsers.json')
+ self._lock = lock
+
+ # ---- low level load/save ----
+ def all(self):
+ """Raw dict {id: entry-dict}. Empty dict when the file is absent."""
+ if not path.isfile(self._path):
+ return {}
+ try:
+ if HAS_ORJSON:
+ with open(self._path, 'rb') as f:
+ return orjson.loads(f.read()) or {}
+ with open(self._path, encoding='utf-8') as f:
+ return json.load(f) or {}
+ except Exception as e:
+ logger.error(f"Could not load browsers.json: {e}")
+ return {}
+
+ def _save(self, configs):
+ # Deferred import avoids a model -> store import cycle at module load.
+ from changedetectionio.store.file_saving_datastore import save_json_atomic
+ with self._lock:
+ save_json_atomic(self._path, configs, label="browsers")
+
+ # ---- CRUD ----
+ def get(self, config_id):
+ return self.all().get(config_id)
+
+ def add(self, label, base_fetcher, browser_config=None):
+ """Create a config; returns its stable id."""
+ configs = self.all()
+ new_id = str(uuid_builder.uuid4())
+ configs[new_id] = BrowserConfigEntry(
+ label=label,
+ base_fetcher=base_fetcher,
+ browser_config=FetcherConfig(**(browser_config or {})),
+ ).model_dump()
+ self._save(configs)
+ return new_id
+
+ def upsert(self, config_id, label, base_fetcher, browser_config=None):
+ """Create-or-replace an entry at a specific key. Used for built-in engine configs,
+ which are keyed by the engine name (e.g. 'html_webdriver') rather than a uuid."""
+ configs = self.all()
+ configs[config_id] = BrowserConfigEntry(
+ label=label,
+ base_fetcher=base_fetcher,
+ browser_config=FetcherConfig(**(browser_config or {})),
+ ).model_dump()
+ self._save(configs)
+ return config_id
+
+ def update(self, config_id, label=None, base_fetcher=None, browser_config=None):
+ configs = self.all()
+ raw = configs.get(config_id)
+ if not raw:
+ return False
+ entry = BrowserConfigEntry(**raw)
+ if label is not None:
+ entry.label = label
+ if base_fetcher is not None:
+ entry.base_fetcher = base_fetcher
+ if browser_config is not None:
+ entry.browser_config = FetcherConfig(**browser_config)
+ configs[config_id] = entry.model_dump()
+ self._save(configs)
+ return True
+
+ def delete(self, config_id):
+ configs = self.all()
+ if config_id in configs:
+ del configs[config_id]
+ self._save(configs)
+ return True
+ return False
+
+ def resolve_config(self, config_id):
+ """Validated FetcherConfig for an id, or None if the id is unknown."""
+ raw = self.get(config_id)
+ if not raw:
+ return None
+ return FetcherConfig(**(raw.get('browser_config') or {}))
+
+
+def base_fetcher_for(value, datastore):
+ """Map any fetch_backend value to the concrete engine/fetcher name.
+
+ Accepts a user browser-config id, a built-in engine name ('html_requests'/'html_webdriver'/
+ 'extra_browser_*'), or the sentinel 'system'. This is the single place the rest of the
+ codebase should go through when it needs the *engine* behind a watch/global fetch_backend
+ (capability checks, screenshot support, etc.). 'system' resolves to the global default,
+ which may itself be a browser-config id.
+ """
+ # `datastore` may be the ChangeDetectionStore (has .browser_config_store and .data) or the
+ # raw settings data dict (Watch._datastore). Tolerate both; browser-config lookups are only
+ # possible when the store object is available.
+ store = getattr(datastore, 'browser_config_store', None)
+ data = getattr(datastore, 'data', datastore)
+ entry = store.get(value) if (store and value) else None
+ if entry:
+ return entry.get('base_fetcher') or 'html_webdriver'
+ if not value or value == 'system':
+ gd = data['settings']['application'].get('fetch_backend', 'html_requests')
+ gd_entry = store.get(gd) if (store and gd) else None
+ return (gd_entry.get('base_fetcher') if gd_entry else gd) or 'html_requests'
+ return value
+
+
+def list_builtin_browsers():
+ """Built-in engine 'browsers' - always present, zero-override config.
+
+ These are the engines the app instantiated at boot (available_fetchers() already reflects
+ the env-driven html_webdriver -> playwright/puppeteer/selenium choice), exposed as
+ selectable browsers with a stable id == the engine name. That id equals the value existing
+ watches already store in fetch_backend, so nothing breaks and they always show in the list.
+ """
+ from changedetectionio import content_fetchers
+ return [{'id': name, 'label': description, 'base_fetcher': name}
+ for name, description in content_fetchers.available_fetchers()]
+
+
+def list_watch_browser_choices(datastore):
+ """(value, label) choices for the watch-level 'Browser' picker:
+ system default, the always-present built-in engine browsers, then the user's saved browsers.
+ """
+ choices = [('system', _system_default_label(datastore))]
+ 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 _system_default_label(datastore):
+ from flask_babel import gettext
+ return gettext('Default (system settings)')
+
+
+def resolve_browser_config_override(watch, datastore):
+ """If a group/tag overrides this watch's browser config, describe it, else None.
+
+ Uses the group's own dedicated enabler (browser_config_overrides_watch), NOT the coarse
+ legacy `overrides_watch` flag. Tags are checked in the watch's tag-list order; the first
+ one whose enabler is on and whose selected config still exists wins (deterministic).
+
+ Returns: {group_uuid, group_title, config_id, label} or None.
+ """
+ tags = datastore.data['settings']['application'].get('tags', {})
+ builtins = {b['id']: b for b in list_builtin_browsers()}
+ for tag_uuid in (watch.get('tags') or []):
+ tag = tags.get(tag_uuid)
+ if not tag:
+ continue
+ if tag.get('browser_config_overrides_watch') and tag.get('browser_config'):
+ cid = tag.get('browser_config')
+ entry = datastore.browser_config_store.get(cid)
+ # The override may point at a user browser (uuid) OR a built-in engine (id == name).
+ if entry:
+ label = entry.get('label')
+ elif cid in builtins:
+ label = builtins[cid]['label']
+ else:
+ continue # dangling / unknown - don't apply
+ return {
+ 'group_uuid': tag_uuid,
+ 'group_title': tag.get('title'),
+ 'config_id': cid,
+ 'label': label,
+ }
+ return None
diff --git a/changedetectionio/pluggy_interface.py b/changedetectionio/pluggy_interface.py
index 1f86e9dc7..6b5a036d5 100644
--- a/changedetectionio/pluggy_interface.py
+++ b/changedetectionio/pluggy_interface.py
@@ -456,12 +456,10 @@ def get_fetcher_capabilities(watch, datastore):
'supports_xpath_element_data': bool
}
"""
- # Get the fetcher name from watch
- fetcher_name = watch.get('fetch_backend', 'system')
-
- # Resolve 'system' to actual fetcher
- if fetcher_name == 'system':
- fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')
+ # Resolve the watch's fetch_backend (which may be a browser-config id, engine name or
+ # 'system') to the concrete engine name via the shared browser-config helper.
+ from changedetectionio.model.browser_config import base_fetcher_for
+ fetcher_name = base_fetcher_for(watch.get('fetch_backend', 'system'), datastore)
# Get the fetcher class
from changedetectionio import content_fetchers
diff --git a/changedetectionio/processors/base.py b/changedetectionio/processors/base.py
index f12ca64b2..d4c16a53c 100644
--- a/changedetectionio/processors/base.py
+++ b/changedetectionio/processors/base.py
@@ -195,7 +195,7 @@ class difference_detection_processor():
# truth (watch -> group -> system, extra_browser_/pdf/browser_steps overrides etc);
# the resolved backend name is stamped onto the fetcher instance below.
from changedetectionio.content_fetchers import resolve_content_fetcher
- fetcher_obj, prefer_fetch_backend, custom_browser_connection_url = resolve_content_fetcher(
+ fetcher_obj, prefer_fetch_backend, custom_browser_connection_url, browser_config = resolve_content_fetcher(
watch=self.watch, datastore=self.datastore)
proxy_url = None
@@ -219,6 +219,9 @@ class difference_detection_processor():
# Stamp the resolved backend name so downstream consumers (processors, plugins)
# can read it directly instead of re-deriving it from the fetcher class name.
self.fetcher.backend_name = prefer_fetch_backend
+ # Inject the resolved per-watch browser behaviour; fetchers that read it apply what
+ # they can, others ignore it. Never None so consumers can read attributes freely.
+ self.fetcher.browser_config = browser_config
if self.watch.has_browser_steps:
self.fetcher.browser_steps = browser_steps_get_valid_steps(self.watch.get('browser_steps', []))
diff --git a/changedetectionio/store/__init__.py b/changedetectionio/store/__init__.py
index 8d8978d44..6ec61170a 100644
--- a/changedetectionio/store/__init__.py
+++ b/changedetectionio/store/__init__.py
@@ -58,6 +58,12 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# logging.basicConfig(filename='/dev/stdout', level=logging.INFO)
self.datastore_path = datastore_path
self.start_time = time.time()
+
+ # Named browser configs (browsers.json) - optional, backup-friendly, lazy-loaded.
+ # Set here in __init__ so it exists on every path (fresh install included).
+ from changedetectionio.model.browser_config import BrowserConfigStore
+ self.browser_config_store = BrowserConfigStore(self.datastore_path, self.lock)
+
self.save_version_copy_json_db(version_tag)
self.reload_state(datastore_path=datastore_path, include_default_watches=include_default_watches, version_tag=version_tag)
diff --git a/changedetectionio/templates/sidebar-nav.html b/changedetectionio/templates/sidebar-nav.html
index 2c0b0fb68..0cc35a128 100644
--- a/changedetectionio/templates/sidebar-nav.html
+++ b/changedetectionio/templates/sidebar-nav.html
@@ -68,14 +68,11 @@