Compare commits

...

6 Commits

Author SHA1 Message Date
dgtlmoon
41952157eb FilterNotFoundInResponse 2025-10-15 15:21:46 +02:00
dgtlmoon
274c575f85 Should be single string field 2025-10-15 13:31:32 +02:00
dgtlmoon
5c39668b40 hash change cleanups 2025-10-15 13:27:29 +02:00
dgtlmoon
254ebc90df normalize fix up for unknown $ 2025-10-15 12:52:39 +02:00
dgtlmoon
912ab903c9 Price currency and amount fixes 2025-10-15 12:33:39 +02:00
dgtlmoon
d758afe87e Implement visual selector override of price data Re #3505 2025-10-15 11:59:12 +02:00
9 changed files with 600 additions and 248 deletions

View File

@@ -29,7 +29,7 @@
const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}";
</script>
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename=watch['processor']+".js")}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script>
{% if playwright_enabled %}
@@ -50,8 +50,10 @@
{% endif %}
<li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li>
<!-- should goto extra forms? -->
{% if watch['processor'] == 'text_json_diff' %}
{% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
{% endif %}
{% if watch['processor'] == 'text_json_diff' %}
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
<li class="tab" id="conditions-tab"><a href="#conditions">Conditions</a></li>
{% endif %}
@@ -377,7 +379,7 @@ Math: {{ 1 + 1 }}") }}
{{ extra_form_content|safe }}
</div>
{% endif %}
{% if watch['processor'] == 'text_json_diff' %}
{% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
<div class="tab-pane-inner visual-selector-ui" id="visualselector">
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">

View File

@@ -55,6 +55,7 @@ class watch_base(dict):
'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,

View File

@@ -6,8 +6,69 @@ import re
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 @@ class Restock(dict):
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 @@ class Restock(dict):
# 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)

View File

@@ -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 @@ class processor_settings_form(processor_text_json_diff_form):
{{ render_field(form.restock_settings.price_change_threshold_percent) }}
<span class="pure-form-message-inline">Price must change more than this % to trigger a change since the first check.</span><br>
<span class="pure-form-message-inline">For example, If the product is $1,000 USD originally, <strong>2%</strong> would mean it has to change more than $20 since the first check.</span><br>
</fieldset>
</fieldset>
<fieldset class="pure-group price-change-minmax">
{{ render_field(form.restock_settings.price_change_custom_include_filters) }}
<span class="pure-form-message-inline">Override the automatic price metadata reader with this custom select from the <a href="#visualselector">Visual Selector</a>, in the case that the automatic detection was incorrect.</span><br>
</fieldset>
</div>
</fieldset>
"""

View File

@@ -6,6 +6,8 @@ from loguru import logger
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 @@ class perform_site_check(difference_detection_processor):
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'):

View File

@@ -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();
});
});

View File

@@ -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('');

View File

@@ -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!
// jQuery plugin for Visual Selector
let runInClearMode = false;
(function($) {
'use strict';
$(document).ready(() => {
let currentSelections = [];
let currentSelection = null;
let appendToList = false;
let c, xctx, ctx;
let xScale = 1, yScale = 1;
let selectorImage, selectorImageRect, selectorData;
// 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)
};
// 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");
// Merge options with defaults
const settings = $.extend({}, defaults, options);
// 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)';
// 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;
$('#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.");
}
$includeFiltersElem.val('');
currentSelections = [];
// Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)
runInClearMode = true;
highlightCurrentSelected();
}
function splitToList(v) {
return v.split('\n').map(line => line.trim()).filter(line => line.length > 0);
}
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;
});
}
$(document).on('keydown keyup', (event) => {
if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
appendToList = event.type === 'keydown';
// 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;
}
if (event.type === 'keydown') {
if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") {
clearReset();
// 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;
}
// 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('');
$('#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;
});
currentSelections = [];
if (!window.location.hash || window.location.hash !== '#visualselector') {
$selectorBackgroundElem.attr('src', '');
return;
}
// Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)
runInClearMode = true;
bootstrapVisualSelector();
function bootstrapVisualSelector() {
$selectorBackgroundElem
.on("error", () => {
$fetchingUpdateNoticeElem.html("<strong>Ooops!</strong> 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 fetchData() {
$fetchingUpdateNoticeElem.html("Fetching element data..");
$.ajax({
url: watch_visual_selector_data_url,
context: document.body
}).done((data) => {
$fetchingUpdateNoticeElem.html("Rendering..");
selectorData = data;
sortScrapedElementsBySize();
console.log(`Reported browser width from backend: ${data['browser_width']}`);
// 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);
}
}
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(() => {
setScale();
highlightCurrentSelected();
});
}
setScale();
function splitToList(v) {
return v.split('\n').map(line => line.trim()).filter(line => line.length > 0);
}
console.log(selectorData['size_pos'].length + " selectors found");
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;
});
}
let existingFilters = splitToList($includeFiltersElem.val());
function alertIfFilterNotFound() {
let existingFilters = splitToList($includeFiltersElem.val());
let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath);
selectorData['size_pos'].forEach(sel => {
if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
console.log("highlighting " + c);
currentSelections.push(sel);
}
});
highlightCurrentSelected();
updateFiltersText();
$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;
}
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();
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 setCurrentSelectedText(s) {
$selectorCurrentXpathElem[0].innerHTML = s;
$.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']}`);
// Little sanity check for the user, alert them if something missing
alertIfFilterNotFound();
setScale();
reflowSelector();
$fetchingUpdateNoticeElem.fadeOut();
});
}
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 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 handleMouseDown() {
// If we are in 'appendToList' mode, grow the list, if not, just 1
currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];
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();
console.log(selectorData['size_pos'].length + " selectors found");
let existingFilters = splitToList($includeFiltersElem.val());
// In singleSelectorOnly mode, only load the first existing filter
if (settings.singleSelectorOnly && existingFilters.length > 1) {
existingFilters = [existingFilters[0]];
}
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();
$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;
}
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 highlightCurrentSelected() {
xctx.fillStyle = FILL_STYLE_GREYED_OUT;
xctx.strokeStyle = STROKE_STYLE_REDLINE;
xctx.lineWidth = 3;
xctx.clearRect(0, 0, c.width, c.height);
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);
});
}
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 bootstrapVisualSelector() {
$selectorBackgroundElem
.on("error", (d) => {
console.error(d)
$fetchingUpdateNoticeElem.html("<strong>Ooops!</strong> 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);
}
// 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();
});
// 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();
}
};
};
})(jQuery);

View File

@@ -105,6 +105,8 @@ extruct
# For cleaning up unknown currency formats
babel
# For normalizing currency symbols to ISO 4217 codes
iso4217parse
levenshtein