mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-01 23:28:06 +00:00
Compare commits
6 Commits
0.50.26
...
restock-cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41952157eb | ||
|
|
274c575f85 | ||
|
|
5c39668b40 | ||
|
|
254ebc90df | ||
|
|
912ab903c9 | ||
|
|
d758afe87e |
@@ -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 & 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">
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
@@ -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'):
|
||||
|
||||
42
changedetectionio/static/js/restock_diff.js
Normal file
42
changedetectionio/static/js/restock_diff.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
@@ -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);
|
||||
|
||||
@@ -105,6 +105,8 @@ extruct
|
||||
|
||||
# For cleaning up unknown currency formats
|
||||
babel
|
||||
# For normalizing currency symbols to ISO 4217 codes
|
||||
iso4217parse
|
||||
|
||||
levenshtein
|
||||
|
||||
|
||||
Reference in New Issue
Block a user