mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-28 08:16:03 +00:00
UI - Another attempt at configurable browsers
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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', []))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user