UI - Another attempt at configurable browsers

This commit is contained in:
dgtlmoon
2026-07-16 21:30:52 +02:00
parent 71d332d5a0
commit ee35b3da1a
28 changed files with 1304 additions and 42 deletions
+3 -2
View File
@@ -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
@@ -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()
@@ -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():
+8
View File
@@ -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='')
@@ -27,6 +27,7 @@
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#general">{{ _('General') }}</a></li>
<li class="tab"><a href="#browser">{{ _('Browser') }}</a></li>
{% if llm_configured %}
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
{% endif %}
@@ -91,6 +92,30 @@
</fieldset>
</div>
<div class="tab-pane-inner" id="browser">
<fieldset>
<div class="pure-control-group">
<div style="display:flex; align-items:center; gap:0.75em; flex-wrap:wrap;">
{{ form.browser_config_overrides_watch() }}
<label for="{{ form.browser_config_overrides_watch.id }}" style="margin:0">{{ form.browser_config_overrides_watch.label.text }}</label>
{{ form.browser_config() }}
</div>
<span class="pure-form-message-inline">
{{ _('When enabled, watches in this group use the selected browser and their own Browser choice is hidden.') }}
<a href="{{ url_for('ui.browser_config.browsers_overview') }}">{{ _('Manage browsers') }}</a>
</span>
<script>
(function () {
var en = document.getElementById('{{ form.browser_config_overrides_watch.id }}');
var sel = document.getElementById('{{ form.browser_config.id }}');
function sync() { if (sel) sel.disabled = !en.checked; }
if (en) { en.addEventListener('change', sync); sync(); }
})();
</script>
</div>
</fieldset>
</div>
{% if llm_configured %}
<div class="tab-pane-inner" id="ai-llm">
{% include "edit/include_llm_intent.html" %}
+10 -5
View File
@@ -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
@@ -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/<string:config_id>", 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/<string:config_id>", 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/<string:config_id>", 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
+17 -2
View File
@@ -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),
@@ -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 [],
}
@@ -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 %}
<fieldset>
<div class="pure-control-group">
{{ render_field(form.label) }}
<span class="pure-form-message-inline">{{ _('A friendly name, e.g. “Mobile” or “German desktop”.') }}</span>
</div>
<div class="pure-control-group">
{{ render_field(form.base_fetcher) }}
</div>
</fieldset>
{% if caps.supports_screenshots %}
<fieldset>
<legend>{{ _('Screen size') }}</legend>
<div class="pure-control-group">
<div class="viewport-presets">
<button type="button" class="pure-button button-small" data-w="1920" data-h="1080">{{ _('Desktop') }}</button>
<button type="button" class="pure-button button-small" data-w="1366" data-h="768">{{ _('Laptop') }}</button>
<button type="button" class="pure-button button-small" data-w="768" data-h="1024">{{ _('Tablet') }}</button>
<button type="button" class="pure-button button-small" data-w="390" data-h="844">{{ _('Mobile') }}</button>
<button type="button" class="pure-button button-small" data-w="" data-h="">{{ _('Clear') }}</button>
</div>
</div>
<div class="pure-control-group">{{ render_field(form.viewport_width) }}</div>
<div class="pure-control-group">{{ render_field(form.viewport_height) }}</div>
</fieldset>
<fieldset>
<legend>{{ _('Language & timezone') }}</legend>
<div class="pure-control-group">
{{ render_field(form.locale) }}
<span class="pure-form-message-inline">{{ _('e.g. <code>de-DE</code>, <code>en-GB</code>.')|safe }}</span>
</div>
<div class="pure-control-group">
{{ render_field(form.timezone_id) }}
<span class="pure-form-message-inline">{{ _('e.g. <code>Europe/Berlin</code>.')|safe }}</span>
</div>
</fieldset>
{% endif %}
{% if caps.supports_request_blocking %}
<fieldset>
<legend>{{ _('Skip downloads (save bandwidth)') }}</legend>
<div class="pure-control-group">
<label>{{ form.block_resource_types.label.text }}</label>
<div class="browser-inline-checkboxes">
{% for opt in form.block_resource_types %}
<span class="checkbox">{{ opt() }} <label for="{{ opt.id }}">{{ opt.label.text }}</label></span>
{% endfor %}
</div>
</div>
<div class="pure-control-group">
{{ render_field(form.block_url_patterns) }}
<span class="pure-form-message-inline">{{ _('One address pattern per line, e.g. <code>*.ttf</code>.')|safe }}</span>
</div>
</fieldset>
{% endif %}
{% if caps.supports_screenshots %}
<fieldset>
<div class="pure-control-group">{{ render_field(form.screenshot_format) }}</div>
</fieldset>
{% endif %}
<script>
(function () {
var w = document.getElementById('{{ form.viewport_width.id }}');
var h = document.getElementById('{{ form.viewport_height.id }}');
document.querySelectorAll('.viewport-presets button').forEach(function (btn) {
btn.addEventListener('click', function () {
if (w) w.value = this.getAttribute('data-w');
if (h) h.value = this.getAttribute('data-h');
});
});
})();
</script>
<style>
.viewport-presets { display:flex; flex-wrap:wrap; gap:0.4em; }
.browser-inline-checkboxes { display:flex; flex-wrap:wrap; gap:0.4em 1.2em; }
.browser-inline-checkboxes .checkbox { white-space:nowrap; }
</style>
@@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% block content %}
<div class="edit-form">
<div class="box-wrap inner">
<form class="pure-form pure-form-stacked" method="POST"
action="{{ url_for('ui.browser_config.browser_config_edit', config_id=config_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h4>{{ _('Edit browser') }}</h4>
{% include "_browser_config_fields.html" %}
<div class="pure-control-group">
{{ form.save(class="pure-button pure-button-primary") }}
<a class="pure-button button-cancel" href="{{ url_for('ui.browser_config.browsers_overview') }}">{{ _('Cancel') }}</a>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,139 @@
{% extends 'base.html' %}
{% block content %}
<div class="edit-form">
<div class="box-wrap inner">
<h4 style="margin-top:0">{{ _('Browsers') }}</h4>
<p class="pure-form-message-inline">
{{ _('Save a browser once (its screen size, language, timezone…) and reuse it on any watch or group.') }}
</p>
<div style="display:flex; align-items:center; justify-content:space-between; gap:1em; flex-wrap:wrap; margin-top:1.5em;">
<h5 style="margin:0">{{ _('Your browsers') }}</h5>
</div>
<table class="pure-table" style="width:100%; margin-top:0.75em;">
<thead>
<tr>
<th>{{ _('Name') }}</th>
<th>{{ _('Setup') }}</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{# Built-in engine browsers - always present, zero-override, not editable/removable #}
{% for f in base_fetchers %}
{% set fbc = f.browser_config or {} %}
<tr>
<td>
<strong>{{ f.description }}</strong>
<span class="browser-chip">{{ _('Built-in') }}</span>
{% if f.name == default_browser_id %}<span class="browser-chip browser-chip--default">{{ _('Default') }}</span>{% endif %}
</td>
<td>
{% if fbc.get('viewport_width') %}<span class="browser-chip">{{ fbc.viewport_width }}×{{ fbc.viewport_height or '?' }}</span>{% endif %}
{% if fbc.get('locale') %}<span class="browser-chip">{{ fbc.locale }}</span>{% endif %}
{% if fbc.get('timezone_id') %}<span class="browser-chip">{{ fbc.timezone_id }}</span>{% endif %}
{% if fbc.get('block_resource_types') %}<span class="browser-chip">{{ _('no') }} {{ fbc.block_resource_types|join(', ') }}</span>{% endif %}
{% for cap, on in f.capabilities.items() %}
{% if on %}<span class="browser-chip browser-chip--cap">{{ cap.replace('supports_', '')|replace('_', ' ') }}</span>{% endif %}
{% endfor %}
</td>
<td style="text-align:right; white-space:nowrap">
{% if f.name != default_browser_id %}
<form method="POST" action="{{ url_for('ui.browser_config.browser_config_set_default', config_id=f.name) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="pure-button button-small" type="submit">{{ _('Make default') }}</button>
</form>
{% endif %}
</td>
<td style="text-align:right; white-space:nowrap">
<a class="pure-button button-small" href="{{ url_for('ui.browser_config.browser_config_edit', config_id=f.name) }}">{{ _('Edit') }}</a>
</td>
</tr>
{% endfor %}
{# User-defined browsers #}
{% for cid, entry in browser_configs.items() %}
{% set bc = entry.get('browser_config') or {} %}
<tr>
<td>
<strong>{{ entry.get('label') }}</strong>
{% if cid == default_browser_id %}<span class="browser-chip browser-chip--default">{{ _('Default') }}</span>{% endif %}
</td>
<td>
{% if bc.get('viewport_width') %}<span class="browser-chip">{{ bc.viewport_width }}×{{ bc.viewport_height or '?' }}</span>{% endif %}
{% if bc.get('locale') %}<span class="browser-chip">{{ bc.locale }}</span>{% endif %}
{% if bc.get('timezone_id') %}<span class="browser-chip">{{ bc.timezone_id }}</span>{% endif %}
{% if bc.get('block_resource_types') %}<span class="browser-chip">{{ _('no') }} {{ bc.block_resource_types|join(', ') }}</span>{% endif %}
</td>
<td style="text-align:right; white-space:nowrap">
{% if cid != default_browser_id %}
<form method="POST" action="{{ url_for('ui.browser_config.browser_config_set_default', config_id=cid) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="pure-button button-small" type="submit">{{ _('Make default') }}</button>
</form>
{% endif %}
</td>
<td style="text-align:right; white-space:nowrap">
<a class="pure-button button-small" href="{{ url_for('ui.browser_config.browser_config_edit', config_id=cid) }}">{{ _('Edit') }}</a>
<form method="POST" action="{{ url_for('ui.browser_config.browser_config_remove', config_id=cid) }}" style="display:inline"
onsubmit="return confirm('{{ _('Remove this browser?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="pure-button button-small button-error" type="submit">{{ _('Remove') }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if browser_engine_available %}
<details class="browser-add" {% if open_add_form %}open{% endif %} style="margin-top:1em;">
<summary class="pure-button pure-button-primary" style="display:inline-block;">+ {{ _('Browser') }}</summary>
<div class="box-wrap inner" style="margin-top:1em;">
<form class="pure-form pure-form-stacked" method="POST" action="{{ url_for('ui.browser_config.browser_config_add') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% with form = add_form %}
{% include "_browser_config_fields.html" %}
{% endwith %}
<div class="pure-control-group">
{{ add_form.save(class="pure-button pure-button-primary") }}
</div>
</form>
</div>
</details>
{% else %}
<div class="browser-empty">
<p>{{ _("No interactive browser is connected, so there's nothing to configure yet.") }}</p>
<p class="pure-form-message-inline">{{ _('Connect a Chrome/Playwright browser (set the <code>PLAYWRIGHT_DRIVER_URL</code> environment variable) to add custom browsers here.')|safe }}</p>
</div>
{% endif %}
</div>
</div>
<style>
.browser-chip {
display: inline-block;
padding: 0.1em 0.6em;
margin: 0.1em;
border-radius: 1em;
font-size: 0.8rem;
background: rgba(127,127,127,0.15);
white-space: nowrap;
}
.browser-chip--default { background: #d4edda; color: #155724; }
.browser-empty {
margin-top: 0.75em;
padding: 2em;
text-align: center;
border: 1px dashed rgba(127,127,127,0.35);
border-radius: 0.5em;
}
.browser-empty p { color: #777; margin-top: 0; }
.browser-add > summary { list-style: none; cursor: pointer; }
.browser-add > summary::-webkit-details-marker { display: none; }
</style>
{% endblock %}
@@ -141,6 +141,21 @@
{% if capabilities.supports_request_type %}
<div class="tab-pane-inner" id="request">
{% if browser_config_group_override %}
<div class="pure-control-group">
<div class="fetch-backend-group-override">
{{ _('Using browser config') }}
<strong>{{ browser_config_group_override.label }}</strong>
{{ _('from group') }}
<a href="{{ url_for('tags.form_tag_edit', uuid=browser_config_group_override.group_uuid) }}">{{ browser_config_group_override.group_title }}</a>.
</div>
<span class="pure-form-message-inline">
{{ _('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.') }}
</span>
{# Keep the value submitted so nothing is lost, but hidden - the group config wins at resolve time. #}
<div style="display:none">{{ form.fetch_backend(class="fetch-backend") }}</div>
</div>
{% else %}
<div class="pure-control-group inline-radio">
{{ render_field(form.fetch_backend, class="fetch-backend") }}
<span class="pure-form-message-inline">
@@ -149,6 +164,7 @@
{{ _('Tip:') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">{{ _('Connect using Bright Data and Oxylabs Proxies, find out more here.') }}</a>
</span>
</div>
{% endif %}
{% if form.proxy %}
<div class="pure-control-group inline-radio">
<div>{{ form.proxy.label }} <a href="" id="check-all-proxies" class="pure-button button-secondary button-xsmall" >{{ _('Check/Scan all') }}</a></div>
@@ -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()),
@@ -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 <template> until the modal is opened. #}
<template id="bulk-browser-options">
<label class="bulk-choice-row"><input type="radio" name="bulk-fetch-backend" value="system" checked> {{ _('System settings default') }}</label>
{% for key, label in available_fetchers %}
<label class="bulk-choice-row"><input type="radio" name="bulk-fetch-backend" value="{{ key }}"> {{ label }}</label>
{% for key, label in browser_choices %}
<label class="bulk-choice-row"><input type="radio" name="bulk-fetch-backend" value="{{ key }}" {% if loop.first %}checked{% endif %}> {{ label }}</label>
{% endfor %}
</template>
{% if has_proxies %}
+35 -9
View File
@@ -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_<key>) -> 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
@@ -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
@@ -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):
@@ -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):
+4 -1
View File
@@ -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'."
+8 -3
View File
@@ -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)
+294
View File
@@ -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:
{ "<stable-id>": {"label": str, "base_fetcher": str, "is_default": bool,
"browser_config": { <FetcherConfig fields> }}, ... }
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
+4 -6
View File
@@ -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
+4 -1
View File
@@ -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', []))
+6
View File
@@ -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)
+3 -6
View File
@@ -68,14 +68,11 @@
</a>
</li>
<li class="action-sidebar-li">
<a href="#"
class="action-sidebar-item is-disabled"
title="{{ _('Browsers (coming soon)') }}"
aria-disabled="true"
tabindex="-1">
<a href="{{ url_for('ui.browser_config.browsers_overview') }}"
class="action-sidebar-item {% if ep.startswith('ui.browser_config') %}active{% endif %}"
title="{{ _('Browsers') }}">
<i data-feather="globe" class="action-icon"></i>
<span class="action-label">{{ _('Browsers') }}</span>
<span class="action-badge">{{ _('soon') }}</span>
</a>
</li>
{% if not current_diff_url %}
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Tests for named browser configs (browsers.json) and the watch-level Browser picker."""
from flask import url_for
from .util import live_server_setup, wait_for_all_checks
def _add_browser(client, label="Mobile de-DE", base_fetcher="html_webdriver", **cfg):
data = {'label': label, 'base_fetcher': base_fetcher, 'screenshot_format': 'JPEG'}
data.update(cfg)
return client.post(url_for("ui.browser_config.browser_config_add"), data=data, follow_redirects=True)
def test_browser_config_crud(client, live_server, measure_memory_usage, datastore_path):
datastore = client.application.config.get('DATASTORE')
# Empty state: no configs, but the overview renders
res = client.get(url_for("ui.browser_config.browsers_overview"))
assert res.status_code == 200
# Add one
res = _add_browser(client, label="Desktop Full-HD", viewport_width=1920, viewport_height=1080,
locale="en-GB", timezone_id="Europe/London")
assert b"Desktop Full-HD" in res.data
configs = datastore.browser_config_store.all()
assert len(configs) == 1
cid = list(configs)[0]
assert configs[cid]['browser_config']['locale'] == 'en-GB'
assert configs[cid]['browser_config']['viewport_width'] == 1920
# Invalid locale is rejected with an inline error, nothing stored
res = _add_browser(client, label="Bad", locale="xx-ZZ")
assert b"Unknown locale" in res.data
assert len(datastore.browser_config_store.all()) == 1
# A name is required
res = _add_browser(client, label="")
assert len(datastore.browser_config_store.all()) == 1
# Set default -> becomes the global system fetch_backend (single source of truth)
client.post(url_for("ui.browser_config.browser_config_set_default", config_id=cid), follow_redirects=True)
assert datastore.data['settings']['application']['fetch_backend'] == cid
# Edit
res = client.post(url_for("ui.browser_config.browser_config_edit", config_id=cid),
data={'label': 'Desktop 4K', 'base_fetcher': 'html_webdriver',
'viewport_width': 3840, 'viewport_height': 2160, 'screenshot_format': 'JPEG'},
follow_redirects=True)
assert b"Desktop 4K" in res.data
assert datastore.browser_config_store.get(cid)['browser_config']['viewport_width'] == 3840
# Remove
client.post(url_for("ui.browser_config.browser_config_remove", config_id=cid), follow_redirects=True)
assert len(datastore.browser_config_store.all()) == 0
def test_browser_config_unique_label(client, live_server, measure_memory_usage, datastore_path):
datastore = client.application.config.get('DATASTORE')
_add_browser(client, label="Mobile")
assert len(datastore.browser_config_store.all()) == 1
# Same label (case-insensitive) is rejected, nothing added
res = _add_browser(client, label=" mobile ")
assert b"already exists" in res.data
assert len(datastore.browser_config_store.all()) == 1
def test_watch_browser_picker_and_resolution(client, live_server, measure_memory_usage, datastore_path):
from changedetectionio.content_fetchers import resolve_content_fetcher
datastore = client.application.config.get('DATASTORE')
_add_browser(client, label="Mobile de-DE", viewport_width=390, viewport_height=844,
locale="de-DE", timezone_id="Europe/Berlin")
cid = list(datastore.browser_config_store.all())[0]
uuid = datastore.add_watch(url="https://example.com")
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
# Picker shows Default, the always-present built-in engines, and our user browser
assert b"Mobile de-DE" in res.data
assert b"Default (system settings)" in res.data
assert b'value="html_webdriver"' in res.data # built-in engine browser always present
# There must be exactly one "system" option (no duplicate legacy 'System settings default')
assert b"System settings default" not in res.data
assert res.data.count(b'value="system"') == 1
# No group override, so the normal picker is shown (not the override message)
assert b"fetch-backend-group-override" not in res.data
# Selecting the browser stores its id in fetch_backend
client.post(url_for("ui.ui_edit.edit_page", uuid=uuid),
data={'url': 'https://example.com', 'fetch_backend': cid, 'processor': 'text_json_diff',
'time_between_check_use_default': 'y', 'headers': '', 'method': 'GET', 'tags': ''},
follow_redirects=True)
assert datastore.data['watching'][uuid]['fetch_backend'] == cid
# Resolver maps the browser id -> engine + FetcherConfig
_cls, backend_name, _url, browser_config = resolve_content_fetcher(datastore.data['watching'][uuid], datastore)
assert backend_name == 'html_webdriver'
assert browser_config.locale == 'de-DE'
assert browser_config.viewport_width == 390
def test_edit_builtin_browser_config(client, live_server, measure_memory_usage, datastore_path):
from changedetectionio.content_fetchers import resolve_content_fetcher
datastore = client.application.config.get('DATASTORE')
# The built-in html_webdriver is editable; its config is stored keyed by the engine name
res = client.get(url_for("ui.browser_config.browser_config_edit", config_id="html_webdriver"))
assert res.status_code == 200
client.post(url_for("ui.browser_config.browser_config_edit", config_id="html_webdriver"),
data={'label': 'anything', 'base_fetcher': 'html_webdriver', 'viewport_width': 1280,
'viewport_height': 720, 'locale': 'it-IT', 'screenshot_format': 'JPEG'},
follow_redirects=True)
entry = datastore.browser_config_store.get('html_webdriver')
assert entry is not None
assert entry['base_fetcher'] == 'html_webdriver'
assert entry['browser_config']['locale'] == 'it-IT'
# A watch using the html_webdriver engine picks up that stored built-in config
uuid = datastore.add_watch(url="https://example.com")
datastore.data['watching'][uuid]['fetch_backend'] = 'html_webdriver'
_cls, name, _url, browser_config = resolve_content_fetcher(datastore.data['watching'][uuid], datastore)
assert name == 'html_webdriver'
assert browser_config.locale == 'it-IT'
assert browser_config.viewport_width == 1280
def test_html_requests_edit_hides_browser_only_fields(client, live_server, measure_memory_usage, datastore_path):
"""html_requests has no screenshot capability, so the edit form must not render the
viewport / language / timezone / screenshot fields (capability-gated server-side)."""
res = client.get(url_for("ui.browser_config.browser_config_edit", config_id="html_requests"))
assert res.status_code == 200
assert b'name="viewport_width"' not in res.data
assert b'name="timezone_id"' not in res.data
assert b'name="screenshot_format"' not in res.data
# A browser-capable engine (html_webdriver) DOES render them
res = client.get(url_for("ui.browser_config.browser_config_edit", config_id="html_webdriver"))
assert b'name="viewport_width"' in res.data
assert b'name="timezone_id"' in res.data
def test_missing_browser_config_raises(client, live_server, measure_memory_usage, datastore_path):
import pytest
from changedetectionio.content_fetchers import resolve_content_fetcher
from changedetectionio.model.browser_config import BrowserConfigDoesntExist
datastore = client.application.config.get('DATASTORE')
# Watch points at a browser config id that doesn't exist
uuid = datastore.add_watch(url="https://example.com")
datastore.data['watching'][uuid]['fetch_backend'] = 'deleted-config-id-1234'
with pytest.raises(BrowserConfigDoesntExist):
resolve_content_fetcher(datastore.data['watching'][uuid], datastore)
# Legacy raw engine names and 'system' must NOT raise
for legacy in ('system', 'html_requests', 'html_webdriver'):
datastore.data['watching'][uuid]['fetch_backend'] = legacy
resolve_content_fetcher(datastore.data['watching'][uuid], datastore)
def test_global_default_browser_resolves_for_system_watches(client, live_server, measure_memory_usage, datastore_path):
from changedetectionio.content_fetchers import resolve_content_fetcher
from changedetectionio.pluggy_interface import get_fetcher_capabilities
datastore = client.application.config.get('DATASTORE')
_add_browser(client, label="System Mobile", viewport_width=360, locale="es-ES")
cid = list(datastore.browser_config_store.all())[0]
# Make it the global default (the single source of truth 'system' resolves to)
client.post(url_for("ui.browser_config.browser_config_set_default", config_id=cid), follow_redirects=True)
assert datastore.data['settings']['application']['fetch_backend'] == cid
# A watch left on 'system' resolves through the global default -> engine + its config
uuid = datastore.add_watch(url="https://example.com")
datastore.data['watching'][uuid]['fetch_backend'] = 'system'
_cls, backend_name, _url, browser_config = resolve_content_fetcher(datastore.data['watching'][uuid], datastore)
assert backend_name == 'html_webdriver'
assert browser_config.locale == 'es-ES'
assert browser_config.viewport_width == 360
# Capability lookups resolve through it too (edit-page gating)
caps = get_fetcher_capabilities(datastore.data['watching'][uuid], datastore)
assert caps['supports_screenshots'] is True
def test_bulk_set_browser_operation(client, live_server, measure_memory_usage, datastore_path):
"""The watchlist bulk 'Set browser' modal lists the same browsers and its apply op accepts
a saved browser-config id (not just raw engine names)."""
datastore = client.application.config.get('DATASTORE')
_add_browser(client, label="Bulk Mobile", viewport_width=390)
cid = list(datastore.browser_config_store.all())[0]
# The modal option list (rendered on the overview) includes Default, a built-in and our browser
res = client.get(url_for("watchlist.index"))
assert b"Default (system settings)" in res.data
assert b'value="html_webdriver"' in res.data
assert b"Bulk Mobile" in res.data
uuid = datastore.add_watch(url="https://example.com")
res = client.post(url_for("ui.form_watch_list_checkbox_operations"),
data={'op': 'set-fetch-backend', 'op_extradata': cid, 'uuids': uuid},
follow_redirects=True)
assert datastore.data['watching'][uuid]['fetch_backend'] == cid
def test_group_browser_config_override(client, live_server, measure_memory_usage, datastore_path):
"""End-to-end through the tag edit form: the group's browser-config dropdown must list the
available browsers (built-ins + user), saving the override must persist, and a member watch
must then show + resolve the override."""
from changedetectionio.content_fetchers import resolve_content_fetcher
from changedetectionio.model.browser_config import resolve_browser_config_override
datastore = client.application.config.get('DATASTORE')
_add_browser(client, label="Group Mobile", viewport_width=375, locale="fr-FR")
cid = list(datastore.browser_config_store.all())[0]
tag_uuid = datastore.add_tag("French mobile")
# 1) The group edit page lists the browsers to choose from (built-in + our user browser).
# This is what was broken - the dropdown only had "None".
res = client.get(url_for("tags.form_tag_edit", uuid=tag_uuid))
assert res.status_code == 200
assert b"Group Mobile" in res.data # user browser listed
assert b'value="html_webdriver"' in res.data # built-in listed
# 2) Save the override via the form (enabler on + pick our browser)
client.post(url_for("tags.form_tag_edit_submit", uuid=tag_uuid),
data={'title': 'French mobile', 'browser_config_overrides_watch': 'y',
'browser_config': cid},
follow_redirects=True)
tag = datastore.data['settings']['application']['tags'][tag_uuid]
assert tag.get('browser_config_overrides_watch') is True
assert tag.get('browser_config') == cid
# 3) A member watch: resolver reports the override, edit page shows it, fetch resolves it
uuid = datastore.add_watch(url="https://example.com", tag_uuids=[tag_uuid])
override = resolve_browser_config_override(datastore.data['watching'][uuid], datastore)
assert override and override['config_id'] == cid and override['label'] == 'Group Mobile'
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
# The override message + link to the group are shown...
assert b"fetch-backend-group-override" in res.data
assert b"Using browser config" in res.data
assert b"Group Mobile" in res.data
assert url_for("tags.form_tag_edit", uuid=tag_uuid).encode() in res.data
# ...and the normal Browser picker is NOT offered (its help text is gone -> radio replaced)
assert b"method (default) where" not in res.data
_cls, backend_name, _url, browser_config = resolve_content_fetcher(datastore.data['watching'][uuid], datastore)
assert browser_config.locale == 'fr-FR'
assert browser_config.viewport_width == 375
def test_group_override_with_builtin_browser(client, live_server, measure_memory_usage, datastore_path):
"""A group can also override with a built-in engine (e.g. html_webdriver), not just a user browser."""
from changedetectionio.model.browser_config import resolve_browser_config_override
datastore = client.application.config.get('DATASTORE')
tag_uuid = datastore.add_tag("Force Chrome")
client.post(url_for("tags.form_tag_edit_submit", uuid=tag_uuid),
data={'title': 'Force Chrome', 'browser_config_overrides_watch': 'y',
'browser_config': 'html_webdriver'},
follow_redirects=True)
uuid = datastore.add_watch(url="https://example.com", tag_uuids=[tag_uuid])
override = resolve_browser_config_override(datastore.data['watching'][uuid], datastore)
assert override is not None and override['config_id'] == 'html_webdriver'
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.8\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-07-13 11:51+0200\n"
"POT-Creation-Date: 2026-07-16 15:08+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"