mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-08 04:51:42 +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>
111 lines
5.5 KiB
Python
111 lines
5.5 KiB
Python
"""
|
|
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")})
|
|
|
|
# --- 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", "list": "locale-datalist", "autocomplete": "off"})
|
|
timezone_id = StringField(_l('Timezone'), validators=[validators.Optional(), validators.Length(max=64)],
|
|
render_kw={"placeholder": "Europe/Berlin", "list": "timezone-datalist", "autocomplete": "off"})
|
|
|
|
screenshot_format = SelectField(_l('Screenshot format'), choices=SCREENSHOT_FORMATS, default='JPEG')
|
|
|
|
# Local-launch engines only (gated by supports_browser_type)
|
|
browser_type = SelectField(_l('Browser engine'), validators=[validators.Optional()],
|
|
choices=[('chromium', 'Chromium'), ('firefox', 'Firefox'), ('webkit', 'WebKit')],
|
|
default='chromium')
|
|
delete_created_files = BooleanField(_l('Delete temporary browser files after each check'), default=True)
|
|
|
|
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/*"})
|
|
|
|
# External browser endpoint (gated by supports_connection_url). No scheme/format validators
|
|
# here on purpose: FetcherConfig.connection_url enforces them, and the blueprint attaches the
|
|
# model's error to this field - so there is one rule, not a form copy that can drift.
|
|
connection_url = StringField(_l('Browser connection URL'), validators=[validators.Optional()],
|
|
render_kw={"placeholder": "wss://brd… wss://oxylabs… ws://my-browser:3000",
|
|
"size": 50})
|
|
|
|
# Plain HTTP client options (gated by supports_request_timeout / supports_custom_user_agent)
|
|
timeout = IntegerField(_l('Request timeout (seconds)'), validators=[
|
|
validators.Optional(), validators.NumberRange(min=1, max=999)],
|
|
render_kw={"placeholder": "30", "style": "width: 6em;"})
|
|
user_agent = StringField(_l('User-Agent'), validators=[validators.Optional(), validators.Length(max=500)],
|
|
render_kw={"placeholder": "Mozilla/5.0 (…)"})
|
|
|
|
save = SubmitField(_l('Save'))
|
|
|
|
def to_fetcher_config_dict(self):
|
|
"""Map the FetcherConfig-named fields to a plain dict for FetcherConfig(**...)."""
|
|
data = {
|
|
'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,
|
|
'browser_type': (self.browser_type.data or None),
|
|
'delete_created_files': bool(self.delete_created_files.data),
|
|
'block_resource_types': self.block_resource_types.data or [],
|
|
'block_url_patterns': self.block_url_patterns.data or [],
|
|
'connection_url': (self.connection_url.data or None),
|
|
'user_agent': (self.user_agent.data or None),
|
|
}
|
|
# Blank timeout means "use the model default" (DEFAULT_REQUEST_TIMEOUT_SECONDS), so omit the
|
|
# key entirely rather than passing None (which would override that default).
|
|
if self.timeout.data is not None:
|
|
data['timeout'] = self.timeout.data
|
|
return data
|