diff --git a/changedetectionio/blueprint/add_watch_ui/__init__.py b/changedetectionio/blueprint/add_watch_ui/__init__.py index 21f9847c..23bdd347 100644 --- a/changedetectionio/blueprint/add_watch_ui/__init__.py +++ b/changedetectionio/blueprint/add_watch_ui/__init__.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template +from flask import Blueprint, render_template, request, jsonify, make_response +from loguru import logger from changedetectionio import forms from changedetectionio.auth_decorator import login_optionally_required @@ -6,7 +7,7 @@ from changedetectionio.store import ChangeDetectionStore def construct_blueprint(datastore: ChangeDetectionStore): - add_watch_ui_blueprint = Blueprint('add_watch_ui', __name__, template_folder="templates") + add_watch_ui_blueprint = Blueprint('add_watch_ui', __name__, template_folder="templates", static_folder="static") @add_watch_ui_blueprint.route("/", methods=['GET']) @login_optionally_required @@ -24,4 +25,64 @@ def construct_blueprint(datastore: ChangeDetectionStore): llm_intent_watch_placeholder=LLM_INTENT_WATCH_PLACEHOLDER, ) + @add_watch_ui_blueprint.route("/snapshot", methods=['GET']) + @login_optionally_required + def add_watch_ui_snapshot(): + """One-shot live fetch of an arbitrary URL for the Add Watch visual selector. + + Reuses the same browser machinery as Browser Steps (browsersteps_live_ui + + the dedicated async loop) but without needing a persisted watch - we just + connect, "Goto site", grab the screenshot + xpath element data, then tear + the browser down again. Element selection then happens client-side on the + returned data, exactly like the watch Edit page's visual selector. + """ + import base64 + from changedetectionio.blueprint.browser_steps import ( + run_async_in_browser_loop, + _close_session_resources, + acquire_browser_for_fetcher, + ) + from changedetectionio.browser_steps.browser_steps import browsersteps_live_ui + + url = (request.args.get('url') or '').strip() + if not url or not url.lower().startswith(('http://', 'https://')): + return make_response('Please enter a valid http(s):// URL', 400) + + # Use whatever fetcher the application is configured to use by default + # (e.g. CloakBrowser, Playwright/sockpuppet) so the preview matches real checks. + fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests') + logger.debug(f"Add-watch snapshot: fetching '{url}' using system default fetcher '{fetcher_name}'") + + async def _fetch_snapshot(): + keepalive_ms = 30 * 1000 + browser, playwright_context = await acquire_browser_for_fetcher( + fetcher_name, proxy=None, keepalive_ms=keepalive_ms + ) + + stepper = browsersteps_live_ui(playwright_browser=browser, proxy=None, start_url=url) + session = {'browserstepper': stepper, 'browser': browser, 'playwright_context': playwright_context} + try: + await stepper.connect(proxy=None) + await stepper.call_action(action_name="Goto site", selector=None, optional_value=None) + return await stepper.get_current_state() + finally: + await _close_session_resources(session, label=' for add-watch snapshot') + + try: + (screenshot, xpath_data) = run_async_in_browser_loop(_fetch_snapshot()) + except Exception as e: + logger.error(f"Add-watch snapshot fetch failed for {url}: {e}") + if 'ECONNREFUSED' in str(e): + return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? ' + 'The live preview needs a fetcher that supports Javascript and screenshots.', 502) + return make_response(str(e).splitlines()[0] if str(e) else 'Could not fetch the page', 502) + + if not screenshot: + return make_response('Could not capture a screenshot for that URL', 502) + + return jsonify({ + "screenshot": f"data:image/jpeg;base64,{base64.b64encode(screenshot).decode('ascii')}", + "xpath_data": xpath_data, + }) + return add_watch_ui_blueprint diff --git a/changedetectionio/blueprint/add_watch_ui/static/add-watch.js b/changedetectionio/blueprint/add_watch_ui/static/add-watch.js new file mode 100644 index 00000000..8c7c35f4 --- /dev/null +++ b/changedetectionio/blueprint/add_watch_ui/static/add-watch.js @@ -0,0 +1,87 @@ +// Add Watch UI glue: fetch a live snapshot for the entered URL and drive the +// shared visual selector (window.initVisualSelector from visual-selector.js). + +$(document).ready(() => { + const $url = $('#url'); + const $go = $('#add-watch-go'); + const $emptyState = $('#add-watch-empty-state'); + const $spinner = $('#add-watch-spinner'); + const $error = $('#add-watch-error'); + const $wrapper = $('#selector-wrapper'); + const $xpathRow = $('#selector-current-xpath'); + const $byElement = $('#by-element-toggle'); + const $clear = $('#clear-selector'); + const $includeFilters = $('#include_filters'); + + const vs = window.initVisualSelector({ + $canvas: $('#selector-canvas'), + $includeFilters: $includeFilters, + $background: $('#selector-background'), + $xpathDisplay: $('#selector-current-xpath span'), + $fetchingNotice: $('#add-watch-spinner .fetching-update-notice'), + $wrapper: $wrapper, + $clearButton: $clear, + enableSelection: false, // off until the user opts into "Select by element" + processorIsImage: false, + // The snapshot comes from the live browser-steps capture, so scale X by the page + // CSS width (browser_width) like browser-steps.js - handles device-scale-factor != 1. + scaleByBrowserWidth: true, + }); + + function showState(which) { + // which: 'empty' | 'loading' | 'error' | 'ready' + $emptyState.toggle(which === 'empty'); + $spinner.toggle(which === 'loading'); + $error.toggle(which === 'error'); + const ready = which === 'ready'; + $wrapper.toggle(ready); + $xpathRow.toggle(ready && $byElement.is(':checked')); + $clear.toggle(ready && $byElement.is(':checked')); + } + + function fetchSnapshot() { + const url = ($url.val() || '').trim(); + if (!url) { + $url.focus(); + return; + } + + showState('loading'); + + $.ajax({ + url: add_watch_snapshot_url, + data: {url: url}, + dataType: 'json', + }).done((data) => { + showState('ready'); + vs.load({screenshotSrc: data.screenshot, xpathData: data.xpath_data}); + }).fail((xhr) => { + const msg = (xhr && xhr.responseText) ? xhr.responseText : 'Could not fetch a preview for that URL.'; + $error.text(msg); + showState('error'); + }); + } + + $go.on('click', fetchSnapshot); + + // Enter in the URL box should fetch a preview, not submit the whole form + $url.on('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + fetchSnapshot(); + } + }); + + // "Select by element" toggles live hover/click element selection + $byElement.on('change', function () { + const on = $(this).is(':checked'); + vs.setSelectionEnabled(on); + $xpathRow.toggle(on && $wrapper.is(':visible')); + $clear.toggle(on && $wrapper.is(':visible')); + if (!on) { + $includeFilters.val(''); + } + }); + + showState('empty'); +}); diff --git a/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html b/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html index 4aef12d2..eb9233ec 100644 --- a/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html +++ b/changedetectionio/blueprint/add_watch_ui/templates/add-watch-ui.html @@ -2,42 +2,94 @@ {%- block content -%} {%- from '_helpers.html' import render_simple_field, render_field, render_nolabel_field -%} -