{{ extra_form_content|safe }}
{% endif %}
- {% if watch['processor'] == 'text_json_diff' %}
+ {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
diff --git a/changedetectionio/model/__init__.py b/changedetectionio/model/__init__.py
index 222dafc7014..46500f1ebf4 100644
--- a/changedetectionio/model/__init__.py
+++ b/changedetectionio/model/__init__.py
@@ -55,6 +55,7 @@ def __init__(self, *arg, **kw):
'previous_md5_before_filters': False, # Used for skipping changedetection entirely
'processor': 'text_json_diff', # could be restock_diff or others from .processors
'price_change_threshold_percent': None,
+ 'price_change_custom_include_filters': None, # Like 'include_filter' but for price changes only
'proxy': None, # Preferred proxy connection
'remote_server_reply': None, # From 'server' reply header
'sort_text_alphabetically': False,
diff --git a/changedetectionio/processors/restock_diff/__init__.py b/changedetectionio/processors/restock_diff/__init__.py
index 3d472beece0..70d8cf08598 100644
--- a/changedetectionio/processors/restock_diff/__init__.py
+++ b/changedetectionio/processors/restock_diff/__init__.py
@@ -6,8 +6,69 @@
class Restock(dict):
- def parse_currency(self, raw_value: str) -> Union[float, None]:
- # Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer.
+ def _normalize_currency_code(self, currency: str, normalize_dollar=False) -> str:
+ """
+ Normalize currency symbol or code to ISO 4217 code for consistency.
+ Uses iso4217parse for accurate conversion.
+
+ Returns empty string for ambiguous symbols like '$' where we can't determine
+ the specific currency (USD, CAD, AUD, etc.).
+ """
+ if not currency:
+ return currency
+
+ # If already a 3-letter code, likely already normalized
+ if len(currency) == 3 and currency.isupper():
+ return currency
+
+ # Handle ambiguous dollar sign - can't determine which dollar currency
+ if normalize_dollar and currency == '$':
+ return ''
+
+ try:
+ import iso4217parse
+
+ # Parse the currency - returns list of possible matches
+ # This handles: € -> EUR, Kč -> CZK, £ -> GBP, ¥ -> JPY, etc.
+ currencies = iso4217parse.parse(currency)
+
+ if currencies:
+ # Return first match (iso4217parse handles the mapping)
+ return currencies[0].alpha3
+ except Exception:
+ pass
+
+ # Fallback: return as-is if can't normalize
+ return currency
+
+ def parse_currency(self, raw_value: str, normalize_dollar=False) -> Union[dict, None]:
+ """
+ Parse price and currency from text, handling messy formats with extra text.
+ Returns dict with 'price' and 'currency' keys (ISO 4217 code), or None if parsing fails.
+
+ normalize_dollar convert $ to '' on sites that we cant tell what currency the site is in
+ """
+ try:
+ from price_parser import Price
+ # price-parser handles:
+ # - Extra text before/after ("Beginning at", "tax incl.")
+ # - Various number formats (1 099,00 or 1,099.00)
+ # - Currency symbols and codes
+ price_obj = Price.fromstring(raw_value)
+
+ if price_obj.amount is not None:
+ result = {'price': float(price_obj.amount)}
+ if price_obj.currency:
+ # Normalize currency symbol to ISO 4217 code for consistency with metadata
+ normalized_currency = self._normalize_currency_code(currency=price_obj.currency, normalize_dollar=normalize_dollar)
+ result['currency'] = normalized_currency
+ return result
+
+ except Exception as e:
+ from loguru import logger
+ logger.trace(f"price-parser failed on '{raw_value}': {e}, falling back to manual parsing")
+
+ # Fallback to existing manual parsing logic
standardized_value = raw_value
if ',' in standardized_value and '.' in standardized_value:
@@ -24,7 +85,7 @@ def parse_currency(self, raw_value: str) -> Union[float, None]:
if standardized_value:
# Convert to float
- return float(parse_decimal(standardized_value, locale='en'))
+ return {'price': float(parse_decimal(standardized_value, locale='en'))}
return None
@@ -51,7 +112,15 @@ def __setitem__(self, key, value):
# Custom logic to handle setting price and original_price
if key == 'price' or key == 'original_price':
if isinstance(value, str):
- value = self.parse_currency(raw_value=value)
+ parsed = self.parse_currency(raw_value=value)
+ if parsed:
+ # Set the price value
+ value = parsed.get('price')
+ # Also set currency if found and not already set
+ if parsed.get('currency') and not self.get('currency'):
+ super().__setitem__('currency', parsed.get('currency'))
+ else:
+ value = None
super().__setitem__(key, value)
diff --git a/changedetectionio/processors/restock_diff/forms.py b/changedetectionio/processors/restock_diff/forms.py
index 39334aa3c71..fe6851e38e7 100644
--- a/changedetectionio/processors/restock_diff/forms.py
+++ b/changedetectionio/processors/restock_diff/forms.py
@@ -1,13 +1,13 @@
from wtforms import (
BooleanField,
validators,
- FloatField
+ FloatField, StringField
)
from wtforms.fields.choices import RadioField
from wtforms.fields.form import FormField
from wtforms.form import Form
-from changedetectionio.forms import processor_text_json_diff_form
+from changedetectionio.forms import processor_text_json_diff_form, ValidateCSSJSONXPATHInput, StringListField
class RestockSettingsForm(Form):
@@ -27,6 +27,8 @@ class RestockSettingsForm(Form):
validators.NumberRange(min=0, max=100, message="Should be between 0 and 100"),
], render_kw={"placeholder": "0%", "size": "5"})
+ price_change_custom_include_filters = StringField('Override automatic price detection with this selector', [ValidateCSSJSONXPATHInput()], default='', render_kw={"style": "width: 100%;"})
+
follow_price_changes = BooleanField('Follow price changes', default=True)
class processor_settings_form(processor_text_json_diff_form):
@@ -74,7 +76,11 @@ def extra_form_content(self):
{{ render_field(form.restock_settings.price_change_threshold_percent) }}
Price must change more than this % to trigger a change since the first check. For example, If the product is $1,000 USD originally, 2% would mean it has to change more than $20 since the first check.
-
+
+
"""
diff --git a/changedetectionio/processors/restock_diff/processor.py b/changedetectionio/processors/restock_diff/processor.py
index 1fa81058caa..42a0036acbb 100644
--- a/changedetectionio/processors/restock_diff/processor.py
+++ b/changedetectionio/processors/restock_diff/processor.py
@@ -6,6 +6,8 @@
import urllib3
import time
+from ..text_json_diff.processor import FilterNotFoundInResponse
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
name = 'Re-stock & Price detection for pages with a SINGLE product'
description = 'Detects if the product goes back to in-stock'
@@ -125,6 +127,85 @@ def get_itemprop_availability(html_content) -> Restock:
return value
+def get_price_data_availability_from_filters(html_content, price_change_custom_include_filters) -> Restock:
+ """
+ Extract price using custom CSS/XPath selectors.
+ Reuses apply_include_filters logic from text_json_diff processor.
+
+ Args:
+ html_content: The HTML content to parse
+ price_change_custom_include_filters: List of CSS/XPath selectors to extract price
+
+ Returns:
+ Restock dict with 'price' key if found
+ """
+ from changedetectionio import html_tools
+ from changedetectionio.processors.magic import guess_stream_type
+
+ value = Restock()
+
+ if not price_change_custom_include_filters:
+ return value
+
+ # Get content type
+ stream_content_type = guess_stream_type(http_content_header='text/html', content=html_content)
+
+ # Apply filters to extract price element
+ filtered_content = ""
+
+ for filter_rule in price_change_custom_include_filters:
+ # XPath filters
+ if filter_rule[0] == '/' or filter_rule.startswith('xpath:'):
+ filtered_content += html_tools.xpath_filter(
+ xpath_filter=filter_rule.replace('xpath:', ''),
+ html_content=html_content,
+ append_pretty_line_formatting=False,
+ is_rss=stream_content_type.is_rss
+ )
+
+ # XPath1 filters (first match only)
+ elif filter_rule.startswith('xpath1:'):
+ filtered_content += html_tools.xpath1_filter(
+ xpath_filter=filter_rule.replace('xpath1:', ''),
+ html_content=html_content,
+ append_pretty_line_formatting=False,
+ is_rss=stream_content_type.is_rss
+ )
+
+ # CSS selectors, default fallback
+ else:
+ filtered_content += html_tools.include_filters(
+ include_filters=filter_rule,
+ html_content=html_content,
+ append_pretty_line_formatting=False
+ )
+
+ if filtered_content.strip():
+ # Convert HTML to text
+ import re
+ price_text = re.sub(
+ r'[\r\n\t]+', ' ',
+ html_tools.html_to_text(
+ html_content=filtered_content,
+ render_anchor_tag_content=False,
+ is_rss=False
+ ).strip()
+ )
+
+ # Parse the price from text
+ try:
+ parsed_result = value.parse_currency(price_text, normalize_dollar=True)
+ if parsed_result:
+ value['price'] = parsed_result.get('price')
+ if parsed_result.get('currency'):
+ value['currency'] = parsed_result.get('currency')
+ logger.debug(f"Extracted price from custom selector: {parsed_result.get('price')} {parsed_result.get('currency', '')} (from text: '{price_text}')")
+ except Exception as e:
+ logger.warning(f"Failed to parse price from '{price_text}': {e}")
+
+ return value
+
+
def is_between(number, lower=None, upper=None):
"""
Check if a number is between two values.
@@ -185,18 +266,32 @@ def run_changedetection(self, watch):
logger.info(f"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override")
break
-
+ # if not has custom selector..
itemprop_availability = {}
- try:
- itemprop_availability = get_itemprop_availability(self.fetcher.content)
- except MoreThanOnePriceFound as e:
- # Add the real data
- raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
- url=watch.get('url'),
- status_code=self.fetcher.get_last_status_code(),
- screenshot=self.fetcher.screenshot,
- xpath_data=self.fetcher.xpath_data
- )
+ if restock_settings.get('price_change_custom_include_filters'):
+ itemprop_availability = get_price_data_availability_from_filters(html_content=self.fetcher.content,
+ price_change_custom_include_filters=restock_settings.get(
+ 'price_change_custom_include_filters')
+ )
+ if not itemprop_availability or not itemprop_availability.get('price'):
+ raise FilterNotFoundInResponse(
+ msg=restock_settings.get('price_change_custom_include_filters'),
+ screenshot=self.fetcher.screenshot,
+ xpath_data=self.fetcher.xpath_data
+ )
+
+ else:
+ try:
+ itemprop_availability = get_itemprop_availability(self.fetcher.content)
+ except MoreThanOnePriceFound as e:
+ # Add the real data
+ raise ProcessorException(
+ message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
+ url=watch.get('url'),
+ status_code=self.fetcher.get_last_status_code(),
+ screenshot=self.fetcher.screenshot,
+ xpath_data=self.fetcher.xpath_data
+ )
# Something valid in get_itemprop_availability() by scraping metadata ?
if itemprop_availability.get('price') or itemprop_availability.get('availability'):
diff --git a/changedetectionio/static/js/restock_diff.js b/changedetectionio/static/js/restock_diff.js
new file mode 100644
index 00000000000..25d92d82a01
--- /dev/null
+++ b/changedetectionio/static/js/restock_diff.js
@@ -0,0 +1,42 @@
+$(document).ready(function () {
+ // Initialize Visual Selector plugin
+ let visualSelectorAPI = null;
+ if ($('#selector-wrapper').length > 0) {
+ visualSelectorAPI = $('#selector-wrapper').visualSelector({
+ screenshotUrl: screenshot_url,
+ visualSelectorDataUrl: watch_visual_selector_data_url,
+ singleSelectorOnly: true,
+ $includeFiltersElem: $('#restock_settings-price_change_custom_include_filters')
+ });
+ }
+
+ // Function to check and bootstrap visual selector based on hash
+ function checkAndBootstrapVisualSelector() {
+ if (visualSelectorAPI) {
+ if (window.location.hash && window.location.hash.includes('visualselector')) {
+ $('img#selector-background').off('load');
+ visualSelectorAPI.bootstrap();
+ } else {
+ // Shutdown when navigating away from visualselector
+ visualSelectorAPI.shutdown();
+ }
+ }
+ }
+
+ // Bootstrap the visual selector when the tab is clicked
+ $('#visualselector-tab').click(function () {
+ if (visualSelectorAPI) {
+ $('img#selector-background').off('load');
+ visualSelectorAPI.bootstrap();
+ }
+ });
+
+ // Check on page load if hash contains 'visualselector'
+ checkAndBootstrapVisualSelector();
+
+ // Listen for hash changes (when anchor changes)
+ $(window).on('hashchange', function() {
+ checkAndBootstrapVisualSelector();
+ });
+});
+
diff --git a/changedetectionio/static/js/watch-settings.js b/changedetectionio/static/js/text_json_diff.js
similarity index 69%
rename from changedetectionio/static/js/watch-settings.js
rename to changedetectionio/static/js/text_json_diff.js
index 4d805340139..5e4e91ac6ed 100644
--- a/changedetectionio/static/js/watch-settings.js
+++ b/changedetectionio/static/js/text_json_diff.js
@@ -46,6 +46,44 @@ function request_textpreview_update() {
$(document).ready(function () {
+ // Initialize Visual Selector plugin
+ let visualSelectorAPI = null;
+ if ($('#selector-wrapper').length > 0) {
+ visualSelectorAPI = $('#selector-wrapper').visualSelector({
+ screenshotUrl: screenshot_url,
+ visualSelectorDataUrl: watch_visual_selector_data_url
+ });
+
+ // Function to check and bootstrap visual selector based on hash
+ function checkAndBootstrapVisualSelector() {
+ if (visualSelectorAPI) {
+ if (window.location.hash && window.location.hash.includes('visualselector')) {
+ $('img#selector-background').off('load');
+ visualSelectorAPI.bootstrap();
+ } else {
+ // Shutdown when navigating away from visualselector
+ visualSelectorAPI.shutdown();
+ }
+ }
+ }
+
+ // Bootstrap the visual selector when the tab is clicked
+ $('#visualselector-tab').click(function() {
+ if (visualSelectorAPI) {
+ $('img#selector-background').off('load');
+ visualSelectorAPI.bootstrap();
+ }
+ });
+
+ // Check on page load if hash contains 'visualselector'
+ checkAndBootstrapVisualSelector();
+
+ // Listen for hash changes (when anchor changes)
+ $(window).on('hashchange', function() {
+ checkAndBootstrapVisualSelector();
+ });
+ }
+
$('#notification-setting-reset-to-default').click(function (e) {
$('#notification_title').val('');
$('#notification_body').val('');
diff --git a/changedetectionio/static/js/visual-selector.js b/changedetectionio/static/js/visual-selector.js
index f6f8e79c2f3..ffd5a34a9ba 100644
--- a/changedetectionio/static/js/visual-selector.js
+++ b/changedetectionio/static/js/visual-selector.js
@@ -1,260 +1,357 @@
// Copyright (C) 2021 Leigh Morresi (dgtlmoon@gmail.com)
// All rights reserved.
-// yes - this is really a hack, if you are a front-ender and want to help, please get in touch!
-
-let runInClearMode = false;
-
-$(document).ready(() => {
- let currentSelections = [];
- let currentSelection = null;
- let appendToList = false;
- let c, xctx, ctx;
- let xScale = 1, yScale = 1;
- let selectorImage, selectorImageRect, selectorData;
-
-
- // Global jQuery selectors with "Elem" appended
- const $selectorCanvasElem = $('#selector-canvas');
- const $includeFiltersElem = $("#include_filters");
- const $selectorBackgroundElem = $("img#selector-background");
- const $selectorCurrentXpathElem = $("#selector-current-xpath span");
- const $fetchingUpdateNoticeElem = $('.fetching-update-notice');
- const $selectorWrapperElem = $("#selector-wrapper");
-
- // Color constants
- const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)';
- const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)';
- const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)';
- const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)';
- const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)';
-
- $('#visualselector-tab').click(() => {
- $selectorBackgroundElem.off('load');
- currentSelections = [];
- bootstrapVisualSelector();
- });
-
- function clearReset() {
- ctx.clearRect(0, 0, c.width, c.height);
-
- if ($includeFiltersElem.val().length) {
- alert("Existing filters under the 'Filters & Triggers' tab were cleared.");
+// jQuery plugin for Visual Selector
+
+(function($) {
+ 'use strict';
+
+ // Shared across all plugin instances
+ let runInClearMode = false;
+
+ $.fn.visualSelector = function(options) {
+ // Default settings
+ const defaults = {
+ $selectorCanvasElem: $('#selector-canvas'),
+ $includeFiltersElem: $('#include_filters'),
+ $selectorBackgroundElem: $('img#selector-background'),
+ $selectorCurrentXpathElem: $('#selector-current-xpath span'),
+ $selectorCurrentXpathParentElem: $('#selector-current-xpath'),
+ $fetchingUpdateNoticeElem: $('.fetching-update-notice'),
+ $selectorWrapperElem: $('#selector-wrapper'),
+ $visualSelectorHeadingElem: $('#visual-selector-heading'),
+ $clearSelectorElem: $('#clear-selector'),
+ screenshotUrl: window.screenshot_url || '',
+ visualSelectorDataUrl: window.watch_visual_selector_data_url || '',
+ currentSelections: [],
+ singleSelectorOnly: false // When true, only allows selecting one element (disables Shift+Click multi-select)
+ };
+
+ // Merge options with defaults
+ const settings = $.extend({}, defaults, options);
+
+ // Extract settings for easier access
+ const $selectorCanvasElem = settings.$selectorCanvasElem;
+ const $includeFiltersElem = settings.$includeFiltersElem;
+ const $selectorBackgroundElem = settings.$selectorBackgroundElem;
+ const $selectorCurrentXpathElem = settings.$selectorCurrentXpathElem;
+ const $selectorCurrentXpathParentElem = settings.$selectorCurrentXpathParentElem;
+ const $fetchingUpdateNoticeElem = settings.$fetchingUpdateNoticeElem;
+ const $selectorWrapperElem = settings.$selectorWrapperElem;
+ const $visualSelectorHeadingElem = settings.$visualSelectorHeadingElem;
+ const $clearSelectorElem = settings.$clearSelectorElem;
+
+ // Validate required elements exist (supports both textarea and input[type="text"])
+ if (!$includeFiltersElem.length) {
+ console.error('Visual Selector Error: $includeFiltersElem not found. The visual selector requires a valid textarea or input[type="text"] element to write selections to.');
+ console.error('Attempted selector:', settings.$includeFiltersElem.selector || settings.$includeFiltersElem);
+ return null;
}
- $includeFiltersElem.val('');
- currentSelections = [];
+ // Verify the element is a textarea or input
+ const elementType = $includeFiltersElem.prop('tagName').toLowerCase();
+ if (elementType !== 'textarea' && elementType !== 'input') {
+ console.error('Visual Selector Error: $includeFiltersElem must be a textarea or input element, found:', elementType);
+ return null;
+ }
- // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)
- runInClearMode = true;
+ // Plugin instance state
+ let currentSelections = settings.currentSelections || [];
+ let currentSelection = null;
+ let appendToList = false;
+ let c, xctx, ctx;
+ let xScale = 1, yScale = 1;
+ let selectorImage, selectorImageRect, selectorData;
+
+ // Color constants
+ const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)';
+ const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)';
+ const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)';
+ const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)';
+ const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)';
+
+ function clearReset() {
+ ctx.clearRect(0, 0, c.width, c.height);
+
+ if ($includeFiltersElem.val().length) {
+ alert("Existing filters under the 'Filters & Triggers' tab were cleared.");
+ }
+ $includeFiltersElem.val('');
- highlightCurrentSelected();
- }
+ currentSelections = [];
- function splitToList(v) {
- return v.split('\n').map(line => line.trim()).filter(line => line.length > 0);
- }
+ // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)
+ runInClearMode = true;
- function sortScrapedElementsBySize() {
- // Sort the currentSelections array by area (width * height) in descending order
- selectorData['size_pos'].sort((a, b) => {
- const areaA = a.width * a.height;
- const areaB = b.width * b.height;
- return areaB - areaA;
- });
- }
+ highlightCurrentSelected();
+ }
- $(document).on('keydown keyup', (event) => {
- if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
- appendToList = event.type === 'keydown';
+ function splitToList(v) {
+ return v.split('\n').map(line => line.trim()).filter(line => line.length > 0);
}
- if (event.type === 'keydown') {
- if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") {
- clearReset();
- }
+ function sortScrapedElementsBySize() {
+ // Sort the currentSelections array by area (width * height) in descending order
+ selectorData['size_pos'].sort((a, b) => {
+ const areaA = a.width * a.height;
+ const areaB = b.width * b.height;
+ return areaB - areaA;
+ });
}
- });
-
- $('#clear-selector').on('click', () => {
- clearReset();
- });
- // So if they start switching between visualSelector and manual filters, stop it from rendering old filters
- $('li.tab a').on('click', () => {
- runInClearMode = true;
- });
-
- if (!window.location.hash || window.location.hash !== '#visualselector') {
- $selectorBackgroundElem.attr('src', '');
- return;
- }
-
- bootstrapVisualSelector();
-
- function bootstrapVisualSelector() {
- $selectorBackgroundElem
- .on("error", () => {
- $fetchingUpdateNoticeElem.html("Ooops! The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.")
- .css('color', '#bb0000');
- $('#selector-current-xpath, #clear-selector').hide();
- })
- .on('load', () => {
- console.log("Loaded background...");
- c = document.getElementById("selector-canvas");
- xctx = c.getContext("2d");
- ctx = c.getContext("2d");
- fetchData();
- $selectorCanvasElem.off("mousemove mousedown");
- })
- .attr("src", screenshot_url);
-
- let s = `${$selectorBackgroundElem.attr('src')}?${new Date().getTime()}`;
- $selectorBackgroundElem.attr('src', s);
- }
-
- function alertIfFilterNotFound() {
- let existingFilters = splitToList($includeFiltersElem.val());
- let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath);
-
- for (let filter of existingFilters) {
- if (!sizePosXpaths.includes(filter)) {
- alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`);
- break;
+
+ function alertIfFilterNotFound() {
+ let existingFilters = splitToList($includeFiltersElem.val());
+ let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath);
+
+ for (let filter of existingFilters) {
+ if (!sizePosXpaths.includes(filter)) {
+ alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`);
+ break;
+ }
}
}
- }
- function fetchData() {
- $fetchingUpdateNoticeElem.html("Fetching element data..");
+ function fetchData() {
+ $fetchingUpdateNoticeElem.html("Fetching element data..");
- $.ajax({
- url: watch_visual_selector_data_url,
- context: document.body
- }).done((data) => {
- $fetchingUpdateNoticeElem.html("Rendering..");
- selectorData = data;
+ $.ajax({
+ url: settings.visualSelectorDataUrl,
+ context: document.body
+ }).done((data) => {
+ $fetchingUpdateNoticeElem.html("Rendering..");
+ selectorData = data;
- sortScrapedElementsBySize();
- console.log(`Reported browser width from backend: ${data['browser_width']}`);
+ sortScrapedElementsBySize();
+ console.log(`Reported browser width from backend: ${data['browser_width']}`);
- // Little sanity check for the user, alert them if something missing
- alertIfFilterNotFound();
+ // Little sanity check for the user, alert them if something missing
+ alertIfFilterNotFound();
- setScale();
- reflowSelector();
- $fetchingUpdateNoticeElem.fadeOut();
- });
- }
-
- function updateFiltersText() {
- // Assuming currentSelections is already defined and contains the selections
- let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath)));
-
- if (currentSelections.length > 0) {
- // Convert the Set back to an array and join with newline characters
- let textboxFilterText = Array.from(uniqueSelections).join("\n");
- $includeFiltersElem.val(textboxFilterText);
+ setScale();
+ reflowSelector();
+ $fetchingUpdateNoticeElem.fadeOut();
+ });
}
- }
-
- function setScale() {
- $selectorWrapperElem.show();
- selectorImage = $selectorBackgroundElem[0];
- selectorImageRect = selectorImage.getBoundingClientRect();
-
- $selectorCanvasElem.attr({
- 'height': selectorImageRect.height,
- 'width': selectorImageRect.width
- });
- $selectorWrapperElem.attr('width', selectorImageRect.width);
- $('#visual-selector-heading').css('max-width', selectorImageRect.width + "px")
-
- xScale = selectorImageRect.width / selectorImage.naturalWidth;
- yScale = selectorImageRect.height / selectorImage.naturalHeight;
-
- ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT;
- ctx.fillStyle = FILL_STYLE_REDLINE;
- ctx.lineWidth = 3;
- console.log("Scaling set x: " + xScale + " by y:" + yScale);
- $("#selector-current-xpath").css('max-width', selectorImageRect.width);
- }
-
- function reflowSelector() {
- $(window).resize(() => {
+
+ function updateFiltersText() {
+ // Assuming currentSelections is already defined and contains the selections
+ let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath)));
+
+ if (currentSelections.length > 0) {
+ // Convert the Set back to an array and join with newline characters
+ let textboxFilterText = Array.from(uniqueSelections).join("\n");
+ $includeFiltersElem.val(textboxFilterText);
+ }
+ }
+
+ function setScale() {
+ $selectorWrapperElem.show();
+ selectorImage = $selectorBackgroundElem[0];
+ selectorImageRect = selectorImage.getBoundingClientRect();
+
+ $selectorCanvasElem.attr({
+ 'height': selectorImageRect.height,
+ 'width': selectorImageRect.width
+ });
+ $selectorWrapperElem.attr('width', selectorImageRect.width);
+ $visualSelectorHeadingElem.css('max-width', selectorImageRect.width + "px")
+
+ xScale = selectorImageRect.width / selectorImage.naturalWidth;
+ yScale = selectorImageRect.height / selectorImage.naturalHeight;
+
+ ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT;
+ ctx.fillStyle = FILL_STYLE_REDLINE;
+ ctx.lineWidth = 3;
+ console.log("Scaling set x: " + xScale + " by y:" + yScale);
+ $selectorCurrentXpathParentElem.css('max-width', selectorImageRect.width);
+ }
+
+ function reflowSelector() {
+ $(window).resize(() => {
+ setScale();
+ highlightCurrentSelected();
+ });
+
setScale();
- highlightCurrentSelected();
- });
- setScale();
+ console.log(selectorData['size_pos'].length + " selectors found");
- console.log(selectorData['size_pos'].length + " selectors found");
+ let existingFilters = splitToList($includeFiltersElem.val());
- let existingFilters = splitToList($includeFiltersElem.val());
+ // In singleSelectorOnly mode, only load the first existing filter
+ if (settings.singleSelectorOnly && existingFilters.length > 1) {
+ existingFilters = [existingFilters[0]];
+ }
- selectorData['size_pos'].forEach(sel => {
- if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
- console.log("highlighting " + c);
- currentSelections.push(sel);
+ for (let sel of selectorData['size_pos']) {
+ if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
+ console.log("highlighting " + sel.xpath);
+ currentSelections.push(sel);
+ // In singleSelectorOnly mode, stop after finding the first match
+ if (settings.singleSelectorOnly) {
+ break;
+ }
+ }
}
- });
- highlightCurrentSelected();
- updateFiltersText();
+ highlightCurrentSelected();
+ updateFiltersText();
+
+ $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5));
+ $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5));
+ $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5));
- $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5));
- $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5));
- $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5));
+ function handleMouseMove(e) {
+ if (!e.offsetX && !e.offsetY) {
+ const targetOffset = $(e.target).offset();
+ e.offsetX = e.pageX - targetOffset.left;
+ e.offsetY = e.pageY - targetOffset.top;
+ }
- function handleMouseMove(e) {
- if (!e.offsetX && !e.offsetY) {
- const targetOffset = $(e.target).offset();
- e.offsetX = e.pageX - targetOffset.left;
- e.offsetY = e.pageY - targetOffset.top;
+ ctx.fillStyle = FILL_STYLE_HIGHLIGHT;
+
+ selectorData['size_pos'].forEach(sel => {
+ if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale &&
+ e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) {
+ setCurrentSelectedText(sel.xpath);
+ drawHighlight(sel);
+ currentSelections.push(sel);
+ currentSelection = sel;
+ highlightCurrentSelected();
+ currentSelections.pop();
+ }
+ })
}
- ctx.fillStyle = FILL_STYLE_HIGHLIGHT;
- selectorData['size_pos'].forEach(sel => {
- if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale &&
- e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) {
- setCurrentSelectedText(sel.xpath);
- drawHighlight(sel);
- currentSelections.push(sel);
- currentSelection = sel;
- highlightCurrentSelected();
- currentSelections.pop();
+ function setCurrentSelectedText(s) {
+ $selectorCurrentXpathElem[0].innerHTML = s;
+ }
+
+ function drawHighlight(sel) {
+ ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
+ ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
+ }
+
+ function handleMouseDown() {
+ // In singleSelectorOnly mode, always use single selection (ignore appendToList/Shift)
+ if (settings.singleSelectorOnly) {
+ currentSelections = [currentSelection];
+ } else {
+ // If we are in 'appendToList' mode, grow the list, if not, just 1
+ currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];
}
- })
+ highlightCurrentSelected();
+ updateFiltersText();
+ }
+
}
+ function highlightCurrentSelected() {
+ xctx.fillStyle = FILL_STYLE_GREYED_OUT;
+ xctx.strokeStyle = STROKE_STYLE_REDLINE;
+ xctx.lineWidth = 3;
+ xctx.clearRect(0, 0, c.width, c.height);
- function setCurrentSelectedText(s) {
- $selectorCurrentXpathElem[0].innerHTML = s;
+ currentSelections.forEach(sel => {
+ //xctx.clearRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
+ xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
+ });
}
- function drawHighlight(sel) {
- ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
- ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
+ function bootstrapVisualSelector() {
+ $selectorBackgroundElem
+ .on("error", (d) => {
+ console.error(d)
+ $fetchingUpdateNoticeElem.html("Ooops! The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.")
+ .css('color', '#bb0000');
+ $selectorCurrentXpathParentElem.hide();
+ $clearSelectorElem.hide();
+ })
+ .on('load', () => {
+ console.log("Loaded background...");
+ c = document.getElementById("selector-canvas");
+ xctx = c.getContext("2d");
+ ctx = c.getContext("2d");
+ fetchData();
+ $selectorCanvasElem.off("mousemove mousedown");
+ });
+
+ // Set the src with cache-busting timestamp
+ let s = `${settings.screenshotUrl}?${new Date().getTime()}`;
+ console.log(s);
+ $selectorBackgroundElem.attr('src', s);
}
- function handleMouseDown() {
- // If we are in 'appendToList' mode, grow the list, if not, just 1
- currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];
- highlightCurrentSelected();
- updateFiltersText();
- }
+ // Set up global event handlers (these run once on initialization)
+ function initializeEventHandlers() {
+ $(document).on('keydown.visualSelector keyup.visualSelector', (event) => {
+ // Only enable shift+click multi-select if singleSelectorOnly is false
+ if (!settings.singleSelectorOnly && (event.code === 'ShiftLeft' || event.code === 'ShiftRight')) {
+ appendToList = event.type === 'keydown';
+ }
+
+ if (event.type === 'keydown') {
+ if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") {
+ clearReset();
+ }
+ }
+ });
- }
+ $clearSelectorElem.on('click.visualSelector', () => {
+ clearReset();
+ });
- function highlightCurrentSelected() {
- xctx.fillStyle = FILL_STYLE_GREYED_OUT;
- xctx.strokeStyle = STROKE_STYLE_REDLINE;
- xctx.lineWidth = 3;
- xctx.clearRect(0, 0, c.width, c.height);
+ // So if they start switching between visualSelector and manual filters, stop it from rendering old filters
+ $('li.tab a').on('click.visualSelector', () => {
+ runInClearMode = true;
+ });
+ }
+
+ // Initialize event handlers
+ initializeEventHandlers();
+
+ // Return public API
+ return {
+ bootstrap: function() {
+ currentSelections = [];
+ runInClearMode = false;
+ bootstrapVisualSelector();
+ },
+ shutdown: function() {
+ // Clear the background image and canvas when navigating away
+ $selectorBackgroundElem.attr('src', '');
+ if (c && ctx) {
+ ctx.clearRect(0, 0, c.width, c.height);
+ }
+ if (c && xctx) {
+ xctx.clearRect(0, 0, c.width, c.height);
+ }
+ // Unbind mouse events on canvas
+ $selectorCanvasElem.off('mousemove mousedown mouseleave');
+ // Unbind background image events
+ $selectorBackgroundElem.off('load error');
+ },
+ clear: function() {
+ clearReset();
+ },
+ destroy: function() {
+ // Clean up event handlers
+ $(document).off('.visualSelector');
+ $clearSelectorElem.off('.visualSelector');
+ $('li.tab a').off('.visualSelector');
+ $selectorCanvasElem.off('mousemove mousedown mouseleave');
+ $(window).off('resize');
+ },
+ getCurrentSelections: function() {
+ return currentSelections;
+ },
+ setCurrentSelections: function(selections) {
+ currentSelections = selections;
+ highlightCurrentSelected();
+ updateFiltersText();
+ }
+ };
+ };
- currentSelections.forEach(sel => {
- //xctx.clearRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
- xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
- });
- }
-});
\ No newline at end of file
+})(jQuery);
diff --git a/requirements.txt b/requirements.txt
index d8eb4a02b2a..b6cfc780a6e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -105,6 +105,8 @@ extruct
# For cleaning up unknown currency formats
babel
+# For normalizing currency symbols to ISO 4217 codes
+iso4217parse
levenshtein