mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-12-01 13:52:34 +00:00
Compare commits
6 Commits
0.50.27
...
restock-cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41952157eb | ||
|
|
274c575f85 | ||
|
|
5c39668b40 | ||
|
|
254ebc90df | ||
|
|
912ab903c9 | ||
|
|
d758afe87e |
27
.github/workflows/pypi-release.yml
vendored
27
.github/workflows/pypi-release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']):
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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 (' instead of ", " 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('"', '"') 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
19
setup.py
19
setup.py
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user