From aac6fcfa594f17511b8ff73e5eaa4f6c33899de0 Mon Sep 17 00:00:00 2001 From: Jan Kahmen <36455663+kah-ja@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:11:33 +0200 Subject: [PATCH] fix: Visual Selector renders scraped selectors as text, not markup (#4282) --- .../res/xpath_element_scraper.js | 5 +- .../static/js/visual-selector.js | 3 +- .../tests/visualselector/test_fetch_data.py | 62 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/changedetectionio/content_fetchers/res/xpath_element_scraper.js b/changedetectionio/content_fetchers/res/xpath_element_scraper.js index 25bd5ad9..12753ca3 100644 --- a/changedetectionio/content_fetchers/res/xpath_element_scraper.js +++ b/changedetectionio/content_fetchers/res/xpath_element_scraper.js @@ -13,7 +13,10 @@ async (options) => { // Include the getXpath script directly, easier than fetching function getxpath(e) { var n = e; - if (n && n.id) return '//*[@id="' + n.id + '"]'; + // A double quote cannot be expressed inside a double-quoted xpath literal, and an angle + // bracket would carry page markup into whatever displays the selector later, so for those + // ids build the positional path below instead of an unusable '//*[@id="..."]'. + if (n && n.id && !/["<>]/.test(n.id)) return '//*[@id="' + n.id + '"]'; for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) { for (var i = 0, r = !1, d = n.previousSibling; d;) d.nodeType !== Node.DOCUMENT_TYPE_NODE && d.nodeName === n.nodeName && i++, d = d.previousSibling; for (d = n.nextSibling; d;) { diff --git a/changedetectionio/static/js/visual-selector.js b/changedetectionio/static/js/visual-selector.js index def12a5a..03c25d6a 100644 --- a/changedetectionio/static/js/visual-selector.js +++ b/changedetectionio/static/js/visual-selector.js @@ -253,7 +253,8 @@ window.initVisualSelector = function (opts) { } function setCurrentSelectedText(s) { - $selectorCurrentXpathElem[0].innerHTML = s; + // Selectors come from the scraped page, display them as text and never as markup + $selectorCurrentXpathElem[0].textContent = s; } function drawHighlight(sel) { diff --git a/changedetectionio/tests/visualselector/test_fetch_data.py b/changedetectionio/tests/visualselector/test_fetch_data.py index e217d5fd..e83c87ad 100644 --- a/changedetectionio/tests/visualselector/test_fetch_data.py +++ b/changedetectionio/tests/visualselector/test_fetch_data.py @@ -255,3 +255,65 @@ def test_browsersteps_edit_UI_startsession(client, live_server, measure_memory_u url_for("ui.form_delete", uuid="all"), follow_redirects=True ) + +def test_visual_selector_xpath_carries_no_page_markup(client, live_server, measure_memory_usage, datastore_path): + # The scraped element id ends up in the selector that the Visual Selector displays, so it must + # not be able to carry markup from the watched page into it. + import json + import zlib + + assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" + + # The same id on two elements, findUpTag() only returns a selector when the id is unique on the + # page, so this is what reaches the fallback xpath builder + duplicate_id = 'dupe">' + page_content = f""" +
first
+
second
+

unique id, stays on the id shortcut

+ """ + + test_url = url_for('test_endpoint', content=page_content, _external=True) + test_url = test_url.replace('localhost.localdomain', 'cdio') + test_url = test_url.replace('localhost', 'cdio') + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1), + data={ + "url": test_url, + "tags": "", + 'fetch_backend': "html_webdriver", + "time_between_check_use_default": "y", + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + + with open(os.path.join(datastore_path, uuid, 'elements.deflate'), 'rb') as f: + xpath_data = json.loads(zlib.decompress(f.read()).decode('utf-8')) + + xpaths = [str(sel.get('xpath')) for sel in xpath_data['size_pos']] + assert xpaths, "Elements were scraped" + + for xpath in xpaths: + assert '<' not in xpath, f"No markup from the page in the selector: {xpath}" + assert '>' not in xpath, f"No markup from the page in the selector: {xpath}" + + # The two divs are still scraped, just addressed by their position instead + assert any(x.startswith('/html/body/div') for x in xpaths), f"Duplicate-id elements still have a selector: {xpaths}" + # A well behaved id is untouched + assert '#unique' in xpaths, f"Unique id still uses the id selector: {xpaths}" + + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + )