mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-05 19:41:56 +00:00
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>
290 lines
15 KiB
Python
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
|