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
22 changed files with 655 additions and 484 deletions

View File

@@ -28,7 +28,7 @@ jobs:
test-pypi-package:
name: Test the built package works basically.
name: Test the built 📦 package works basically.
runs-on: ubuntu-latest
needs:
- build
@@ -42,39 +42,18 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Test that the basic pip built package runs without error
run: |
set -ex
ls -alR
# Install the first wheel found in dist/
WHEEL=$(find dist -type f -name "*.whl" -print -quit)
echo Installing $WHEEL
python3 -m pip install --upgrade pip
python3 -m pip install "$WHEEL"
# Find and install the first .whl file
find dist -type f -name "*.whl" -exec pip3 install {} \; -quit
changedetection.io -d /tmp -p 10000 &
sleep 3
curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null
curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null
# --- API test ---
# This also means that the docs/api-spec.yml was shipped and could be read
test -f /tmp/url-watches.json
API_KEY=$(jq -r '.. | .api_access_token? // empty' /tmp/url-watches.json)
echo Test API KEY is $API_KEY
curl -X POST "http://127.0.0.1:10000/api/v1/watch" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
--show-error --fail \
--retry 6 --retry-delay 1 --retry-connrefused \
-d '{
"url": "https://example.com",
"title": "Example Site Monitor",
"time_between_check": { "hours": 1 }
}'
killall changedetection.io

View File

@@ -54,10 +54,7 @@ jobs:
- name: Spin up ancillary SMTP+Echo message test server
run: |
# Debug SMTP server/echo message back server, telnet 11080 to it should immediately bounce back the most recent message that tried to send (then you can see if cdio tried to send, the format, etc)
# 11025 is the SMTP port for testing
# apprise example would be 'mailto://changedetection@localhost:11025/?to=fff@home.com (it will also echo to STDOUT)
# telnet localhost 11080
# Debug SMTP server/echo message back server
docker run --network changedet-network -d -p 11025:11025 -p 11080:11080 --hostname mailserver test-changedetectionio bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py'
docker ps

View File

@@ -1,5 +1,4 @@
recursive-include changedetectionio/api *
include docs/api-spec.yaml
recursive-include changedetectionio/blueprint *
recursive-include changedetectionio/conditions *
recursive-include changedetectionio/content_fetchers *

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.50.27'
__version__ = '0.50.24'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError

View File

@@ -37,10 +37,6 @@ def get_openapi_spec():
from openapi_core import OpenAPI # Lazy import - saves ~10.7 MB on startup
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
if not os.path.exists(spec_path):
# Possibly for pip3 packages
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
with open(spec_path, 'r') as f:
spec_dict = yaml.safe_load(f)
_openapi_spec = OpenAPI.from_dict(spec_dict)

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

@@ -1,75 +1,11 @@
import time
import apprise
from apprise import NotifyFormat
from loguru import logger
from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
from ..notification_service import NotificationContextData
def markup_text_links_to_html(body):
"""
Convert plaintext to HTML with clickable links.
Uses Jinja2's escape and Markup for XSS safety.
"""
from linkify_it import LinkifyIt
from markupsafe import Markup, escape
linkify = LinkifyIt()
# Match URLs in the ORIGINAL text (before escaping)
matches = linkify.match(body)
if not matches:
# No URLs, just escape everything
return Markup(escape(body))
result = []
last_index = 0
# Process each URL match
for match in matches:
# Add escaped text before the URL
if match.index > last_index:
text_part = body[last_index:match.index]
result.append(escape(text_part))
# Add the link with escaped URL (both in href and display)
url = match.url
result.append(Markup(f'<a href="{escape(url)}">{escape(url)}</a>'))
last_index = match.last_index
# Add remaining escaped text
if last_index < len(body):
result.append(escape(body[last_index:]))
# Join all parts
return str(Markup(''.join(str(part) for part in result)))
def notification_format_align_with_apprise(n_format : str):
"""
Correctly align changedetection's formats with apprise's formats
Probably these are the same - but good to be sure.
:param n_format:
:return:
"""
if n_format.lower().startswith('html'):
# Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here
n_format = NotifyFormat.HTML
elif n_format.lower().startswith('markdown'):
# probably the same but just to be safe
n_format = NotifyFormat.MARKDOWN
elif n_format.lower().startswith('text'):
# probably the same but just to be safe
n_format = NotifyFormat.TEXT
else:
n_format = NotifyFormat.TEXT
# Must be str for apprise notify body_format
return str(n_format)
def process_notification(n_object: NotificationContextData, datastore):
from changedetectionio.jinja2_custom import render as jinja_render
from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats
@@ -94,9 +30,7 @@ def process_notification(n_object: NotificationContextData, datastore):
# If we arrived with 'System default' then look it up
if n_format == default_notification_format_for_watch and datastore.data['settings']['application'].get('notification_format') != default_notification_format_for_watch:
# Initially text or whatever
n_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format]).lower()
n_format = notification_format_align_with_apprise(n_format=n_format)
n_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format])
logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.2f}s")
@@ -119,11 +53,7 @@ def process_notification(n_object: NotificationContextData, datastore):
# Get the notification body from datastore
n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
if n_object.get('markup_text_to_html'):
n_body = markup_text_links_to_html(body=n_body)
if n_format == str(NotifyFormat.HTML):
if n_object.get('notification_format', '').startswith('HTML'):
n_body = n_body.replace("\n", '<br>')
n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)

View File

@@ -9,8 +9,6 @@ for both sync and async workers
from loguru import logger
import time
from changedetectionio.notification import default_notification_format
# What is passed around as notification context, also used as the complete list of valid {{ tokens }}
class NotificationContextData(dict):
def __init__(self, initial_data=None, **kwargs):
@@ -30,8 +28,7 @@ class NotificationContextData(dict):
'diff_url': None,
'preview_url': None,
'watch_tag': None,
'watch_title': None,
'markup_text_to_html': False, # If automatic conversion of plaintext to HTML should happen
'watch_title': None
})
# Apply any initial data passed in
@@ -124,10 +121,10 @@ class NotificationService:
n_object.update({
'current_snapshot': snapshot_contents,
'diff': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, line_feed_sep=line_feed_sep),
'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, patch_format=True),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=line_feed_sep),
'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
'triggered_text': triggered_text,
'uuid': watch.get('uuid') if watch else None,
@@ -228,25 +225,12 @@ class NotificationService:
if not watch:
return
n_format = self.datastore.data['settings']['application'].get('notification_format', default_notification_format)
filter_list = ", ".join(watch['include_filters'])
# @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_to_html' is not needed
body = f"""Hello,
Your configured CSS/xPath filters of '{filter_list}' for {{{{watch_url}}}} did not appear on the page after {threshold} attempts.
It's possible the page changed layout and the filter needs updating ( Try the 'Visual Selector' tab )
Edit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}
Thanks - Your omniscient changedetection.io installation.
"""
n_object = NotificationContextData({
'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page',
'notification_body': body,
'notification_format': n_format,
'markup_text_to_html': n_format.lower().startswith('html')
'notification_body': "Your configured CSS/xPath filters of '{}' for {{{{watch_url}}}} did not appear on the page after {} attempts, did the page change layout?\n\nLink: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\nThanks - Your omniscient changedetection.io installation :)\n".format(
", ".join(watch['include_filters']),
threshold),
'notification_format': 'text'
})
if len(watch['notification_urls']):
@@ -275,27 +259,13 @@ Thanks - Your omniscient changedetection.io installation.
if not watch:
return
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')
n_format = self.datastore.data['settings']['application'].get('notification_format', default_notification_format).lower()
step = step_n + 1
# @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_to_html' is not needed
# {{{{ }}}} because this will be Jinja2 {{ }} tokens
body = f"""Hello,
Your configured browser step at position {step} for the web page watch {{{{watch_url}}}} did not appear on the page after {threshold} attempts, did the page change layout?
The element may have moved and needs editing, or does it need a delay added?
Edit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}
Thanks - Your omniscient changedetection.io installation.
"""
n_object = NotificationContextData({
'notification_title': f"Changedetection.io - Alert - Browser step at position {step} could not be run",
'notification_body': body,
'notification_format': n_format,
'markup_text_to_html': n_format.lower().startswith('html')
'notification_title': "Changedetection.io - Alert - Browser step at position {} could not be run".format(step_n+1),
'notification_body': "Your configured browser step at position {} for {{{{watch_url}}}} "
"did not appear on the page after {} attempts, did the page change layout? "
"Does it need a delay added?\n\nLink: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\n"
"Thanks - Your omniscient changedetection.io installation :)\n".format(step_n+1, threshold),
'notification_format': 'text'
})
if len(watch['notification_urls']):

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

@@ -324,13 +324,13 @@ class ContentProcessor:
append_pretty_line_formatting=not self.watch.is_source_type_url
)
# Raise error if filter returned nothing
if not filtered_content.strip():
raise FilterNotFoundInResponse(
msg=self.filter_config.include_filters,
screenshot=self.fetcher.screenshot,
xpath_data=self.fetcher.xpath_data
)
# Raise error if filter returned nothing
if not filtered_content.strip():
raise FilterNotFoundInResponse(
msg=self.filter_config.include_filters,
screenshot=self.fetcher.screenshot,
xpath_data=self.fetcher.xpath_data
)
return filtered_content

View File

@@ -15,7 +15,7 @@ find tests/test_*py -type f|while read test_name
do
echo "TEST RUNNING $test_name"
# REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 --tb=long $test_name
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest $test_name
done
echo "RUNNING WITH BASE_URL SET"
@@ -23,20 +23,20 @@ echo "RUNNING WITH BASE_URL SET"
# Now re-run some tests with BASE_URL enabled
# Re #65 - Ability to include a link back to the installation, in the notification.
export BASE_URL="https://really-unique-domain.io"
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
# Re-run with HIDE_REFERER set - could affect login
export HIDE_REFERER=True
pytest -vv -s --maxfail=1 tests/test_access_control.py
pytest tests/test_access_control.py
# Re-run a few tests that will trigger brotli based storage
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
pytest -vv -s --maxfail=1 tests/test_access_control.py
pytest tests/test_access_control.py
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
pytest -vv -s --maxfail=1 tests/test_backend.py
pytest -vv -s --maxfail=1 tests/test_rss.py
pytest -vv -s --maxfail=1 tests/test_unique_lines.py
pytest tests/test_backend.py
pytest tests/test_rss.py
pytest tests/test_unique_lines.py
# Try high concurrency
FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l

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

@@ -228,36 +228,26 @@ class ChangeDetectionStore:
d['settings']['application']['active_base_url'] = active_base_url.strip('" ')
return d
from pathlib import Path
def delete_path(self, path: Path):
import shutil
"""Delete a file or directory tree, including the path itself."""
if not path.exists():
return
if path.is_file() or path.is_symlink():
path.unlink(missing_ok=True) # deletes a file or symlink
else:
shutil.rmtree(path, ignore_errors=True) # deletes dir *and* its contents
# Delete a single watch by UUID
def delete(self, uuid):
import pathlib
import shutil
with self.lock:
if uuid == 'all':
self.__data['watching'] = {}
time.sleep(1) # Mainly used for testing to allow all items to flush before running next test
# GitHub #30 also delete history records
for uuid in self.data['watching']:
path = pathlib.Path(os.path.join(self.datastore_path, uuid))
if os.path.exists(path):
self.delete(uuid)
shutil.rmtree(path)
else:
path = pathlib.Path(os.path.join(self.datastore_path, uuid))
if os.path.exists(path):
self.delete_path(path)
shutil.rmtree(path)
del self.data['watching'][uuid]
self.needs_write_urgent = True

View File

@@ -93,9 +93,7 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
"tags": "my tag",
"title": "my title",
"headers": "",
# preprended with extra filter that intentionally doesn't match any entry,
# notification should still be sent even if first filter does not match (PR#3516)
"include_filters": ".non-matching-selector\n.ticket-available",
"include_filters": '.ticket-available',
"fetch_backend": "html_requests",
"time_between_check_use_default": "y"})

View File

@@ -1,8 +1,10 @@
import os
import time
from loguru import logger
from flask import url_for
from .util import set_original_response, wait_for_all_checks, wait_for_notification_endpoint_output
from ..notification import valid_notification_formats
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks, \
wait_for_notification_endpoint_output
from changedetectionio.model import App
def set_response_with_filter():
@@ -21,14 +23,13 @@ def set_response_with_filter():
f.write(test_return_data)
return None
def run_filter_test(client, live_server, content_filter, app_notification_format):
def run_filter_test(client, live_server, content_filter):
# Response WITHOUT the filter ID element
set_original_response()
live_server.app.config['DATASTORE'].data['settings']['application']['notification_format'] = app_notification_format
# Goto the edit page, add our ignore text
notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'post')
notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
@@ -126,23 +127,8 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
with open("test-datastore/notification.txt", 'r') as f:
notification = f.read()
assert 'Your configured CSS/xPath filters' in notification
# Text (or HTML conversion) markup to make the notifications a little nicer should have worked
if app_notification_format.startswith('html'):
# apprise should have used sax-escape (&#39; instead of &quot;, " etc), lets check it worked
from apprise.conversion import convert_between
from apprise.common import NotifyFormat
escaped_filter = convert_between(NotifyFormat.TEXT, NotifyFormat.HTML, content_filter)
assert escaped_filter in notification or escaped_filter.replace('&quot;', '&#34;') in notification
assert 'a href="' in notification # Quotes should still be there so the link works
else:
assert 'a href' not in notification
assert content_filter in notification
assert 'CSS/xPath filter was not present in the page' in notification
assert content_filter.replace('"', '\\"') in notification
# Remove it and prove that it doesn't trigger when not expected
# It should register a change, but no 'filter not found'
@@ -173,20 +159,14 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
os.unlink("test-datastore/notification.txt")
def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage):
# # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('HTML Color'))
# Check markup send conversion didnt affect plaintext preference
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('Text'))
# # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client, live_server,'#nope-doesnt-exist')
def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage):
# # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client=client, live_server=live_server, content_filter='//*[@id="nope-doesnt-exist"]', app_notification_format=valid_notification_formats.get('HTML Color'))
# # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]')
# Test that notification is never sent
def test_basic_markup_from_text(client, live_server, measure_memory_usage):
# Test the notification error templates convert to HTML if needed (link activate)
from ..notification.handler import markup_text_links_to_html
x = markup_text_links_to_html("hello https://google.com")
assert 'a href' in x

View File

@@ -42,9 +42,6 @@ jsonpath-ng~=1.5.3
# Notification library
apprise==1.9.5
# Lightweight URL linkifier for notifications
linkify-it-py
# - Needed for apprise/spush, and maybe others? hopefully doesnt trigger a rust compile.
# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels
# Pinned to 43.0.1 for ARM compatibility (45.x may not have pre-built ARM wheels)
@@ -108,6 +105,8 @@ extruct
# For cleaning up unknown currency formats
babel
# For normalizing currency symbols to ISO 4217 codes
iso4217parse
levenshtein

View File

@@ -5,8 +5,6 @@ import re
import sys
from setuptools import setup, find_packages
from setuptools.command.build_py import build_py
import shutil
here = os.path.abspath(os.path.dirname(__file__))
@@ -24,20 +22,6 @@ def find_version(*file_paths):
raise RuntimeError("Unable to find version string.")
class BuildPyCommand(build_py):
"""Custom build command to copy api-spec.yaml to the package."""
def run(self):
build_py.run(self)
# Ensure the docs directory exists in the build output
docs_dir = os.path.join(self.build_lib, 'changedetectionio', 'docs')
os.makedirs(docs_dir, exist_ok=True)
# Copy api-spec.yaml to the package
shutil.copy(
os.path.join(here, 'docs', 'api-spec.yaml'),
os.path.join(docs_dir, 'api-spec.yaml')
)
install_requires = open('requirements.txt').readlines()
setup(
@@ -53,10 +37,9 @@ setup(
scripts=["changedetection.py"],
author='dgtlmoon',
url='https://changedetection.io',
packages=find_packages(include=['changedetectionio', 'changedetectionio.*']),
packages=['changedetectionio'],
include_package_data=True,
install_requires=install_requires,
cmdclass={'build_py': BuildPyCommand},
license="Apache License 2.0",
python_requires=">= 3.10",
classifiers=['Intended Audience :: Customer Service',