mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-21 00:46:09 +00:00
Compare commits
15 Commits
favicon-de
...
custom-res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93a3470a9 | ||
|
|
57c83e868d | ||
|
|
ddbbe1ddee | ||
|
|
c9c5de20d8 | ||
|
|
1a2e9309ed | ||
|
|
011fa3540e | ||
|
|
c3c3671f8b | ||
|
|
5980bd9bcd | ||
|
|
438871429c | ||
|
|
173ce5bfa2 | ||
|
|
106b1f85fa | ||
|
|
a5c7f343d0 | ||
|
|
401886bcda | ||
|
|
c66fca9de9 | ||
|
|
daee4c5c17 |
1
.github/test/Dockerfile-alpine
vendored
1
.github/test/Dockerfile-alpine
vendored
@@ -18,6 +18,7 @@ RUN \
|
||||
libxslt-dev \
|
||||
openssl-dev \
|
||||
python3-dev \
|
||||
file \
|
||||
zip \
|
||||
zlib-dev && \
|
||||
apk add --update --no-cache \
|
||||
|
||||
@@ -54,6 +54,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
locales \
|
||||
# For pdftohtml
|
||||
poppler-utils \
|
||||
# favicon type detection and other uses
|
||||
file \
|
||||
zlib1g \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
|
||||
__version__ = '0.50.5'
|
||||
__version__ = '0.50.7'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -214,8 +214,17 @@ class WatchFavicon(Resource):
|
||||
|
||||
favicon_filename = watch.get_favicon_filename()
|
||||
if favicon_filename:
|
||||
import mimetypes
|
||||
mime, encoding = mimetypes.guess_type(favicon_filename)
|
||||
try:
|
||||
import magic
|
||||
mime = magic.from_file(
|
||||
os.path.join(watch.watch_data_dir, favicon_filename),
|
||||
mime=True
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback, no python-magic
|
||||
import mimetypes
|
||||
mime, encoding = mimetypes.guess_type(favicon_filename)
|
||||
|
||||
response = make_response(send_from_directory(watch.watch_data_dir, favicon_filename))
|
||||
response.headers['Content-type'] = mime
|
||||
response.headers['Cache-Control'] = 'max-age=300, must-revalidate' # Cache for 5 minutes, then revalidate
|
||||
|
||||
@@ -199,6 +199,14 @@ nav
|
||||
</ul>
|
||||
</span>
|
||||
</fieldset>
|
||||
<fieldset class="pure-group">
|
||||
{{ render_field(form.application.form.custom_outofstock_strings) }}
|
||||
<span class="pure-form-message-inline">Additional custom out-of-stock detection strings (one per line).</span>
|
||||
</fieldset>
|
||||
<fieldset class="pure-group">
|
||||
{{ render_field(form.application.form.custom_instock_strings) }}
|
||||
<span class="pure-form-message-inline">Additional custom in-stock detection strings (one per line).</span>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane-inner" id="api">
|
||||
@@ -256,6 +264,11 @@ nav
|
||||
{{ render_checkbox_field(form.application.form.ui.form.socket_io_enabled, class="socket_io_enabled") }}
|
||||
<span class="pure-form-message-inline">Realtime UI Updates Enabled - (Restart required if this is changed)</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_checkbox_field(form.application.form.ui.form.favicons_enabled, class="") }}
|
||||
<span class="pure-form-message-inline">Enable or Disable Favicons next to the watch list</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="tab-pane-inner" id="proxies">
|
||||
<div id="recommended-proxy">
|
||||
|
||||
@@ -81,10 +81,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
{%- if any_has_restock_price_processor -%}
|
||||
{%- set cols_required = cols_required + 1 -%}
|
||||
{%- endif -%}
|
||||
{%- set ui_settings = datastore.data['settings']['application']['ui'] -%}
|
||||
|
||||
<div id="watch-table-wrapper">
|
||||
|
||||
<table class="pure-table pure-table-striped watch-table">
|
||||
{%- set table_classes = [
|
||||
'favicon-enabled' if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] else 'favicon-not-enabled',
|
||||
] -%}
|
||||
<table class="pure-table pure-table-striped watch-table {{ table_classes | reject('equalto', '') | join(' ') }}">
|
||||
<thead>
|
||||
<tr>
|
||||
{%- set link_order = "desc" if sort_order == 'asc' else "asc" -%}
|
||||
@@ -114,7 +117,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
{%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%}
|
||||
{%- set checking_now = is_checking_now(watch) -%}
|
||||
{%- set history_n = watch.history_n -%}
|
||||
{%- set has_favicon = watch.get_favicon_filename() -%}
|
||||
{%- set favicon = watch.get_favicon_filename() -%}
|
||||
{# Mirror in changedetectionio/static/js/realtime.js for the frontend #}
|
||||
{%- set row_classes = [
|
||||
loop.cycle('pure-table-odd', 'pure-table-even'),
|
||||
@@ -123,7 +126,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
'paused' if watch.paused is defined and watch.paused != False else '',
|
||||
'unviewed' if watch.has_unviewed else '',
|
||||
'has-restock-info' if watch.has_restock_info else 'no-restock-info',
|
||||
'has-favicon' if has_favicon else '',
|
||||
'has-favicon' if favicon else '',
|
||||
'in-stock' if watch.has_restock_info and watch['restock']['in_stock'] else '',
|
||||
'not-in-stock' if watch.has_restock_info and not watch['restock']['in_stock'] else '',
|
||||
'queued' if watch.uuid in queued_uuids else '',
|
||||
@@ -145,9 +148,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
<td class="title-col inline">
|
||||
<div class="flex-wrapper">
|
||||
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
|
||||
<div>{# A page might have hundreds of these images, set IMG options for lazy loading, don't set SRC if we dont have it so it doesnt fetch the placeholder' #}
|
||||
<img alt="Favicon thumbnail" style="display: none;" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if has_favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="{% endif %} />
|
||||
<img alt="Favicon thumbnail" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E' {% endif %} />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<span class="watch-title">
|
||||
{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"> </a>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
async () => {
|
||||
async (customOutOfStockStrings = []) => {
|
||||
|
||||
function isItemInStock() {
|
||||
// @todo Pass these in so the same list can be used in non-JS fetchers
|
||||
const outOfStockTexts = [
|
||||
const builtInOutOfStockTexts = [
|
||||
' أخبرني عندما يتوفر',
|
||||
'0 in stock',
|
||||
'actuellement indisponible',
|
||||
@@ -110,6 +110,9 @@ async () => {
|
||||
'품절'
|
||||
];
|
||||
|
||||
// Combine built-in strings with custom strings provided by user
|
||||
const outOfStockTexts = [...builtInOutOfStockTexts, ...customOutOfStockStrings];
|
||||
|
||||
|
||||
const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
||||
|
||||
|
||||
@@ -438,8 +438,17 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
|
||||
favicon_filename = watch.get_favicon_filename()
|
||||
if favicon_filename:
|
||||
import mimetypes
|
||||
mime, encoding = mimetypes.guess_type(favicon_filename)
|
||||
try:
|
||||
import magic
|
||||
mime = magic.from_file(
|
||||
os.path.join(watch.watch_data_dir, favicon_filename),
|
||||
mime=True
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback, no python-magic
|
||||
import mimetypes
|
||||
mime, encoding = mimetypes.guess_type(favicon_filename)
|
||||
|
||||
response = make_response(send_from_directory(watch.watch_data_dir, favicon_filename))
|
||||
response.headers['Content-type'] = mime
|
||||
response.headers['Cache-Control'] = 'max-age=300, must-revalidate' # Cache for 5 minutes, then revalidate
|
||||
|
||||
@@ -740,6 +740,7 @@ class globalSettingsRequestForm(Form):
|
||||
class globalSettingsApplicationUIForm(Form):
|
||||
open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()])
|
||||
socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()])
|
||||
favicons_enabled = BooleanField('Favicons Enabled', default=True, validators=[validators.Optional()])
|
||||
|
||||
# datastore.data['settings']['application']..
|
||||
class globalSettingsApplicationForm(commonSettingsForm):
|
||||
@@ -773,6 +774,20 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
||||
message="Should contain zero or more attempts")])
|
||||
ui = FormField(globalSettingsApplicationUIForm)
|
||||
|
||||
#@todo better validations?
|
||||
|
||||
custom_outofstock_strings = StringListField('Custom out-of-stock detection strings',
|
||||
[validators.Optional()],
|
||||
render_kw={
|
||||
"placeholder": "Enter custom out-of-stock strings, one per line\nExample:\nPronto estarán en stock!\nTemporarily out of stock",
|
||||
"rows": "3"})
|
||||
|
||||
custom_instock_strings = StringListField('Custom in-stock detection strings',
|
||||
[validators.Optional()],
|
||||
render_kw={
|
||||
"placeholder": "Enter custom in-stock strings, one per line\nExample:\nDisponible ahora\nIn voorraad",
|
||||
"rows": "3"})
|
||||
|
||||
|
||||
class globalSettingsForm(Form):
|
||||
# Define these as FormFields/"sub forms", this way it matches the JSON storage
|
||||
|
||||
@@ -38,6 +38,8 @@ class model(dict):
|
||||
# Custom notification content
|
||||
'api_access_token_enabled': True,
|
||||
'base_url' : None,
|
||||
'custom_instock_strings': [],
|
||||
'custom_outofstock_strings' : [],
|
||||
'empty_pages_are_a_change': False,
|
||||
'extract_title_as_title': False,
|
||||
'fetch_backend': getenv("DEFAULT_FETCH_BACKEND", "html_requests"),
|
||||
@@ -63,6 +65,7 @@ class model(dict):
|
||||
'ui': {
|
||||
'open_diff_in_new_tab': True,
|
||||
'socket_io_enabled': True,
|
||||
'favicons_enabled': True
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
validators,
|
||||
FloatField
|
||||
FloatField,
|
||||
TextAreaField
|
||||
)
|
||||
from wtforms.fields.choices import RadioField
|
||||
from wtforms.fields.form import FormField
|
||||
@@ -29,6 +30,7 @@ class RestockSettingsForm(Form):
|
||||
|
||||
follow_price_changes = BooleanField('Follow price changes', default=True)
|
||||
|
||||
|
||||
class processor_settings_form(processor_text_json_diff_form):
|
||||
restock_settings = FormField(RestockSettingsForm)
|
||||
|
||||
@@ -74,7 +76,7 @@ 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>
|
||||
</div>
|
||||
</fieldset>
|
||||
"""
|
||||
|
||||
@@ -143,6 +143,89 @@ def is_between(number, lower=None, upper=None):
|
||||
class perform_site_check(difference_detection_processor):
|
||||
screenshot = None
|
||||
xpath_data = None
|
||||
|
||||
def _normalize_text_for_matching(self, text):
|
||||
"""
|
||||
Normalize text for more robust matching:
|
||||
- Convert to lowercase
|
||||
- Remove accents/diacritics
|
||||
- Normalize whitespace
|
||||
"""
|
||||
import unicodedata
|
||||
import re
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Convert to lowercase
|
||||
text = text.lower()
|
||||
|
||||
# Remove accents/diacritics (NFD normalization + filter)
|
||||
# This converts "é" to "e", "ñ" to "n", etc.
|
||||
text = unicodedata.normalize('NFD', text)
|
||||
text = ''.join(char for char in text if unicodedata.category(char) != 'Mn')
|
||||
|
||||
# Normalize whitespace (replace multiple spaces/tabs/newlines with single space)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
return text
|
||||
|
||||
def _check_custom_strings(self, text_to_check, custom_strings, string_type="out-of-stock"):
|
||||
"""
|
||||
Check text against custom strings (either in-stock or out-of-stock).
|
||||
Uses normalized matching for better international support.
|
||||
Returns the matched string if found, None otherwise.
|
||||
"""
|
||||
if not custom_strings:
|
||||
return None
|
||||
|
||||
# Split custom strings by newlines and clean them up
|
||||
raw_custom_list = [s.strip() for s in custom_strings.split('\n') if s.strip()]
|
||||
|
||||
if not raw_custom_list:
|
||||
return None
|
||||
|
||||
# Normalize both the page text and custom strings for matching
|
||||
normalized_text = self._normalize_text_for_matching(text_to_check)
|
||||
|
||||
# Check each custom string against the text
|
||||
for original_custom_text in raw_custom_list:
|
||||
normalized_custom_text = self._normalize_text_for_matching(original_custom_text)
|
||||
|
||||
if normalized_custom_text and normalized_custom_text in normalized_text:
|
||||
logger.debug(f"Custom {string_type} string found: '{original_custom_text}' (normalized: '{normalized_custom_text}')")
|
||||
return original_custom_text # Return the original user-provided string
|
||||
|
||||
return None
|
||||
|
||||
def _get_combined_instock_strings(self, restock_settings):
|
||||
"""
|
||||
Get combined list of built-in and custom in-stock strings.
|
||||
Custom strings are normalized for better matching.
|
||||
"""
|
||||
# Built-in in-stock strings (from the TODO line)
|
||||
builtin_instock_strings = [
|
||||
'instock',
|
||||
'instoreonly',
|
||||
'limitedavailability',
|
||||
'onlineonly',
|
||||
'presale'
|
||||
]
|
||||
|
||||
# Add custom in-stock strings if provided
|
||||
custom_strings = restock_settings.get('custom_instock_strings', '').strip()
|
||||
if custom_strings:
|
||||
# Normalize custom strings for better matching
|
||||
custom_list = []
|
||||
for s in custom_strings.split('\n'):
|
||||
s = s.strip()
|
||||
if s:
|
||||
normalized = self._normalize_text_for_matching(s)
|
||||
if normalized:
|
||||
custom_list.append(normalized)
|
||||
builtin_instock_strings.extend(custom_list)
|
||||
|
||||
return builtin_instock_strings
|
||||
|
||||
def run_changedetection(self, watch):
|
||||
import hashlib
|
||||
@@ -205,6 +288,7 @@ class perform_site_check(difference_detection_processor):
|
||||
|
||||
if itemprop_availability.get('availability'):
|
||||
# @todo: Configurable?
|
||||
|
||||
if any(substring.lower() in itemprop_availability['availability'].lower() for substring in [
|
||||
'instock',
|
||||
'instoreonly',
|
||||
@@ -238,6 +322,8 @@ class perform_site_check(difference_detection_processor):
|
||||
if self.fetcher.instock_data and itemprop_availability.get('availability') is None:
|
||||
# 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
|
||||
# Careful! this does not really come from chrome/js when the watch is set to plaintext
|
||||
stock_detection_result = self.fetcher.instock_data
|
||||
|
||||
update_obj['restock']["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False
|
||||
logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned instock_data - '{self.fetcher.instock_data}' from JS scraper.")
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,15 +3,16 @@
|
||||
"version": "0.0.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"watch": "node-sass -w scss -o .",
|
||||
"build": "node-sass scss -o ."
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"scripts": {
|
||||
"watch": "sass --watch scss:. --style=compressed --no-source-map",
|
||||
"build": "sass scss:. --style=compressed --no-source-map"
|
||||
},
|
||||
"author": "Leigh Morresi / Web Technologies s.r.o.",
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"node-sass": "^7.0.0",
|
||||
"tar": "^6.1.9",
|
||||
"trim-newlines": "^3.0.1"
|
||||
"sass": "^1.77.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "parts/_variables.scss";
|
||||
@use "parts/variables";
|
||||
|
||||
#diff-ui {
|
||||
|
||||
|
||||
@@ -64,17 +64,17 @@ body.proxy-check-active {
|
||||
#recommended-proxy {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
@media (min-width: 991px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
padding-bottom: 1em;
|
||||
|
||||
@media (min-width: 991px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
> div {
|
||||
border: 1px #aaa solid;
|
||||
border-radius: 4px;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
#extra-proxies-setting {
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
.watch-table {
|
||||
&.favicon-not-enabled {
|
||||
tr {
|
||||
.favicon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
/* make the icons and the text inline-ish */
|
||||
td.inline.title-col {
|
||||
.flex-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
td,
|
||||
th {
|
||||
vertical-align: middle;
|
||||
|
||||
}
|
||||
|
||||
tr.has-favicon {
|
||||
img.favicon {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
&.unviewed {
|
||||
img.favicon {
|
||||
opacity: 1.0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-icons {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center; /* Vertical centering */
|
||||
gap: 4px; /* Space between image and text */
|
||||
display: flex;
|
||||
align-items: center; /* Vertical centering */
|
||||
gap: 4px; /* Space between image and text */
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -55,33 +70,23 @@
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
tr.has-favicon {
|
||||
td.inline.title-col {
|
||||
.flex-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reserved for future use
|
||||
/* &.thumbnail-type-screenshot {
|
||||
tr.has-favicon {
|
||||
td.inline.title-col {
|
||||
img.thumbnail {
|
||||
background-color: #fff; !* fallback bg for SVGs without bg *!
|
||||
border-radius: 4px; !* subtle rounded corners *!
|
||||
border: 1px solid #ddd; !* light border for contrast *!
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); !* soft shadow *!
|
||||
filter: contrast(1.05) saturate(1.1) drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.2));
|
||||
object-fit: cover; !* crop/fill if needed *!
|
||||
opacity: 0.8;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
height: 30px;
|
||||
// Reserved for future use
|
||||
/* &.thumbnail-type-screenshot {
|
||||
tr.has-favicon {
|
||||
td.inline.title-col {
|
||||
img.thumbnail {
|
||||
background-color: #fff; !* fallback bg for SVGs without bg *!
|
||||
border-radius: 4px; !* subtle rounded corners *!
|
||||
border: 1px solid #ddd; !* light border for contrast *!
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); !* soft shadow *!
|
||||
filter: contrast(1.05) saturate(1.1) drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.2));
|
||||
object-fit: cover; !* crop/fill if needed *!
|
||||
opacity: 0.8;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}*/
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "minitabs";
|
||||
@use "minitabs";
|
||||
|
||||
body.preview-text-enabled {
|
||||
|
||||
|
||||
@@ -34,11 +34,17 @@ $grid-gap: 0.5rem;
|
||||
|
||||
|
||||
.last-checked {
|
||||
margin-left: calc($grid-col-checkbox + $grid-gap);
|
||||
|
||||
> span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.last-changed {
|
||||
margin-left: calc($grid-col-checkbox + $grid-gap);
|
||||
}
|
||||
|
||||
.last-checked::before {
|
||||
color: var(--color-text);
|
||||
content: "Last Checked ";
|
||||
@@ -167,6 +173,6 @@ $grid-gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
.pure-table td {
|
||||
padding: 5px !important;
|
||||
padding: 3px !important;
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,24 @@
|
||||
* -- BASE STYLES --
|
||||
*/
|
||||
|
||||
@import "parts/_arrows";
|
||||
@import "parts/_browser-steps";
|
||||
@import "parts/_extra_proxies";
|
||||
@import "parts/_extra_browsers";
|
||||
@import "parts/_pagination";
|
||||
@import "parts/_spinners";
|
||||
@import "parts/_variables";
|
||||
@import "parts/_darkmode";
|
||||
@import "parts/_menu";
|
||||
@import "parts/_love";
|
||||
@import "parts/preview_text_filter";
|
||||
@import "parts/_watch_table";
|
||||
@import "parts/_watch_table-mobile";
|
||||
@import "parts/_edit";
|
||||
@import "parts/_conditions_table";
|
||||
@import "parts/_lister_extra";
|
||||
@import "parts/_socket";
|
||||
@use "parts/variables";
|
||||
@use "parts/arrows";
|
||||
@use "parts/browser-steps";
|
||||
@use "parts/extra_proxies";
|
||||
@use "parts/extra_browsers";
|
||||
@use "parts/pagination";
|
||||
@use "parts/spinners";
|
||||
@use "parts/darkmode";
|
||||
@use "parts/menu";
|
||||
@use "parts/love";
|
||||
@use "parts/preview_text_filter";
|
||||
@use "parts/watch_table";
|
||||
@use "parts/watch_table-mobile";
|
||||
@use "parts/edit";
|
||||
@use "parts/conditions_table";
|
||||
@use "parts/lister_extra";
|
||||
@use "parts/socket";
|
||||
@use "parts/visualselector";
|
||||
|
||||
|
||||
body {
|
||||
@@ -187,9 +188,15 @@ code {
|
||||
@extend .inline-tag;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.box {
|
||||
margin: 0 1em !important;
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
max-width: 100%;
|
||||
margin: 0 1em;
|
||||
margin: 0 0.3em;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -951,8 +958,6 @@ ul {
|
||||
}
|
||||
}
|
||||
|
||||
@import "parts/_visualselector";
|
||||
|
||||
#webdriver_delay {
|
||||
width: 5em;
|
||||
}
|
||||
@@ -1070,17 +1075,23 @@ ul {
|
||||
|
||||
|
||||
#quick-watch-processor-type {
|
||||
color: #fff;
|
||||
ul {
|
||||
padding: 0.3rem;
|
||||
ul#processor {
|
||||
color: #fff;
|
||||
padding-left: 0px;
|
||||
li {
|
||||
list-style: none;
|
||||
font-size: 0.9rem;
|
||||
> * {
|
||||
display: inline-block;
|
||||
}
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
label, input {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.restock-label {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,9 +14,12 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage):
|
||||
#####################
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={"application-empty_pages_are_a_change": "",
|
||||
"requests-time_between_check-minutes": 180,
|
||||
'application-fetch_backend': "html_webdriver"},
|
||||
data={
|
||||
"application-empty_pages_are_a_change": "",
|
||||
"requests-time_between_check-minutes": 180,
|
||||
'application-fetch_backend': "html_webdriver",
|
||||
'application-ui-favicons_enabled': "y",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
@@ -61,3 +64,22 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage):
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) > 10
|
||||
|
||||
##################### disable favicons check
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={
|
||||
"requests-time_between_check-minutes": 180,
|
||||
'application-ui-favicons_enabled': "",
|
||||
"application-empty_pages_are_a_change": "",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert b"Settings updated." in res.data
|
||||
|
||||
res = client.get(
|
||||
url_for("watchlist.index"),
|
||||
)
|
||||
# The UI can access it here
|
||||
assert f'src="/static/favicon'.encode('utf8') not in res.data
|
||||
|
||||
@@ -111,3 +111,130 @@ def test_restock_detection(client, live_server, measure_memory_usage):
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'not-in-stock' in res.data, "Correctly showing NOT IN STOCK in the list after it changed from IN STOCK"
|
||||
|
||||
|
||||
def test_restock_custom_strings(client, live_server):
|
||||
"""Test custom out-of-stock strings feature"""
|
||||
|
||||
# Set up a response with custom out-of-stock text
|
||||
test_return_data = """<html>
|
||||
<body>
|
||||
Some initial text<br>
|
||||
<p>Which is across multiple lines</p>
|
||||
<br>
|
||||
So let's see what happens. <br>
|
||||
<div>price: $10.99</div>
|
||||
<div id="custom">Pronto estarán en stock!</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||
f.write(test_return_data)
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True).replace('http://localhost', 'http://changedet')
|
||||
|
||||
# Add watch with custom out-of-stock strings
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": test_url, "tags": '', 'processor': 'restock_diff'},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
# Get the UUID so we can configure the watch
|
||||
uuid = extract_UUID_from_client(client)
|
||||
|
||||
# Configure custom out-of-stock strings
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
|
||||
data={
|
||||
"url": test_url,
|
||||
'processor': 'restock_diff',
|
||||
'restock_settings-custom_outofstock_strings': 'Pronto estarán en stock!\nCustom unavailable message'
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
# Check that it detects as out of stock
|
||||
wait_for_all_checks(client)
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'not-in-stock' in res.data, "Should detect custom out-of-stock string"
|
||||
|
||||
# Test custom in-stock strings by changing the content
|
||||
test_return_data_instock = """<html>
|
||||
<body>
|
||||
Some initial text<br>
|
||||
<p>Which is across multiple lines</p>
|
||||
<br>
|
||||
So let's see what happens. <br>
|
||||
<div>price: $10.99</div>
|
||||
<div id="custom">Disponible ahora</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||
f.write(test_return_data_instock)
|
||||
|
||||
# Update the watch to include custom in-stock strings
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
|
||||
data={
|
||||
"url": test_url,
|
||||
'processor': 'restock_diff',
|
||||
'restock_settings-custom_outofstock_strings': 'Pronto estarán en stock!\nCustom unavailable message',
|
||||
'restock_settings-custom_instock_strings': 'Disponible ahora\nIn voorraad'
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
# Check again - should be detected as in stock now
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'not-in-stock' not in res.data, "Should detect custom in-stock string and show as available"
|
||||
|
||||
|
||||
def test_restock_custom_strings_normalization(client, live_server):
|
||||
"""Test key normalization scenarios: accents, case, and spaces"""
|
||||
|
||||
# Test page with Spanish text with accents and mixed case
|
||||
test_return_data = """<html>
|
||||
<body>
|
||||
<div>price: $10.99</div>
|
||||
<div id="status">¡TEMPORALMENTE AGOTADO!</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||
f.write(test_return_data)
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True).replace('http://localhost', 'http://changedet')
|
||||
|
||||
# Add watch
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": test_url, "tags": '', 'processor': 'restock_diff'},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
uuid = extract_UUID_from_client(client)
|
||||
|
||||
# Configure custom string without accents, lowercase, no extra spaces
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
|
||||
data={
|
||||
"url": test_url,
|
||||
'processor': 'restock_diff',
|
||||
'restock_settings-custom_outofstock_strings': 'temporalmente agotado'
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
# Should detect as out of stock despite text differences
|
||||
wait_for_all_checks(client)
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'not-in-stock' in res.data, "Should match despite accents, case, and spacing differences"
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from changedetectionio.processors.restock_diff.processor import perform_site_check
|
||||
|
||||
|
||||
class TestCustomStringNormalization(unittest.TestCase):
|
||||
"""Test the text normalization logic for custom out-of-stock strings"""
|
||||
|
||||
def setUp(self):
|
||||
# Create a processor instance for testing
|
||||
self.processor = perform_site_check(datastore=None, watch_uuid='test')
|
||||
|
||||
def test_normalize_text_for_matching(self):
|
||||
"""Test the _normalize_text_for_matching method"""
|
||||
|
||||
test_cases = [
|
||||
# (input, expected_output)
|
||||
("Agotado", "agotado"),
|
||||
("AGOTADO", "agotado"), # Lowercase
|
||||
("Sin stock!", "sin stock!"), # Normalize whitespace
|
||||
("Pronto\t\nestarán\nen stock", "pronto estaran en stock"), # Multiple whitespace types + accents
|
||||
("¡Temporalmente AGOTADO!", "¡temporalmente agotado!"), # Complex case
|
||||
("", ""), # Empty string
|
||||
("café", "cafe"), # French accent
|
||||
("naïve", "naive"), # Multiple accents
|
||||
]
|
||||
|
||||
for input_text, expected in test_cases:
|
||||
with self.subTest(input_text=input_text):
|
||||
result = self.processor._normalize_text_for_matching(input_text)
|
||||
self.assertEqual(result, expected,
|
||||
f"Failed to normalize '{input_text}' -> expected '{expected}', got '{result}'")
|
||||
|
||||
def test_check_custom_strings_normalization(self):
|
||||
"""Test that custom string matching works with normalization"""
|
||||
|
||||
test_cases = [
|
||||
# (page_text, custom_strings, should_match, description)
|
||||
("AGOTADO", "agotado", True, "uppercase to lowercase"),
|
||||
("Agotado", "agotado", True, "single uppercase to lowercase"),
|
||||
("Sin stock!", "sin stock", True, "multiple spaces normalized"),
|
||||
("¡Pronto estarán en stock!", "pronto estaran en stock", True, "accents + spaces"),
|
||||
("TEMPORALMENTE AGOTADO", "temporalmente agotado", True, "multi-word uppercase"),
|
||||
("Available now", "agotado", False, "no match case"),
|
||||
("", "agotado", False, "empty text"),
|
||||
("agotado", "", False, "empty custom strings"),
|
||||
]
|
||||
|
||||
for page_text, custom_strings, should_match, description in test_cases:
|
||||
with self.subTest(description=description):
|
||||
result = self.processor._check_custom_strings(page_text, custom_strings, "out-of-stock")
|
||||
|
||||
if should_match:
|
||||
self.assertIsNotNone(result,
|
||||
f"Expected match for '{description}': '{page_text}' should match '{custom_strings}'")
|
||||
else:
|
||||
self.assertIsNone(result,
|
||||
f"Expected no match for '{description}': '{page_text}' should not match '{custom_strings}'")
|
||||
|
||||
def test_check_custom_strings_multiline(self):
|
||||
"""Test that multi-line custom strings work properly"""
|
||||
|
||||
page_text = "Product status: TEMPORALMENTE AGOTADO"
|
||||
custom_strings = """
|
||||
sin stock
|
||||
agotado
|
||||
temporalmente agotado
|
||||
"""
|
||||
|
||||
result = self.processor._check_custom_strings(page_text, custom_strings, "out-of-stock")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.strip(), "temporalmente agotado")
|
||||
|
||||
def test_get_combined_instock_strings_normalization(self):
|
||||
"""Test that custom in-stock strings are normalized properly"""
|
||||
|
||||
restock_settings = {
|
||||
'custom_instock_strings': 'Disponible AHORA\nEn Stock\nDISPONÍBLE'
|
||||
}
|
||||
|
||||
result = self.processor._get_combined_instock_strings(restock_settings)
|
||||
|
||||
# Check that built-in strings are included
|
||||
self.assertIn('instock', result)
|
||||
self.assertIn('presale', result)
|
||||
|
||||
# Check that custom strings are normalized and included
|
||||
self.assertIn('disponible ahora', result)
|
||||
self.assertIn('en stock', result)
|
||||
self.assertIn('disponible', result) # accent removed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 200 KiB |
@@ -117,6 +117,9 @@ price-parser
|
||||
|
||||
# flask_socket_io - incorrect package name, already have flask-socketio above
|
||||
|
||||
# So far for detecting correct favicon type, but for other things in the future
|
||||
python-magic
|
||||
|
||||
# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
|
||||
tzdata
|
||||
|
||||
|
||||
Reference in New Issue
Block a user