Compare commits

..

1 Commits

Author SHA1 Message Date
dgtlmoon
670f72631c Conditions - Fixing "Does NOT contain" condition 2025-06-24 15:04:34 +02:00
42 changed files with 2131 additions and 971 deletions

View File

@@ -18,7 +18,6 @@ RUN \
libxslt-dev \ libxslt-dev \
openssl-dev \ openssl-dev \
python3-dev \ python3-dev \
file \
zip \ zip \
zlib-dev && \ zlib-dev && \
apk add --update --no-cache \ apk add --update --no-cache \

View File

@@ -16,7 +16,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \ libssl-dev \
libxslt-dev \ libxslt-dev \
make \ make \
patch \
zlib1g-dev zlib1g-dev
RUN mkdir /install RUN mkdir /install
@@ -54,8 +53,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
locales \ locales \
# For pdftohtml # For pdftohtml
poppler-utils \ poppler-utils \
# favicon type detection and other uses
file \
zlib1g \ zlib1g \
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/*

View File

@@ -1,21 +1,11 @@
# Monitor website changes ## Web Site Change Detection, Monitoring and Notification.
Detect WebPage Changes Automatically — Monitor Web Page Changes in Real Time Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more
Monitor websites for updates — get notified via Discord, Email, Slack, Telegram, Webhook and many more.
Detect web page content changes and get instant alerts.
[Changedetection.io is the best tool to monitor web-pages for changes](https://changedetection.io) Track website content changes and receive notifications via Discord, Email, Slack, Telegram and 90+ more
Ideal for monitoring price changes, content edits, conditional changes and more.
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring, list of websites with changes" title="Self-hosted web page change monitoring, list of websites with changes" />](https://changedetection.io) [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring, list of websites with changes" title="Self-hosted web page change monitoring, list of websites with changes" />](https://changedetection.io)
[**Don't have time? Try our extremely affordable subscription use our proxies and support!**](https://changedetection.io) [**Don't have time? Let us host it for you! try our extremely affordable subscription use our proxies and support!**](https://changedetection.io)
### Target specific parts of the webpage using the Visual Selector tool. ### Target specific parts of the webpage using the Visual Selector tool.

View File

@@ -1,13 +1,11 @@
# Detect Website Changes Automatically — Monitor Web Page Changes in Real Time ## Web Site Change Detection, Restock monitoring and notifications.
Monitor websites for updates — get notified via Discord, Email, Slack, Telegram, Webhook and many more. **_Detect website content changes and perform meaningful actions - trigger notifications via Discord, Email, Slack, Telegram, API calls and many more._**
**Detect web page content changes and get instant alerts.** _Live your data-life pro-actively._
Ideal for monitoring price changes, content edits, conditional changes and more.
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Web site page change monitoring" title="Web site page change monitoring" />](https://changedetection.io?src=github) [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web site page change monitoring" title="Self-hosted web site page change monitoring" />](https://changedetection.io?src=github)
[![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md) [![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md)
@@ -15,7 +13,6 @@ Ideal for monitoring price changes, content edits, conditional changes and more.
[**Get started with website page change monitoring straight away. Don't have time? Try our $8.99/month subscription, use our proxies and support!**](https://changedetection.io) , _half the price of other website change monitoring services!_ [**Get started with website page change monitoring straight away. Don't have time? Try our $8.99/month subscription, use our proxies and support!**](https://changedetection.io) , _half the price of other website change monitoring services!_
- Chrome browser included. - Chrome browser included.
- Nothing to install, access via browser login after signup. - Nothing to install, access via browser login after signup.
- Super fast, no registration needed setup. - Super fast, no registration needed setup.
@@ -102,7 +99,9 @@ _Need an actual Chrome runner with Javascript support? We support fetching via W
- Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration) - Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration)
- Send a screenshot with the notification when a change is detected in the web page - Send a screenshot with the notification when a change is detected in the web page
We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $150 using our signup link. We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $100 using our signup link.
[Oxylabs](https://oxylabs.go2cloud.org/SH2d) is also an excellent proxy provider and well worth using, they offer Residental, ISP, Rotating and many other proxy types to suit your project.
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/

View File

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

View File

@@ -5,7 +5,7 @@ from flask_expects_json import expects_json
from changedetectionio import queuedWatchMetaData from changedetectionio import queuedWatchMetaData
from changedetectionio import worker_handler from changedetectionio import worker_handler
from flask_restful import abort, Resource from flask_restful import abort, Resource
from flask import request, make_response, send_from_directory from flask import request, make_response
import validators import validators
from . import auth from . import auth
import copy import copy
@@ -191,47 +191,6 @@ class WatchSingleHistory(Resource):
return response return response
class WatchFavicon(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
def get(self, uuid):
"""
@api {get} /api/v1/watch/<string:uuid>/favicon Get Favicon for a watch
@apiDescription Requires watch `uuid`
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/favicon -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Get latest Favicon
@apiGroup Watch History
@apiSuccess (200) {String} OK
@apiSuccess (404) {String} ERR Not found
"""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
favicon_filename = watch.get_favicon_filename()
if 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
return response
abort(404, message=f'No Favicon available for {uuid}')
class CreateWatch(Resource): class CreateWatch(Resource):
def __init__(self, **kwargs): def __init__(self, **kwargs):

View File

@@ -26,7 +26,7 @@ schema_delete_notification_urls = copy.deepcopy(schema_notification_urls)
schema_delete_notification_urls['required'] = ['notification_urls'] schema_delete_notification_urls['required'] = ['notification_urls']
# Import all API resources # Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch
from .Tags import Tags, Tag from .Tags import Tags, Tag
from .Import import Import from .Import import Import
from .SystemInfo import SystemInfo from .SystemInfo import SystemInfo

View File

@@ -353,12 +353,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
except Exception as e: except Exception as e:
pass pass
# Store favicon if necessary
if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
)
datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3), datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
'check_count': count}) 'check_count': count})

View File

@@ -256,11 +256,6 @@ nav
{{ render_checkbox_field(form.application.form.ui.form.socket_io_enabled, class="socket_io_enabled") }} {{ 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> <span class="pure-form-message-inline">Realtime UI Updates Enabled - (Restart required if this is changed)</span>
</div> </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>
<div class="tab-pane-inner" id="proxies"> <div class="tab-pane-inner" id="proxies">
<div id="recommended-proxy"> <div id="recommended-proxy">

View File

@@ -1,7 +1,8 @@
from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort
from flask_login import current_user
import os import os
import time import time
from loguru import logger from copy import deepcopy
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required from changedetectionio.auth_decorator import login_optionally_required
@@ -77,42 +78,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
return output return output
@views_blueprint.route("/diff/<string:uuid>", methods=['POST']) @views_blueprint.route("/diff/<string:uuid>", methods=['GET', 'POST'])
@login_optionally_required
def diff_history_page_build_report(uuid):
from changedetectionio import forms
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
# For submission of requesting an extract
extract_form = forms.extractDataForm(request.form)
if not extract_form.validate():
flash("An error occurred, please see below.", "error")
else:
extract_regex = request.form.get('extract_regex').strip()
output = watch.extract_regex_from_all_history(extract_regex)
if output:
watch_dir = os.path.join(datastore.datastore_path, uuid)
response = make_response(send_from_directory(directory=watch_dir, path=output, as_attachment=True))
response.headers['Content-type'] = 'text/csv'
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = "0"
return response
flash('Nothing matches that RegEx', 'error')
redirect(url_for('ui_views.diff_history_page', uuid=uuid) + '#extract')
@views_blueprint.route("/diff/<string:uuid>", methods=['GET'])
@login_optionally_required @login_optionally_required
def diff_history_page(uuid): def diff_history_page(uuid):
from changedetectionio import forms from changedetectionio import forms
@@ -130,31 +96,60 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# For submission of requesting an extract # For submission of requesting an extract
extract_form = forms.extractDataForm(request.form) extract_form = forms.extractDataForm(request.form)
if request.method == 'POST':
if not extract_form.validate():
flash("An error occurred, please see below.", "error")
else:
extract_regex = request.form.get('extract_regex').strip()
output = watch.extract_regex_from_all_history(extract_regex)
if output:
watch_dir = os.path.join(datastore.datastore_path, uuid)
response = make_response(send_from_directory(directory=watch_dir, path=output, as_attachment=True))
response.headers['Content-type'] = 'text/csv'
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = 0
return response
flash('Nothing matches that RegEx', 'error')
redirect(url_for('ui_views.diff_history_page', uuid=uuid)+'#extract')
history = watch.history history = watch.history
dates = list(history.keys()) dates = list(history.keys())
# If a "from_version" was requested, then find it (or the closest one) if len(dates) < 2:
# Also set "from version" to be the closest version to the one that was last viewed. flash("Not enough saved change detection snapshots to produce a report.", "error")
return redirect(url_for('watchlist.index'))
best_last_viewed_timestamp = watch.get_from_version_based_on_last_viewed # Save the current newest history as the most recently viewed
from_version_timestamp = best_last_viewed_timestamp if best_last_viewed_timestamp else dates[-2] datastore.set_last_viewed(uuid, time.time())
from_version = request.args.get('from_version', from_version_timestamp )
# Use the current one if nothing was specified # Read as binary and force decode as UTF-8
to_version = request.args.get('to_version', str(dates[-1])) # Windows may fail decode in python if we just use 'r' mode (chardet decode exception)
from_version = request.args.get('from_version')
from_version_index = -2 # second newest
if from_version and from_version in dates:
from_version_index = dates.index(from_version)
else:
from_version = dates[from_version_index]
try: try:
to_version_file_contents = watch.get_history_snapshot(timestamp=to_version) from_version_file_contents = watch.get_history_snapshot(dates[from_version_index])
except Exception as e: except Exception as e:
logger.error(f"Unable to read watch history to-version for version {to_version}: {str(e)}") from_version_file_contents = f"Unable to read to-version at index {dates[from_version_index]}.\n"
to_version_file_contents = f"Unable to read to-version at {to_version}.\n"
to_version = request.args.get('to_version')
to_version_index = -1
if to_version and to_version in dates:
to_version_index = dates.index(to_version)
else:
to_version = dates[to_version_index]
try: try:
from_version_file_contents = watch.get_history_snapshot(timestamp=from_version) to_version_file_contents = watch.get_history_snapshot(dates[to_version_index])
except Exception as e: except Exception as e:
logger.error(f"Unable to read watch history from-version for version {from_version}: {str(e)}") to_version_file_contents = "Unable to read to-version at index{}.\n".format(dates[to_version_index])
from_version_file_contents = f"Unable to read to-version {from_version}.\n"
screenshot_url = watch.get_screenshot() screenshot_url = watch.get_screenshot()
@@ -168,8 +163,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False): if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access') password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access')
datastore.set_last_viewed(uuid, time.time())
output = render_template("diff.html", output = render_template("diff.html",
current_diff_url=watch['url'], current_diff_url=watch['url'],
from_version=str(from_version), from_version=str(from_version),

View File

@@ -4,7 +4,6 @@
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
<script>let nowtimeserver={{ now_time_server }};</script> <script>let nowtimeserver={{ now_time_server }};</script>
<script>let favicon_baseURL="{{ url_for('static_content', group='favicon', filename="PLACEHOLDER")}}";</script>
<script> <script>
// Initialize Feather icons after the page loads // Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -83,20 +82,14 @@ document.addEventListener('DOMContentLoaded', function() {
{%- endif -%} {%- endif -%}
<div id="watch-table-wrapper"> <div id="watch-table-wrapper">
{%- set table_classes = [
'favicon-enabled' if datastore.data['settings']['application']['ui'].get('favicons_enabled') else 'favicon-not-enabled', <table class="pure-table pure-table-striped watch-table">
] -%}
<table class="pure-table pure-table-striped watch-table {{ table_classes | reject('equalto', '') | join(' ') }}">
<thead> <thead>
<tr> <tr>
{%- set link_order = "desc" if sort_order == 'asc' else "asc" -%} {%- set link_order = "desc" if sort_order == 'asc' else "asc" -%}
{%- set arrow_span = "" -%} {%- set arrow_span = "" -%}
<th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}" href="{{url_for('watchlist.index', sort='date_created', order=link_order, tag=active_tag_uuid)}}"># <span class='arrow {{link_order}}'></span></a></th> <th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}" href="{{url_for('watchlist.index', sort='date_created', order=link_order, tag=active_tag_uuid)}}"># <span class='arrow {{link_order}}'></span></a></th>
<th> <th class="empty-cell"></th>
<a class="{{ 'active '+link_order if sort_attribute == 'paused' else 'inactive' }}" href="{{url_for('watchlist.index', sort='paused', order=link_order, tag=active_tag_uuid)}}"><i data-feather="pause" style="vertical-align: bottom; width: 14px; height: 14px; margin-right: 4px;"></i><span class='arrow {{link_order}}'></span></a>
&nbsp;
<a class="{{ 'active '+link_order if sort_attribute == 'notification_muted' else 'inactive' }}" href="{{url_for('watchlist.index', sort='notification_muted', order=link_order, tag=active_tag_uuid)}}"><i data-feather="volume-2" style="vertical-align: bottom; width: 14px; height: 14px; margin-right: 4px;"></i><span class='arrow {{link_order}}'></span></a>
</th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('watchlist.index', sort='label', order=link_order, tag=active_tag_uuid)}}">Website <span class='arrow {{link_order}}'></span></a></th> <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('watchlist.index', sort='label', order=link_order, tag=active_tag_uuid)}}">Website <span class='arrow {{link_order}}'></span></a></th>
{%- if any_has_restock_price_processor -%} {%- if any_has_restock_price_processor -%}
<th>Restock &amp; Price</th> <th>Restock &amp; Price</th>
@@ -112,11 +105,9 @@ document.addEventListener('DOMContentLoaded', function() {
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('imports.import_page')}}" >import a list</a>.</td> <td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('imports.import_page')}}" >import a list</a>.</td>
</tr> </tr>
{%- endif -%} {%- endif -%}
{%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%} {%- 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 checking_now = is_checking_now(watch) -%}
{%- set history_n = watch.history_n -%} {%- set history_n = watch.history_n -%}
{%- set favicon = watch.get_favicon_filename() -%}
{# Mirror in changedetectionio/static/js/realtime.js for the frontend #} {# Mirror in changedetectionio/static/js/realtime.js for the frontend #}
{%- set row_classes = [ {%- set row_classes = [
loop.cycle('pure-table-odd', 'pure-table-even'), loop.cycle('pure-table-odd', 'pure-table-even'),
@@ -125,63 +116,49 @@ document.addEventListener('DOMContentLoaded', function() {
'paused' if watch.paused is defined and watch.paused != False else '', 'paused' if watch.paused is defined and watch.paused != False else '',
'unviewed' if watch.has_unviewed else '', 'unviewed' if watch.has_unviewed else '',
'has-restock-info' if watch.has_restock_info else 'no-restock-info', 'has-restock-info' if watch.has_restock_info else 'no-restock-info',
'has-favicon' if favicon else '',
'in-stock' if watch.has_restock_info and watch['restock']['in_stock'] 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 '', 'not-in-stock' if watch.has_restock_info and not watch['restock']['in_stock'] else '',
'queued' if watch.uuid in queued_uuids else '', 'queued' if watch.uuid in queued_uuids else '',
'checking-now' if checking_now else '', 'checking-now' if checking_now else '',
'notification_muted' if watch.notification_muted else '', 'notification_muted' if watch.notification_muted else '',
'single-history' if history_n == 1 else '', 'single-history' if history_n == 1 else '',
'multiple-history' if history_n >= 2 else '', 'multiple-history' if history_n >= 2 else ''
] -%} ] -%}
<tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}"> <tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}">
<td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span class="counter-i">{{ loop.index+pagination.skip }}</span></div></td> <td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td>
<td class="inline watch-controls"> <td class="inline watch-controls">
<div>
<a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a> <a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a>
<a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a> <a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a>
<a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notification" title="Mute notification" class="icon icon-mute" ></a> <a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notification" title="Mute notification" class="icon icon-mute" ></a>
<a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="UnMute notification" title="UnMute notification" class="icon icon-mute" ></a> <a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="UnMute notification" title="UnMute notification" class="icon icon-mute" ></a>
</div>
</td> </td>
<td class="title-col inline">{{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:','') }}">&nbsp;</a>
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
<td class="title-col inline"> {%- if watch.get_fetch_backend == "html_webdriver"
<div class="flex-wrapper"> or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
{% if datastore.data['settings']['application']['ui'].get('favicons_enabled') %} or "extra_browser_" in watch.get_fetch_backend
<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" 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 %} /> <img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
</div> {%- endif -%}
{% endif %}
<div>
<span class="watch-title">
{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}&nbsp;<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a>
</span>
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div>
{%- if watch['processor'] == 'text_json_diff' -%}
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
{%- endif -%}
{%- endif -%}
{%- if watch['processor'] == 'restock_diff' -%}
<span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}" class="status-icon price-follow-tag-icon" > Price</span>
{%- endif -%}
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
<span class="watch-tag-list">{{ watch_tag.title }}</span>
{%- endfor -%}
</div>
<div class="status-icons">
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
{%- if watch.get_fetch_backend == "html_webdriver"
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
or "extra_browser_" in watch.get_fetch_backend
-%}
<img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
{%- endif -%}
{%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%}
{%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%}
</div> {%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%}
</div> {%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%}
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div>
{%- if watch['processor'] == 'text_json_diff' -%}
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
{%- endif -%}
{%- endif -%}
{%- if watch['processor'] == 'restock_diff' -%}
<span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}" class="status-icon price-follow-tag-icon" > Price</span>
{%- endif -%}
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
<span class="watch-tag-list">{{ watch_tag.title }}</span>
{%- endfor -%}
</td> </td>
{%- if any_has_restock_price_processor -%} {%- if any_has_restock_price_processor -%}
<td class="restock-and-price"> <td class="restock-and-price">
@@ -218,15 +195,13 @@ document.addEventListener('DOMContentLoaded', function() {
Not yet Not yet
{%- endif -%} {%- endif -%}
</td> </td>
<td class="buttons"> <td>
<div>
{%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%} {%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}
<a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a> <a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a> <a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a> <a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a> <a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a>
<a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a> <a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a>
</div>
</td> </td>
</tr> </tr>
{%- endfor -%} {%- endfor -%}

View File

@@ -1,3 +1,5 @@
from flask import Blueprint
from json_logic.builtins import BUILTINS from json_logic.builtins import BUILTINS
from .exceptions import EmptyConditionRuleRowNotUsable from .exceptions import EmptyConditionRuleRowNotUsable

View File

@@ -1,8 +1,6 @@
import pluggy import pluggy
from loguru import logger from loguru import logger
LEVENSHTEIN_MAX_LEN_FOR_EDIT_STATS=100000
# Support both plugin systems # Support both plugin systems
conditions_hookimpl = pluggy.HookimplMarker("changedetectionio_conditions") conditions_hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
global_hookimpl = pluggy.HookimplMarker("changedetectionio") global_hookimpl = pluggy.HookimplMarker("changedetectionio")
@@ -74,17 +72,7 @@ def ui_edit_stats_extras(watch):
"""Generate the HTML for Levenshtein stats - shared by both plugin systems""" """Generate the HTML for Levenshtein stats - shared by both plugin systems"""
if len(watch.history.keys()) < 2: if len(watch.history.keys()) < 2:
return "<p>Not enough history to calculate Levenshtein metrics</p>" return "<p>Not enough history to calculate Levenshtein metrics</p>"
# Protection against the algorithm getting stuck on huge documents
k = list(watch.history.keys())
if any(
len(watch.get_history_snapshot(timestamp=k[idx])) > LEVENSHTEIN_MAX_LEN_FOR_EDIT_STATS
for idx in (-1, -2)
if len(k) >= abs(idx)
):
return "<p>Snapshot too large for edit statistics, skipping.</p>"
try: try:
lev_data = levenshtein_ratio_recent_history(watch) lev_data = levenshtein_ratio_recent_history(watch)
if not lev_data or not isinstance(lev_data, dict): if not lev_data or not isinstance(lev_data, dict):

View File

@@ -28,7 +28,6 @@ from changedetectionio.content_fetchers.requests import fetcher as html_requests
import importlib.resources import importlib.resources
XPATH_ELEMENT_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8') XPATH_ELEMENT_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
INSTOCK_DATA_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8') INSTOCK_DATA_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
FAVICON_FETCHER_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('favicon-fetcher.js').read_text(encoding='utf-8')
def available_fetchers(): def available_fetchers():

View File

@@ -48,7 +48,6 @@ class Fetcher():
error = None error = None
fetcher_description = "No description" fetcher_description = "No description"
headers = {} headers = {}
favicon_blob = None
instock_data = None instock_data = None
instock_data_js = "" instock_data_js = ""
status_code = None status_code = None

View File

@@ -5,7 +5,7 @@ from urllib.parse import urlparse
from loguru import logger from loguru import logger
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \ from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, FAVICON_FETCHER_JS SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable
@@ -234,12 +234,6 @@ class fetcher(Fetcher):
await browser.close() await browser.close()
raise PageUnloadable(url=url, status_code=None, message=str(e)) raise PageUnloadable(url=url, status_code=None, message=str(e))
try:
self.favicon_blob = await self.page.evaluate(FAVICON_FETCHER_JS)
await self.page.request_gc()
except Exception as e:
logger.error(f"Error fetching FavIcon info {str(e)}, continuing.")
if self.status_code != 200 and not ignore_status_codes: if self.status_code != 200 and not ignore_status_codes:
screenshot = await capture_full_page_async(self.page) screenshot = await capture_full_page_async(self.page)
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot) raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
@@ -280,7 +274,6 @@ class fetcher(Fetcher):
await self.page.request_gc() await self.page.request_gc()
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s") logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
# Bug 3 in Playwright screenshot handling # Bug 3 in Playwright screenshot handling
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
# JPEG is better here because the screenshots can be very very large # JPEG is better here because the screenshots can be very very large

View File

@@ -8,7 +8,7 @@ from loguru import logger
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \ from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \ SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \
SCREENSHOT_MAX_TOTAL_HEIGHT, FAVICON_FETCHER_JS SCREENSHOT_MAX_TOTAL_HEIGHT
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \ from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \
BrowserConnectError BrowserConnectError
@@ -179,8 +179,10 @@ class fetcher(Fetcher):
except Exception as e: except Exception as e:
raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'") raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'")
# more reliable is to just request a new page # Better is to launch chrome with the URL as arg
self.page = await browser.newPage() # non-headless - newPage() will launch an extra tab/window, .browser should already contain 1 page/tab
# headless - ask a new page
self.page = (pages := await browser.pages) and len(pages) or await browser.newPage()
if '--window-size' in self.browser_connection_url: if '--window-size' in self.browser_connection_url:
# Be sure the viewport is always the window-size, this is often not the same thing # Be sure the viewport is always the window-size, this is often not the same thing
@@ -290,11 +292,6 @@ class fetcher(Fetcher):
await browser.close() await browser.close()
raise PageUnloadable(url=url, status_code=None, message=str(e)) raise PageUnloadable(url=url, status_code=None, message=str(e))
try:
self.favicon_blob = await self.page.evaluate(FAVICON_FETCHER_JS)
except Exception as e:
logger.error(f"Error fetching FavIcon info {str(e)}, continuing.")
if self.status_code != 200 and not ignore_status_codes: if self.status_code != 200 and not ignore_status_codes:
screenshot = await capture_full_page(page=self.page) screenshot = await capture_full_page(page=self.page)

View File

@@ -1,79 +0,0 @@
(async () => {
const links = Array.from(document.querySelectorAll(
'link[rel~="apple-touch-icon"], link[rel~="icon"]'
));
const icons = links.map(link => {
const sizesStr = link.getAttribute('sizes');
let size = 0;
if (sizesStr) {
const [w] = sizesStr.split('x').map(Number);
if (!isNaN(w)) size = w;
} else {
size = 16;
}
return {
size,
rel: link.getAttribute('rel'),
href: link.href
};
});
// If no icons found, add fallback favicon.ico
if (icons.length === 0) {
icons.push({
size: 16,
rel: 'icon',
href: '/favicon.ico'
});
}
// sort preference
icons.sort((a, b) => {
const isAppleA = /apple-touch-icon/.test(a.rel);
const isAppleB = /apple-touch-icon/.test(b.rel);
if (isAppleA && !isAppleB) return -1;
if (!isAppleA && isAppleB) return 1;
return b.size - a.size;
});
const timeoutMs = 2000;
for (const icon of icons) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const resp = await fetch(icon.href, {
signal: controller.signal,
redirect: 'follow'
});
clearTimeout(timeout);
if (!resp.ok) {
continue;
}
const blob = await resp.blob();
// Convert blob to base64
const reader = new FileReader();
return await new Promise(resolve => {
reader.onloadend = () => {
resolve({
url: icon.href,
base64: reader.result.split(",")[1]
});
};
reader.readAsDataURL(blob);
});
} catch (e) {
continue;
}
}
// nothing found
return null;
})();

View File

@@ -17,7 +17,6 @@ async () => {
'back in stock soon', 'back in stock soon',
'back-order or out of stock', 'back-order or out of stock',
'backordered', 'backordered',
'backorder',
'benachrichtigt mich', // notify me 'benachrichtigt mich', // notify me
'binnenkort leverbaar', // coming soon 'binnenkort leverbaar', // coming soon
'brak na stanie', 'brak na stanie',
@@ -40,7 +39,6 @@ async () => {
'mail me when available', 'mail me when available',
'message if back in stock', 'message if back in stock',
'mevcut değil', 'mevcut değil',
'more on order',
'nachricht bei', 'nachricht bei',
'nicht auf lager', 'nicht auf lager',
'nicht lagernd', 'nicht lagernd',

View File

@@ -19,10 +19,12 @@ from flask import (
Flask, Flask,
abort, abort,
flash, flash,
make_response,
redirect, redirect,
render_template, render_template,
request, request,
send_from_directory, send_from_directory,
session,
url_for, url_for,
) )
from flask_compress import Compress as FlaskCompress from flask_compress import Compress as FlaskCompress
@@ -38,7 +40,7 @@ from loguru import logger
from changedetectionio import __version__ from changedetectionio import __version__
from changedetectionio import queuedWatchMetaData from changedetectionio import queuedWatchMetaData
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications
from changedetectionio.api.Search import Search from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule from .time_handler import is_within_schedule
@@ -305,9 +307,7 @@ def changedetection_app(config=None, datastore_o=None):
watch_api.add_resource(WatchSingleHistory, watch_api.add_resource(WatchSingleHistory,
'/api/v1/watch/<string:uuid>/history/<string:timestamp>', '/api/v1/watch/<string:uuid>/history/<string:timestamp>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
watch_api.add_resource(WatchFavicon,
'/api/v1/watch/<string:uuid>/favicon',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(WatchHistory, watch_api.add_resource(WatchHistory,
'/api/v1/watch/<string:uuid>/history', '/api/v1/watch/<string:uuid>/history',
resource_class_kwargs={'datastore': datastore}) resource_class_kwargs={'datastore': datastore})
@@ -427,32 +427,6 @@ def changedetection_app(config=None, datastore_o=None):
except FileNotFoundError: except FileNotFoundError:
abort(404) abort(404)
if group == 'favicon':
# Could be sensitive, follow password requirements
if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
abort(403)
# Get the watch object
watch = datastore.data['watching'].get(filename)
if not watch:
abort(404)
favicon_filename = watch.get_favicon_filename()
if 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
return response
if group == 'visual_selector_data': if group == 'visual_selector_data':
# Could be sensitive, follow password requirements # Could be sensitive, follow password requirements

View File

@@ -740,7 +740,6 @@ class globalSettingsRequestForm(Form):
class globalSettingsApplicationUIForm(Form): class globalSettingsApplicationUIForm(Form):
open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()]) 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()]) 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'].. # datastore.data['settings']['application']..
class globalSettingsApplicationForm(commonSettingsForm): class globalSettingsApplicationForm(commonSettingsForm):

View File

@@ -62,8 +62,7 @@ class model(dict):
'timezone': None, # Default IANA timezone name 'timezone': None, # Default IANA timezone name
'ui': { 'ui': {
'open_diff_in_new_tab': True, 'open_diff_in_new_tab': True,
'socket_io_enabled': True, 'socket_io_enabled': True
'favicons_enabled': True
}, },
} }
} }

View File

@@ -103,13 +103,6 @@ class model(watch_base):
return 'DISABLED' return 'DISABLED'
return ready_url return ready_url
@property
def domain_only_from_link(self):
from urllib.parse import urlparse
parsed = urlparse(self.link)
domain = parsed.hostname
return domain
def clear_watch(self): def clear_watch(self):
import pathlib import pathlib
@@ -420,132 +413,6 @@ class model(watch_base):
# False is not an option for AppRise, must be type None # False is not an option for AppRise, must be type None
return None return None
def bump_favicon(self, url, favicon_base_64: str) -> None:
from urllib.parse import urlparse
import base64
import binascii
decoded = None
if url:
try:
parsed = urlparse(url)
filename = os.path.basename(parsed.path)
(base, extension) = filename.lower().strip().rsplit('.', 1)
except ValueError:
logger.error(f"UUID: {self.get('uuid')} Cant work out file extension from '{url}'")
return None
else:
# Assume favicon.ico
base = "favicon"
extension = "ico"
fname = os.path.join(self.watch_data_dir, f"favicon.{extension}")
try:
# validate=True makes sure the string only contains valid base64 chars
decoded = base64.b64decode(favicon_base_64, validate=True)
except (binascii.Error, ValueError) as e:
logger.warning(f"UUID: {self.get('uuid')} FavIcon save data (Base64) corrupt? {str(e)}")
else:
if decoded:
try:
with open(fname, 'wb') as f:
f.write(decoded)
# A signal that could trigger the socket server to update the browser also
watch_check_update = signal('watch_favicon_bump')
if watch_check_update:
watch_check_update.send(watch_uuid=self.get('uuid'))
except Exception as e:
logger.warning(f"UUID: {self.get('uuid')} error saving FavIcon to {fname} - {str(e)}")
# @todo - Store some checksum and only write when its different
logger.debug(f"UUID: {self.get('uuid')} updated favicon to at {fname}")
def get_favicon_filename(self) -> str | None:
"""
Find any favicon.* file in the current working directory
and return the contents of the newest one.
Returns:
bytes: Contents of the newest favicon file, or None if not found.
"""
import glob
# Search for all favicon.* files
files = glob.glob(os.path.join(self.watch_data_dir, "favicon.*"))
if not files:
return None
# Find the newest by modification time
newest_file = max(files, key=os.path.getmtime)
return os.path.basename(newest_file)
def get_screenshot_as_thumbnail(self, max_age=3200):
"""Return path to a square thumbnail of the most recent screenshot.
Creates a 150x150 pixel thumbnail from the top portion of the screenshot.
Args:
max_age: Maximum age in seconds before recreating thumbnail
Returns:
Path to thumbnail or None if no screenshot exists
"""
import os
import time
thumbnail_path = os.path.join(self.watch_data_dir, "thumbnail.jpeg")
top_trim = 500 # Pixels from top of screenshot to use
screenshot_path = self.get_screenshot()
if not screenshot_path:
return None
# Reuse thumbnail if it's fresh and screenshot hasn't changed
if os.path.isfile(thumbnail_path):
thumbnail_mtime = os.path.getmtime(thumbnail_path)
screenshot_mtime = os.path.getmtime(screenshot_path)
if screenshot_mtime <= thumbnail_mtime and time.time() - thumbnail_mtime < max_age:
return thumbnail_path
try:
from PIL import Image
with Image.open(screenshot_path) as img:
# Crop top portion first (full width, top_trim height)
top_crop_height = min(top_trim, img.height)
img = img.crop((0, 0, img.width, top_crop_height))
# Create a smaller intermediate image (to reduce memory usage)
aspect = img.width / img.height
interim_width = min(top_trim, img.width)
interim_height = int(interim_width / aspect) if aspect > 0 else top_trim
img = img.resize((interim_width, interim_height), Image.NEAREST)
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
# Crop to square from top center
square_size = min(img.width, img.height)
left = (img.width - square_size) // 2
img = img.crop((left, 0, left + square_size, square_size))
# Final resize to exact thumbnail size with better filter
img = img.resize((350, 350), Image.BILINEAR)
# Save with optimized settings
img.save(thumbnail_path, "JPEG", quality=75, optimize=True)
return thumbnail_path
except Exception as e:
logger.error(f"Error creating thumbnail for {self.get('uuid')}: {str(e)}")
return None
def __get_file_ctime(self, filename): def __get_file_ctime(self, filename):
fname = os.path.join(self.watch_data_dir, filename) fname = os.path.join(self.watch_data_dir, filename)
if os.path.isfile(fname): if os.path.isfile(fname):

View File

@@ -29,9 +29,6 @@ class SignalHandler:
watch_delete_signal = signal('watch_deleted') watch_delete_signal = signal('watch_deleted')
watch_delete_signal.connect(self.handle_deleted_signal, weak=False) watch_delete_signal.connect(self.handle_deleted_signal, weak=False)
watch_favicon_bumped_signal = signal('watch_favicon_bump')
watch_favicon_bumped_signal.connect(self.handle_watch_bumped_favicon_signal, weak=False)
# Connect to the notification_event signal # Connect to the notification_event signal
notification_event_signal = signal('notification_event') notification_event_signal = signal('notification_event')
notification_event_signal.connect(self.handle_notification_event, weak=False) notification_event_signal.connect(self.handle_notification_event, weak=False)
@@ -40,7 +37,7 @@ class SignalHandler:
# Create and start the queue update thread using standard threading # Create and start the queue update thread using standard threading
import threading import threading
self.polling_emitter_thread = threading.Thread( self.polling_emitter_thread = threading.Thread(
target=self.polling_emit_running_or_queued_watches_threaded, target=self.polling_emit_running_or_queued_watches_threaded,
daemon=True daemon=True
) )
self.polling_emitter_thread.start() self.polling_emitter_thread.start()
@@ -72,16 +69,6 @@ class SignalHandler:
else: else:
logger.warning(f"Watch UUID {watch_uuid} not found in datastore") logger.warning(f"Watch UUID {watch_uuid} not found in datastore")
def handle_watch_bumped_favicon_signal(self, *args, **kwargs):
watch_uuid = kwargs.get('watch_uuid')
if watch_uuid:
# Emit the queue size to all connected clients
self.socketio_instance.emit("watch_bumped_favicon", {
"uuid": watch_uuid,
"event_timestamp": time.time()
})
logger.debug(f"Watch UUID {watch_uuid} got its favicon updated")
def handle_deleted_signal(self, *args, **kwargs): def handle_deleted_signal(self, *args, **kwargs):
watch_uuid = kwargs.get('watch_uuid') watch_uuid = kwargs.get('watch_uuid')
if watch_uuid: if watch_uuid:
@@ -118,38 +105,39 @@ class SignalHandler:
"watch_uuid": watch_uuid, "watch_uuid": watch_uuid,
"event_timestamp": time.time() "event_timestamp": time.time()
}) })
logger.trace(f"Socket.IO: Emitted notification_event for watch UUID {watch_uuid}") logger.trace(f"Socket.IO: Emitted notification_event for watch UUID {watch_uuid}")
except Exception as e: except Exception as e:
logger.error(f"Socket.IO error in handle_notification_event: {str(e)}") logger.error(f"Socket.IO error in handle_notification_event: {str(e)}")
def polling_emit_running_or_queued_watches_threaded(self): def polling_emit_running_or_queued_watches_threaded(self):
"""Threading version of polling for Windows compatibility""" """Threading version of polling for Windows compatibility"""
import time import time
import threading import threading
logger.info("Queue update thread started (threading mode)") logger.info("Queue update thread started (threading mode)")
# Import here to avoid circular imports # Import here to avoid circular imports
from changedetectionio.flask_app import app from changedetectionio.flask_app import app
from changedetectionio import worker_handler from changedetectionio import worker_handler
watch_check_update = signal('watch_check_update') watch_check_update = signal('watch_check_update')
# Track previous state to avoid unnecessary emissions # Track previous state to avoid unnecessary emissions
previous_running_uuids = set() previous_running_uuids = set()
# Run until app shutdown - check exit flag more frequently for fast shutdown # Run until app shutdown - check exit flag more frequently for fast shutdown
exit_event = getattr(app.config, 'exit', threading.Event()) exit_event = getattr(app.config, 'exit', threading.Event())
while not exit_event.is_set(): while not exit_event.is_set():
try: try:
# Get current running UUIDs from async workers # Get current running UUIDs from async workers
running_uuids = set(worker_handler.get_running_uuids()) running_uuids = set(worker_handler.get_running_uuids())
# Only send updates for UUIDs that changed state # Only send updates for UUIDs that changed state
newly_running = running_uuids - previous_running_uuids newly_running = running_uuids - previous_running_uuids
no_longer_running = previous_running_uuids - running_uuids no_longer_running = previous_running_uuids - running_uuids
# Send updates for newly running UUIDs (but exit fast if shutdown requested) # Send updates for newly running UUIDs (but exit fast if shutdown requested)
for uuid in newly_running: for uuid in newly_running:
if exit_event.is_set(): if exit_event.is_set():
@@ -158,7 +146,7 @@ class SignalHandler:
with app.app_context(): with app.app_context():
watch_check_update.send(app_context=app, watch_uuid=uuid) watch_check_update.send(app_context=app, watch_uuid=uuid)
time.sleep(0.01) # Small yield time.sleep(0.01) # Small yield
# Send updates for UUIDs that finished processing (but exit fast if shutdown requested) # Send updates for UUIDs that finished processing (but exit fast if shutdown requested)
if not exit_event.is_set(): if not exit_event.is_set():
for uuid in no_longer_running: for uuid in no_longer_running:
@@ -168,16 +156,16 @@ class SignalHandler:
with app.app_context(): with app.app_context():
watch_check_update.send(app_context=app, watch_uuid=uuid) watch_check_update.send(app_context=app, watch_uuid=uuid)
time.sleep(0.01) # Small yield time.sleep(0.01) # Small yield
# Update tracking for next iteration # Update tracking for next iteration
previous_running_uuids = running_uuids previous_running_uuids = running_uuids
# Sleep between polling cycles, but check exit flag every 0.5 seconds for fast shutdown # Sleep between polling cycles, but check exit flag every 0.5 seconds for fast shutdown
for _ in range(20): # 20 * 0.5 = 10 seconds total for _ in range(20): # 20 * 0.5 = 10 seconds total
if exit_event.is_set(): if exit_event.is_set():
break break
time.sleep(0.5) time.sleep(0.5)
except Exception as e: except Exception as e:
logger.error(f"Error in threading polling: {str(e)}") logger.error(f"Error in threading polling: {str(e)}")
# Even during error recovery, check for exit quickly # Even during error recovery, check for exit quickly
@@ -185,11 +173,11 @@ class SignalHandler:
if exit_event.is_set(): if exit_event.is_set():
break break
time.sleep(0.5) time.sleep(0.5)
# Check if we're in pytest environment - if so, be more gentle with logging # Check if we're in pytest environment - if so, be more gentle with logging
import sys import sys
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
if not in_pytest: if not in_pytest:
logger.info("Queue update thread stopped (threading mode)") logger.info("Queue update thread stopped (threading mode)")
@@ -220,20 +208,20 @@ def handle_watch_update(socketio, **kwargs):
watch_data = { watch_data = {
'checking_now': True if watch.get('uuid') in running_uuids else False, 'checking_now': True if watch.get('uuid') in running_uuids else False,
'error_text': error_texts,
'event_timestamp': time.time(),
'fetch_time': watch.get('fetch_time'), 'fetch_time': watch.get('fetch_time'),
'has_error': True if error_texts else False, 'has_error': True if error_texts else False,
'has_favicon': True if watch.get_favicon_filename() else False, 'last_changed': watch.get('last_changed'),
'history_n': watch.history_n,
'last_changed_text': timeago.format(int(watch.last_changed), time.time()) if watch.history_n >= 2 and int(watch.last_changed) > 0 else 'Not yet',
'last_checked': watch.get('last_checked'), 'last_checked': watch.get('last_checked'),
'error_text': error_texts,
'history_n': watch.history_n,
'last_checked_text': _jinja2_filter_datetime(watch), 'last_checked_text': _jinja2_filter_datetime(watch),
'notification_muted': True if watch.get('notification_muted') else False, 'last_changed_text': timeago.format(int(watch.last_changed), time.time()) if watch.history_n >= 2 and int(watch.last_changed) > 0 else 'Not yet',
'paused': True if watch.get('paused') else False,
'queued': True if watch.get('uuid') in queue_list else False, 'queued': True if watch.get('uuid') in queue_list else False,
'paused': True if watch.get('paused') else False,
'notification_muted': True if watch.get('notification_muted') else False,
'unviewed': watch.has_unviewed, 'unviewed': watch.has_unviewed,
'uuid': watch.get('uuid'), 'uuid': watch.get('uuid'),
'event_timestamp': time.time()
} }
errored_count = 0 errored_count = 0
@@ -263,15 +251,15 @@ def init_socketio(app, datastore):
"""Initialize SocketIO with the main Flask app""" """Initialize SocketIO with the main Flask app"""
import platform import platform
import sys import sys
# Platform-specific async_mode selection for better stability # Platform-specific async_mode selection for better stability
system = platform.system().lower() system = platform.system().lower()
python_version = sys.version_info python_version = sys.version_info
# Check for SocketIO mode configuration via environment variable # Check for SocketIO mode configuration via environment variable
# Default is 'threading' for best cross-platform compatibility # Default is 'threading' for best cross-platform compatibility
socketio_mode = os.getenv('SOCKETIO_MODE', 'threading').lower() socketio_mode = os.getenv('SOCKETIO_MODE', 'threading').lower()
if socketio_mode == 'gevent': if socketio_mode == 'gevent':
# Use gevent mode (higher concurrency but platform limitations) # Use gevent mode (higher concurrency but platform limitations)
try: try:
@@ -289,7 +277,7 @@ def init_socketio(app, datastore):
# Invalid mode specified, use default # Invalid mode specified, use default
async_mode = 'threading' async_mode = 'threading'
logger.warning(f"Invalid SOCKETIO_MODE='{socketio_mode}', using default {async_mode} mode for Socket.IO") logger.warning(f"Invalid SOCKETIO_MODE='{socketio_mode}', using default {async_mode} mode for Socket.IO")
# Log platform info for debugging # Log platform info for debugging
logger.info(f"Platform: {system}, Python: {python_version.major}.{python_version.minor}, Socket.IO mode: {async_mode}") logger.info(f"Platform: {system}, Python: {python_version.major}.{python_version.minor}, Socket.IO mode: {async_mode}")
@@ -327,6 +315,7 @@ def init_socketio(app, datastore):
emit_flash=False emit_flash=False
) )
@socketio.on('connect') @socketio.on('connect')
def handle_connect(): def handle_connect():
"""Handle client connection""" """Handle client connection"""
@@ -404,4 +393,4 @@ def init_socketio(app, datastore):
logger.info("Socket.IO initialized and attached to main Flask app") logger.info("Socket.IO initialized and attached to main Flask app")
logger.info(f"Socket.IO: Registered event handlers: {socketio.handlers if hasattr(socketio, 'handlers') else 'No handlers found'}") logger.info(f"Socket.IO: Registered event handlers: {socketio.handlers if hasattr(socketio, 'handlers') else 'No handlers found'}")
return socketio return socketio

View File

@@ -159,7 +159,6 @@
// Return the current request in case it's needed // Return the current request in case it's needed
return requests[namespace]; return requests[namespace];
}; };
})(jQuery); })(jQuery);

View File

@@ -104,18 +104,8 @@ $(document).ready(function () {
}); });
}); });
// So that the favicon is only updated when the server has written the scraped favicon to disk. // Listen for periodically emitted watch data
socket.on('watch_bumped_favicon', function (watch) { console.log('Adding watch_update event listener');
const $watchRow = $(`tr[data-watch-uuid="${watch.uuid}"]`);
if ($watchRow.length) {
$watchRow.addClass('has-favicon');
// Because the event could be emitted from a process that is outside the app context, url_for() might not work.
// Lets use url_for at template generation time to give us a PLACEHOLDER instead
let favicon_url = favicon_baseURL.replace('/PLACEHOLDER', `/${watch.uuid}?cache=${watch.event_timestamp}`);
console.log(`Setting favicon for UUID - ${watch.uuid} - ${favicon_url}`);
$('img.favicon', $watchRow).attr('src', favicon_url);
}
})
socket.on('watch_update', function (data) { socket.on('watch_update', function (data) {
const watch = data.watch; const watch = data.watch;
@@ -126,28 +116,29 @@ $(document).ready(function () {
console.log(`${watch.event_timestamp} - Watch update ${watch.uuid} - Checking now - ${watch.checking_now} - UUID in URL ${window.location.href.includes(watch.uuid)}`); console.log(`${watch.event_timestamp} - Watch update ${watch.uuid} - Checking now - ${watch.checking_now} - UUID in URL ${window.location.href.includes(watch.uuid)}`);
console.log('Watch data:', watch); console.log('Watch data:', watch);
console.log('General stats:', general_stats); console.log('General stats:', general_stats);
// Updating watch table rows // Updating watch table rows
const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]'); const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]');
console.log('Found watch row elements:', $watchRow.length); console.log('Found watch row elements:', $watchRow.length);
if ($watchRow.length) { if ($watchRow.length) {
$($watchRow).toggleClass('checking-now', watch.checking_now); $($watchRow).toggleClass('checking-now', watch.checking_now);
$($watchRow).toggleClass('queued', watch.queued); $($watchRow).toggleClass('queued', watch.queued);
$($watchRow).toggleClass('unviewed', watch.unviewed); $($watchRow).toggleClass('unviewed', watch.unviewed);
$($watchRow).toggleClass('has-error', watch.has_error); $($watchRow).toggleClass('has-error', watch.has_error);
$($watchRow).toggleClass('has-favicon', watch.has_favicon);
$($watchRow).toggleClass('notification_muted', watch.notification_muted); $($watchRow).toggleClass('notification_muted', watch.notification_muted);
$($watchRow).toggleClass('paused', watch.paused); $($watchRow).toggleClass('paused', watch.paused);
$($watchRow).toggleClass('single-history', watch.history_n === 1); $($watchRow).toggleClass('single-history', watch.history_n === 1);
$($watchRow).toggleClass('multiple-history', watch.history_n >= 2); $($watchRow).toggleClass('multiple-history', watch.history_n >= 2);
$('td.title-col .error-text', $watchRow).html(watch.error_text) $('td.title-col .error-text', $watchRow).html(watch.error_text)
$('td.last-changed', $watchRow).text(watch.last_changed_text) $('td.last-changed', $watchRow).text(watch.last_changed_text)
$('td.last-checked .innertext', $watchRow).text(watch.last_checked_text) $('td.last-checked .innertext', $watchRow).text(watch.last_checked_text)
$('td.last-checked', $watchRow).data('timestamp', watch.last_checked).data('fetchduration', watch.fetch_time); $('td.last-checked', $watchRow).data('timestamp', watch.last_checked).data('fetchduration', watch.fetch_time);
$('td.last-checked', $watchRow).data('eta_complete', watch.last_checked + watch.fetch_time); $('td.last-checked', $watchRow).data('eta_complete', watch.last_checked + watch.fetch_time);
console.log('Updated UI for watch:', watch.uuid); console.log('Updated UI for watch:', watch.uuid);
} }

File diff suppressed because one or more lines are too long

View File

@@ -3,16 +3,15 @@
"version": "0.0.3", "version": "0.0.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"engines": {
"node": ">=18.0.0"
},
"scripts": { "scripts": {
"watch": "sass --watch scss:. --style=compressed --no-source-map", "watch": "node-sass -w scss -o .",
"build": "sass scss:. --style=compressed --no-source-map" "build": "node-sass scss -o ."
}, },
"author": "Leigh Morresi / Web Technologies s.r.o.", "author": "",
"license": "Apache", "license": "ISC",
"dependencies": { "dependencies": {
"sass": "^1.77.8" "node-sass": "^7.0.0",
"tar": "^6.1.9",
"trim-newlines": "^3.0.1"
} }
} }

View File

@@ -1,4 +1,4 @@
@use "parts/variables"; @import "parts/_variables.scss";
#diff-ui { #diff-ui {

View File

@@ -64,17 +64,17 @@ body.proxy-check-active {
#recommended-proxy { #recommended-proxy {
display: grid; display: grid;
gap: 2rem; gap: 2rem;
padding-bottom: 1em; @media (min-width: 991px) {
grid-template-columns: repeat(2, 1fr);
@media (min-width: 991px) { }
grid-template-columns: repeat(2, 1fr);
}
> div { > div {
border: 1px #aaa solid; border: 1px #aaa solid;
border-radius: 4px; border-radius: 4px;
padding: 1em; padding: 1em;
} }
padding-bottom: 1em;
} }
#extra-proxies-setting { #extra-proxies-setting {

View File

@@ -1,92 +0,0 @@
.watch-table {
&.favicon-not-enabled {
tr {
.favicon {
display: none;
}
}
}
&.favicon-enabled {
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 {
&.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 */
> * {
vertical-align: middle;
}
}
}
.title-col {
/* Optional, for spacing */
padding: 10px;
}
.title-wrapper {
display: flex;
align-items: center; /* Vertical centering */
gap: 10px; /* Space between image and text */
}
/* Make sure .title-col-inner doesn't collapse or misalign */
.title-col-inner {
display: inline-block;
vertical-align: middle;
}
/* favicon styling */
.watch-table {
img.favicon {
vertical-align: middle;
max-width: 25px;
max-height: 25px;
height: 25px;
padding-right: 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;
}
}
}
}*/
}

View File

@@ -1,4 +1,4 @@
@use "minitabs"; @import "minitabs";
body.preview-text-enabled { body.preview-text-enabled {

View File

@@ -1,178 +0,0 @@
$grid-col-checkbox: 20px;
$grid-col-watch: 100px;
$grid-gap: 0.5rem;
@media (max-width: 767px) {
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
.watch-table {
/* make headings work on mobile */
thead {
display: block;
tr {
th {
display: inline-block;
// Hide the "Last" text for smaller screens
@media (max-width: 768px) {
.hide-on-mobile {
display: none;
}
}
}
}
.empty-cell {
display: none;
}
}
.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 ";
}
.last-changed::before {
color: var(--color-text);
content: "Last Changed ";
}
/* Force table to not be like tables anymore */
td.inline {
display: inline-block;
}
.pure-table td,
.pure-table th {
border: none;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid var(--color-border-watch-table-cell);
vertical-align: middle;
&:before {
/* Top/left values mimic padding */
top: 6px;
left: 6px;
width: 45%;
padding-right: 10px;
white-space: nowrap;
}
}
&.pure-table-striped {
tr {
background-color: var(--color-table-background);
}
tr:nth-child(2n-1) {
background-color: var(--color-table-stripe);
}
tr:nth-child(2n-1) td {
background-color: inherit;
}
}
}
}
@media (max-width: 767px) {
.watch-table {
tbody {
tr {
padding-bottom: 10px;
padding-top: 10px;
display: grid;
grid-template-columns: $grid-col-checkbox 1fr $grid-col-watch;
grid-template-rows: auto auto auto auto;
gap: $grid-gap;
.counter-i {
display: none;
}
td.checkbox-uuid {
display: grid;
place-items: center;
}
td.inline {
/* display: block !important;;*/
}
> td {
border-bottom: none;
}
> td.title-col {
grid-column: 1 / -1;
grid-row: 1;
.watch-title {
font-size: 0.92rem;
}
.link-spread {
display: none;
}
}
> td.last-checked {
grid-column: 1 / -1;
grid-row: 2;
}
> td.last-changed {
grid-column: 1 / -1;
grid-row: 3;
}
> td.checkbox-uuid {
grid-column: 1;
grid-row: 4;
}
> td.buttons {
grid-column: 2;
grid-row: 4;
display: flex;
align-items: center;
justify-content: flex-start;
}
> td.watch-controls {
grid-column: 3;
grid-row: 4;
display: grid;
place-items: center;
a img {
padding: 10px;
}
}
}
}
}
.pure-table td {
padding: 3px !important;
}
}

View File

@@ -7,7 +7,6 @@
&.unviewed { &.unviewed {
font-weight: bold; font-weight: bold;
} }
color: var(--color-watch-table-row-text); color: var(--color-watch-table-row-text);
} }
@@ -49,17 +48,17 @@
/* Row with 'checking-now' */ /* Row with 'checking-now' */
tr.checking-now { tr.checking-now {
td:first-child { td:first-child {
position: relative; position: relative;
} }
td:first-child::before { td:first-child::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 3px; width: 3px;
background-color: #293eff; background-color: #293eff;
} }
td.last-checked { td.last-checked {
@@ -110,7 +109,6 @@
tr.has-error { tr.has-error {
color: var(--color-watch-table-error); color: var(--color-watch-table-error);
.error-text { .error-text {
display: block !important; display: block !important;
} }
@@ -121,7 +119,6 @@
display: inline-block !important; display: inline-block !important;
} }
} }
tr.multiple-history { tr.multiple-history {
a.history-link { a.history-link {
display: inline-block !important; display: inline-block !important;
@@ -129,3 +126,5 @@
} }
} }

View File

@@ -2,25 +2,21 @@
* -- BASE STYLES -- * -- BASE STYLES --
*/ */
@use "parts/variables"; @import "parts/_arrows";
@use "parts/arrows"; @import "parts/_browser-steps";
@use "parts/browser-steps"; @import "parts/_extra_proxies";
@use "parts/extra_proxies"; @import "parts/_extra_browsers";
@use "parts/extra_browsers"; @import "parts/_pagination";
@use "parts/pagination"; @import "parts/_spinners";
@use "parts/spinners"; @import "parts/_variables";
@use "parts/darkmode"; @import "parts/_darkmode";
@use "parts/menu"; @import "parts/_menu";
@use "parts/love"; @import "parts/_love";
@use "parts/preview_text_filter"; @import "parts/preview_text_filter";
@use "parts/watch_table"; @import "parts/_watch_table";
@use "parts/watch_table-mobile"; @import "parts/_edit";
@use "parts/edit"; @import "parts/_conditions_table";
@use "parts/conditions_table"; @import "parts/_socket";
@use "parts/lister_extra";
@use "parts/socket";
@use "parts/visualselector";
body { body {
color: var(--color-text); color: var(--color-text);
@@ -188,15 +184,9 @@ code {
@extend .inline-tag; @extend .inline-tag;
} }
@media (min-width: 768px) {
.box {
margin: 0 1em !important;
}
}
.box { .box {
max-width: 100%; max-width: 100%;
margin: 0 0.3em; margin: 0 1em;
flex-direction: column; flex-direction: column;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -704,6 +694,114 @@ footer {
width: 100%; width: 100%;
} }
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
.watch-table {
/* make headings work on mobile */
thead {
display: block;
tr {
th {
display: inline-block;
// Hide the "Last" text for smaller screens
@media (max-width: 768px) {
.hide-on-mobile {
display: none;
}
}
}
}
.empty-cell {
display: none;
}
}
/* Force table to not be like tables anymore */
tbody {
td,
tr {
display: block;
}
}
tbody {
tr {
display: flex;
flex-wrap: wrap;
// The third child of each row will take up the remaining space
// This is useful for the URL column, which should expand to fill the remaining space
:nth-child(3) {
flex-grow: 1;
}
// The last three children (from the end) of each row will take up the full width
// This is useful for the "Last Checked", "Last Changed", and the action buttons columns, which should each take up the full width
:nth-last-child(-n+3) {
flex-basis: 100%;
}
}
}
.last-checked {
>span {
vertical-align: middle;
}
}
.last-checked::before {
color: var(--color-last-checked);
content: "Last Checked ";
}
.last-changed::before {
color: var(--color-last-checked);
content: "Last Changed ";
}
/* Force table to not be like tables anymore */
td.inline {
display: inline-block;
}
.pure-table td,
.pure-table th {
border: none;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid var(--color-border-watch-table-cell);
vertical-align: middle;
&:before {
/* Top/left values mimic padding */
top: 6px;
left: 6px;
width: 45%;
padding-right: 10px;
white-space: nowrap;
}
}
&.pure-table-striped {
tr {
background-color: var(--color-table-background);
}
tr:nth-child(2n-1) {
background-color: var(--color-table-stripe);
}
tr:nth-child(2n-1) td {
background-color: inherit;
}
}
}
} }
.pure-table { .pure-table {
@@ -958,6 +1056,8 @@ ul {
} }
} }
@import "parts/_visualselector";
#webdriver_delay { #webdriver_delay {
width: 5em; width: 5em;
} }
@@ -1075,23 +1175,17 @@ ul {
#quick-watch-processor-type { #quick-watch-processor-type {
ul#processor { color: #fff;
color: #fff; ul {
padding-left: 0px; padding: 0.3rem;
li { li {
list-style: none; list-style: none;
font-size: 0.9rem; font-size: 0.9rem;
display: grid; > * {
grid-template-columns: auto 1fr; display: inline-block;
align-items: center; }
gap: 0.5rem;
margin-bottom: 0.5rem;
} }
} }
label, input {
padding: 0;
margin: 0;
}
} }
.restock-label { .restock-label {

File diff suppressed because one or more lines are too long

View File

@@ -44,8 +44,6 @@ class ChangeDetectionStore:
def __init__(self, datastore_path="/datastore", include_default_watches=True, version_tag="0.0.0"): def __init__(self, datastore_path="/datastore", include_default_watches=True, version_tag="0.0.0"):
# Should only be active for docker # Should only be active for docker
# logging.basicConfig(filename='/dev/stdout', level=logging.INFO) # logging.basicConfig(filename='/dev/stdout', level=logging.INFO)
from deepmerge import always_merger
self.__data = App.model() self.__data = App.model()
self.datastore_path = datastore_path self.datastore_path = datastore_path
self.json_store_path = os.path.join(self.datastore_path, "url-watches.json") self.json_store_path = os.path.join(self.datastore_path, "url-watches.json")
@@ -77,8 +75,14 @@ class ChangeDetectionStore:
self.__data['app_guid'] = from_disk['app_guid'] self.__data['app_guid'] = from_disk['app_guid']
if 'settings' in from_disk: if 'settings' in from_disk:
# update the modal with whats on disk if 'headers' in from_disk['settings']:
self.__data['settings'] = always_merger.merge(from_disk['settings'], self.__data['settings']) self.__data['settings']['headers'].update(from_disk['settings']['headers'])
if 'requests' in from_disk['settings']:
self.__data['settings']['requests'].update(from_disk['settings']['requests'])
if 'application' in from_disk['settings']:
self.__data['settings']['application'].update(from_disk['settings']['application'])
# Convert each existing watch back to the Watch.model object # Convert each existing watch back to the Watch.model object
for uuid, watch in self.__data['watching'].items(): for uuid, watch in self.__data['watching'].items():
@@ -408,7 +412,7 @@ class ChangeDetectionStore:
with open(self.json_store_path+".tmp", 'w') as json_file: with open(self.json_store_path+".tmp", 'w') as json_file:
# Use compact JSON in production for better performance # Use compact JSON in production for better performance
json.dump(data, json_file, indent=2) json.dump(data, json_file, indent=2)
os.replace(self.json_store_path+".tmp", self.json_store_path) os.replace(self.json_store_path+".tmp", self.json_store_path)
except Exception as e: except Exception as e:
logger.error(f"Error writing JSON!! (Main JSON file save was skipped) : {str(e)}") logger.error(f"Error writing JSON!! (Main JSON file save was skipped) : {str(e)}")

View File

@@ -2,24 +2,19 @@
import time import time
from flask import url_for from flask import url_for
import os
from ..util import live_server_setup, wait_for_all_checks from ..util import live_server_setup, wait_for_all_checks
import logging import logging
# Requires playwright to be installed # Requires playwright to be installed
def test_fetch_webdriver_content(client, live_server, measure_memory_usage): def test_fetch_webdriver_content(client, live_server, measure_memory_usage):
# live_server_setup(live_server) # Setup on conftest per function # live_server_setup(live_server) # Setup on conftest per function
##################### #####################
res = client.post( res = client.post(
url_for("settings.settings_page"), url_for("settings.settings_page"),
data={ data={"application-empty_pages_are_a_change": "",
"application-empty_pages_are_a_change": "", "requests-time_between_check-minutes": 180,
"requests-time_between_check-minutes": 180, 'application-fetch_backend': "html_webdriver"},
'application-fetch_backend': "html_webdriver",
'application-ui-favicons_enabled': "y",
},
follow_redirects=True follow_redirects=True
) )
@@ -35,51 +30,11 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage):
assert b"1 Imported" in res.data assert b"1 Imported" in res.data
wait_for_all_checks(client) wait_for_all_checks(client)
res = client.get( res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"), url_for("ui.ui_views.preview_page", uuid="first"),
follow_redirects=True follow_redirects=True
) )
logging.getLogger().info("Looking for correct fetched HTML (text) from server") logging.getLogger().info("Looking for correct fetched HTML (text) from server")
assert b'cool it works' in res.data assert b'cool it works' in res.data
# Favicon scraper check, favicon only so far is fetched when in browser mode (not requests mode)
if os.getenv("PLAYWRIGHT_DRIVER_URL"):
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
res = client.get(
url_for("watchlist.index"),
)
# The UI can access it here
assert f'src="/static/favicon/{uuid}'.encode('utf8') in res.data
# Attempt to fetch it, make sure that works
res = client.get(url_for('static_content', group='favicon', filename=uuid))
assert res.status_code == 200
assert len(res.data) > 10
# Check the API also returns it
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
res = client.get(
url_for("watchfavicon", uuid=uuid),
headers={'x-api-key': api_key}
)
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

View File

@@ -79,48 +79,3 @@ def test_consistent_history(client, live_server, measure_memory_usage):
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json') json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
with open(json_db_file, 'r') as f: with open(json_db_file, 'r') as f:
assert '"default"' not in f.read(), "'default' probably shouldnt be here, it came from when the 'default' Watch vars were accidently being saved" assert '"default"' not in f.read(), "'default' probably shouldnt be here, it came from when the 'default' Watch vars were accidently being saved"
def test_check_text_history_view(client, live_server):
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("<html>test-one</html>")
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("imports.import_page"),
data={"urls": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data
# Give the thread time to pick it up
wait_for_all_checks(client)
# Set second version, Make a change
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("<html>test-two</html>")
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
assert b'test-one' in res.data
assert b'test-two' in res.data
# Set third version, Make a change
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("<html>test-three</html>")
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# It should remember the last viewed time, so the first difference is not shown
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
assert b'test-three' in res.data
assert b'test-two' in res.data
assert b'test-one' not in res.data
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data

View File

@@ -419,20 +419,13 @@ def check_json_ext_filter(json_filter, client, live_server):
res = client.get(url_for("watchlist.index")) res = client.get(url_for("watchlist.index"))
assert b'unviewed' in res.data assert b'unviewed' in res.data
res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
# We should never see 'ForSale' because we are selecting on 'Sold' in the rule, # We should never see 'ForSale' because we are selecting on 'Sold' in the rule,
# But we should know it triggered ('unviewed' assert above) # But we should know it triggered ('unviewed' assert above)
assert b'ForSale' not in res.data assert b'ForSale' not in res.data
assert b'Sold' in res.data assert b'Sold' in res.data
# And the difference should have both?
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
assert b'ForSale' in res.data
assert b'Sold' in res.data
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data assert b'Deleted' in res.data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 171 KiB

View File

@@ -78,7 +78,6 @@ jq~=1.3; python_version >= "3.8" and sys_platform == "linux"
# playwright is installed at Dockerfile build time because it's not available on all platforms # playwright is installed at Dockerfile build time because it's not available on all platforms
pyppeteer-ng==2.0.0rc10 pyppeteer-ng==2.0.0rc10
deepmerge
pyppeteerstealth>=0.0.4 pyppeteerstealth>=0.0.4
@@ -118,9 +117,6 @@ price-parser
# flask_socket_io - incorrect package name, already have flask-socketio above # 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" !) # Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
tzdata tzdata