Files
changedetection.io/changedetectionio/blueprint/ui/browser_config.py
T
dgtlmoonandClaude Opus 5 ba2280ba81 External CDP Browser engine - replaces the extra_browser_* hackery
An "extra browser" was a name + a ws(s):// endpoint in settings.requests.extra_browsers,
selected by a watch as the magic string 'extra_browser_<name>'. That string resolved to
html_webdriver plus a custom connection URL, which meant the protocol the endpoint was
spoken to came from env vars rather than from the entry: CDP over a WebSocket with
PLAYWRIGHT_DRIVER_URL set, CDP via pyppeteer with FAST_PUPPETEER_CHROME_FETCHER, and the
W3C WebDriver protocol over HTTP on a Selenium-only install - where a wss:// URL cannot
work at all. The form only ever accepted ws:// / wss://, so the feature was silently
broken on exactly the installs that could not honour it.

So it becomes an engine, html_external_cdp, which pins the protocol: a subclass of the
Playwright fetcher that takes its endpoint from the watch's browser config
(FetcherConfig.connection_url) instead of the environment. It is base-only
(ready_to_use=False) because an endpoint is required, so each endpoint is one browser
config ("variation") on the Browsers page - which is what the old settings list was.

update_36 migrates each extra_browsers row to such a variation, keyed by the SAME
'extra_browser_<name>' string watches already hold, so no watch, group override, API value
or global default needs rewriting; the legacy selector simply becomes a real browser-config
id. A row whose endpoint the model rejects is logged and skipped rather than taking the
update chain, and with it startup, down.

Knock-on cleanups, all of which delete a special case rather than add one:

 - The proxy opt-out for custom endpoints is now Fetcher.ignores_proxy_setting, asked of
   the engine, instead of a string-prefix test in call_browser().
 - A live browser-steps / visual-selector session asks the engine where to connect
   (Fetcher.browser_steps_connection_url, overridden by html_external_cdp) and refuses an
   engine whose supports_browser_steps is False, instead of reading the env var itself and
   silently stepping a browser the watch does not check with. That refusal is real: on a
   Selenium install html_webdriver cannot drive a live session.
 - is_valid_browser_selector() answers "may a watch store this in fetch_backend?" in one
   place; the API (create/update/import), the quick-add form validator and the bulk "set
   browser" operation each had their own copy, which is how they came to disagree about
   whether a browser-config id was acceptable.
 - api-spec.yaml's fetch_backend pattern enumerated extra_browser_* while rejecting
   browser-config ids and every engine newer than html_webdriver. Valid values are
   per-install, so the schema now bounds the string and the handlers do the real check.
 - html_external_cdp registers unconditionally (unlike html_playwright_builtin): migrated
   configs name it, so it must resolve even without the playwright library, or those
   watches would quietly fetch with the plain HTTP client. The library is imported lazily
   inside run(), and an unavailable engine now warns instead of falling back silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:39:16 +02:00

290 lines
15 KiB
Python

"""
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.*`.
"""
import os
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.strtobool import strtobool
from changedetectionio.flask_app import login_optionally_required
def _browser_config_locked():
"""LOCKED_BROWSER_CONFIG: viewing + choosing the default browser stay available, but
adding / editing / removing configs is disabled. Mirrors the browser_config_locked()
template global (same env + strtobool contract) - kept here to avoid importing view
helpers back into the blueprint."""
return bool(strtobool(os.getenv('LOCKED_BROWSER_CONFIG', 'False')))
def _base_fetchers(datastore):
"""Every available base engine shown as a row in "Available browsers".
Includes the plain HTTP client ('html_requests') and any other non-browser fetchers, not just
browser-capable engines: /browsers is the only place the global Default browser is set, and the
plaintext client is the most common default, so it MUST be selectable here. Per-row flags tell
the template which actions apply:
- is_browser (supports screenshots / visual-selector) -> can "Add variation" + "Edit"
- ready_to_use (usable directly, not base-only like html_playwright_builtin) -> can be default
A ready-to-use engine may have a stored built-in override config (keyed by the engine name).
"""
from changedetectionio import content_fetchers
from changedetectionio.content_fetchers.base import FetcherCapabilities
out = []
for name, description in content_fetchers.available_fetchers():
cls = getattr(content_fetchers, name, None)
caps = FetcherCapabilities.from_fetcher(cls)
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 {},
'ready_to_use': getattr(cls, 'ready_to_use', True),
'is_browser': caps.is_browser,
'configurable': caps.can_host_variation,
})
# Browsers first (richer rows), plain fetchers (e.g. html_requests) after, stable by name.
out.sort(key=lambda f: (not f['is_browser'], f['name']))
return out
def _autocomplete_choices():
"""(locales, timezones) for the datalist autocompletes - same sources the FetcherConfig
validators use (babel CLDR + stdlib zoneinfo)."""
from zoneinfo import available_timezones
timezones = sorted(available_timezones())
try:
from babel.localedata import locale_identifiers
locales = sorted({lid.replace('_', '-') for lid in locale_identifiers()})
except Exception:
locales = ['en-US', 'en-GB', 'de-DE', 'fr-FR', 'es-ES', 'it-IT', 'ja-JP', 'zh-CN', 'pt-BR', 'nl-NL']
return locales, timezones
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 _applicable_fields_for(capabilities):
"""The FetcherConfig fields an engine with these capabilities may carry - the one set that
both the form template and the save path use, so they cannot drift apart."""
from changedetectionio.model.browser_config import FetcherConfig
return FetcherConfig.applicable_fields(capabilities)
def _entry_to_formdata(entry):
"""Flatten a browsers.json entry into flat form field values (base is contextual, not a field)."""
data = {'label': entry.get('label')}
data.update(entry.get('browser_config') or {})
return data
def construct_blueprint(datastore: ChangeDetectionStore):
browser_config_blueprint = Blueprint('browser_config', __name__, template_folder="templates")
@browser_config_blueprint.route("/browsers", methods=['GET'])
@login_optionally_required
def browsers_overview():
base_fetchers = _base_fetchers(datastore)
# Nest each user-created variation under the engine it extends, so a variation reads as a
# child of its base browser (↳) instead of living in a separate "Your browsers" table.
# Variations are uuid-keyed with a `base_fetcher`; built-in engine override configs are
# keyed by the engine name itself (cid == base_fetcher) - those are the base row's own
# "Edit", not a variation, so they're skipped here.
variations_by_base = {}
for cid, entry in datastore.browser_config_store.all().items():
base = entry.get('base_fetcher')
if cid == base:
continue
variations_by_base.setdefault(base, []).append({'id': cid, **entry})
for f in base_fetchers:
f['variations'] = variations_by_base.get(f['name'], [])
return render_template(
"browsers-overview.html",
base_fetchers=base_fetchers,
# The default browser is the global system fetch_backend (single source of truth).
default_browser_id=datastore.data['settings']['application'].get('fetch_backend'),
)
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, capabilities):
"""Return a validated FetcherConfig, or None (with errors attached to form).
`capabilities` is the base engine's capability set and acts as the allowlist: only fields
that engine can honour are taken from the submitted form (FetcherConfig.from_submitted),
so nothing this browser ignores can be written into browsers.json.
"""
from changedetectionio.model.browser_config import FetcherConfig
try:
cfg = FetcherConfig.from_submitted(form.to_fetcher_config_dict(), capabilities)
# Fields this engine cannot work without (an external browser with no endpoint has
# nowhere to connect) - declared on the field via _needs(required=True).
for name in FetcherConfig.required_fields(capabilities):
if not getattr(cfg, name, None):
field = getattr(form, name, None)
message = gettext('This is required for this browser')
if field is not None:
field.errors.append(message)
else:
flash(message, 'error')
return None
return cfg
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/<string:base_fetcher>", methods=['GET', 'POST'])
@login_optionally_required
def browser_config_add(base_fetcher):
"""Add a browser config *variation* based on a specific engine (from the row's link).
The base is fixed by the URL, so the form's fields gate correctly for that engine."""
if _browser_config_locked():
flash(gettext("Browser configuration is locked on this instance"), 'error')
return redirect(url_for('ui.browser_config.browsers_overview'))
from .form_browseroptions import BrowserOptionsForm
from changedetectionio import content_fetchers
from changedetectionio.content_fetchers.base import FetcherCapabilities
cls = getattr(content_fetchers, base_fetcher, None)
caps = FetcherCapabilities.from_fetcher(cls)
# Must be an engine that has something to configure (a browser, or the plain client).
if cls is None or not caps.can_host_variation:
flash(gettext("Unknown base browser"), 'error')
return redirect(url_for('ui.browser_config.browsers_overview'))
base_label = dict(content_fetchers.available_fetchers()).get(base_fetcher, base_fetcher)
form = BrowserOptionsForm(request.form if request.method == 'POST' else None)
if request.method == 'POST' and 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, caps)
if cfg is not None:
datastore.browser_config_store.add(
label=form.label.data,
base_fetcher=base_fetcher,
browser_config=cfg.model_dump(exclude_defaults=True),
)
flash(gettext("Browser added"))
return redirect(url_for('ui.browser_config.browsers_overview'))
locale_choices, timezone_choices = _autocomplete_choices()
return render_template("browser-config-form.html", form=form, mode='add',
base_fetcher=base_fetcher, base_label=base_label,
applicable=_applicable_fields_for(caps),
locale_choices=locale_choices, timezone_choices=timezone_choices,
form_action=url_for('ui.browser_config.browser_config_add', base_fetcher=base_fetcher))
@browser_config_blueprint.route("/browsers/edit/<string:config_id>", methods=['GET', 'POST'])
@login_optionally_required
def browser_config_edit(config_id):
if _browser_config_locked():
flash(gettext("Browser configuration is locked on this instance"), 'error')
return redirect(url_for('ui.browser_config.browsers_overview'))
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 (never user-choosable on edit).
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'))
base = config_id if is_builtin else (entry or {}).get('base_fetcher')
base_label = str(builtins[config_id]['label']) if is_builtin else base
if request.method == 'POST':
form = BrowserOptionsForm(request.form)
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, _caps_for(base) if base else None)
if cfg is not None:
datastore.browser_config_store.upsert(
config_id,
label=(base_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:
form = BrowserOptionsForm(data=_entry_to_formdata(entry) if entry else {'label': base_label})
locale_choices, timezone_choices = _autocomplete_choices()
return render_template("browser-config-form.html", form=form, mode='edit',
config_id=config_id, is_builtin=is_builtin,
base_fetcher=base, base_label=base_label,
applicable=_applicable_fields_for(_caps_for(base) if base else None),
locale_choices=locale_choices, timezone_choices=timezone_choices,
form_action=url_for('ui.browser_config.browser_config_edit', config_id=config_id))
@browser_config_blueprint.route("/browsers/remove/<string:config_id>", methods=['POST'])
@login_optionally_required
def browser_config_remove(config_id):
if _browser_config_locked():
flash(gettext("Browser configuration is locked on this instance"), 'error')
return redirect(url_for('ui.browser_config.browsers_overview'))
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 browser" is the global settings.application.fetch_backend - the single source
# of truth a watch/group set to 'system' resolves to. This /browsers tab is now the only
# place it's set (the Settings page shows it read-only). Only usable (ready-to-use)
# built-in engines or saved browser configs may be the default.
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
logger.debug(f"Default browser (settings.application.fetch_backend) set to '{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