Compare commits

..

29 Commits

Author SHA1 Message Date
dgtlmoon
df9258a8f7 WIP
Some checks failed
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-24 00:23:05 +02:00
dgtlmoon
c070265668 UI - Edit - Live Preview - improve error handling 2025-07-16 16:11:36 +02:00
dgtlmoon
48921c878d Simplify the form a bit
Some checks failed
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-15 17:08:12 +02:00
dgtlmoon
44384386cc Merge branch 'master' into openai-integration 2025-07-15 16:42:59 +02:00
dgtlmoon
011fa3540e 0.50.7
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-07-15 13:28:15 +02:00
dgtlmoon
c3c3671f8b UI - Set default favicon, handle default 'not set' for new/updated installations
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-14 18:37:41 +02:00
dgtlmoon
5980bd9bcd UI - Set default favicon, offer option to disable favicons (#3316) 2025-07-14 18:13:16 +02:00
dgtlmoon
438871429c README - Updating screenshot (with better cropping) 2025-07-14 17:51:22 +02:00
dgtlmoon
173ce5bfa2 README - Updating screenshot 2025-07-14 17:49:51 +02:00
dgtlmoon
3513676bc6 Tweak styles 2025-07-14 15:57:34 +02:00
dgtlmoon
559b729475 WIP 2025-07-14 15:21:55 +02:00
dgtlmoon
8937df7b0b WIP 2025-07-14 15:10:01 +02:00
dgtlmoon
9a015041a5 WIP for LLM integration 2025-07-14 14:22:35 +02:00
dgtlmoon
106b1f85fa UI - Mobile CSS tweaks
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-12 23:08:03 +02:00
dgtlmoon
a5c7f343d0 UI - Mobile - Small tidyups for mobile use 2025-07-12 23:06:44 +02:00
dgtlmoon
401886bcda UI - CSS - Modernising stylesheet build 2025-07-12 22:50:55 +02:00
dgtlmoon
c66fca9de9 0.50.6 2025-07-12 21:52:04 +02:00
dgtlmoon
daee4c5c17 Favicon type detection - support for autodetecting mimetype for better reliability (#3308)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
2025-07-12 11:44:27 +02:00
dgtlmoon
af5d0b6963 Fixing ARMv7 docker image support for older devices (#3311)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-11 20:59:44 +02:00
dgtlmoon
f92dd81c8f UI - Favicons - Try /favicon.ico if no other was specified in the document
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-07-09 17:03:31 +02:00
dgtlmoon
55cdcfe3ea UI - Favicons - Realtime mode - Fixing small bug when favicon needed updating in realtime 2025-07-09 16:41:32 +02:00
dgtlmoon
2f7520a6c5 UI - Favicons in realtime update mode, update after favicon was written to disk only. 2025-07-09 15:47:30 +02:00
dgtlmoon
4fdc5d7da2 UI - Lazy load favicons so it doesnt block realtime and other operations 2025-07-09 15:31:26 +02:00
dgtlmoon
308f30b2e8 UI - Adding Favicon support to watch overview lister page + FavIcon API (#3196) 2025-07-09 15:16:22 +02:00
dgtlmoon
4fa2042d12 UI - Sort list by Running or Paused #3284 (#3294)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-04 11:15:04 +02:00
dgtlmoon
2a4e1bad4e Similarity condition - Skip generating stats for very large documents in the 'Edit' page (#3296)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-03 23:16:55 +02:00
dgtlmoon
8a317eead5 Refactor watch history/diff page time handling, fixing issue where the last time viewed was not set in the 'history' page automatically (#3293)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-07-03 14:05:57 +02:00
dsturzy
b58094877f Update stock-not-in-stock.js Added 'backorder' and 'more on order' 2025-07-03 12:38:27 +02:00
dgtlmoon
afe252126c Update README.md
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-06-29 14:39:19 +02:00
54 changed files with 1240 additions and 2136 deletions

View File

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

View File

@@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \
libxslt-dev \
make \
patch \
zlib1g-dev
RUN mkdir /install
@@ -53,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/*

View File

@@ -102,9 +102,7 @@ _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)
- 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 $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.
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.
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
__version__ = '0.50.5'
__version__ = '0.50.7'
from changedetectionio.strtobool import strtobool
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 worker_handler
from flask_restful import abort, Resource
from flask import request, make_response
from flask import request, make_response, send_from_directory
import validators
from . import auth
import copy
@@ -191,6 +191,47 @@ class WatchSingleHistory(Resource):
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):
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']
# Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
from .Tags import Tags, Tag
from .Import import Import
from .SystemInfo import SystemInfo

View File

@@ -353,6 +353,12 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
except Exception as e:
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),
'check_count': count})

View File

@@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_simple_field, render_button, render_time_schedule_form %}
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}";
@@ -23,6 +23,7 @@
<li class="tab"><a href="#fetching">Fetching</a></li>
<li class="tab"><a href="#filters">Global Filters</a></li>
<li class="tab"><a href="#ui-options">UI Options</a></li>
<li class="tab"><a href="#ai-options"><i data-feather="aperture" style="width: 14px; height: 14px; margin-right: 4px;"></i> AI</a></li>
<li class="tab"><a href="#api">API</a></li>
<li class="tab"><a href="#timedate">Time &amp Date</a></li>
<li class="tab"><a href="#proxies">CAPTCHA &amp; Proxies</a></li>
@@ -256,6 +257,29 @@ 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="ai-options">
<p><strong>New:</strong> click here (link to changedetection.io tutorial page) find out how to setup and example</p>
<br>
key fields should be some password type field so you can see its set but doesnt contain the key on view and doesnt lose it on save<br>
<div class="pure-control-group inline-radio">
{{ render_simple_field(form.application.form.ai.form.LLM_backend) }}
<span class="pure-form-message-inline">Preferred LLM connection</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ai.form.API_keys.form.openai) }}
<span class="pure-form-message-inline">Go here to read more about OpenAI integration</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ai.form.API_keys.form.gemini) }}
<span class="pure-form-message-inline">Go here to read more about Google Gemini integration</span>
</div>
</div>
<div class="tab-pane-inner" id="proxies">
<div id="recommended-proxy">

View File

@@ -25,7 +25,7 @@
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#general">General</a></li>
<li class="tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
<li class="tab"><a href="#filters-and-triggers">AI, Filters &amp; Triggers</a></li>
{% if extra_tab_content %}
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
{% endif %}

View File

@@ -312,8 +312,27 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'''For when viewing the "preview" of the rendered text from inside of Edit'''
from flask import jsonify
from changedetectionio.processors.text_json_diff import prepare_filter_prevew
result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore)
return jsonify(result)
watch = datastore.data["watching"].get(uuid)
if not watch:
return jsonify({
"error": "Watch not found",
"code": 400
}), 400
if not watch.history_n:
return jsonify({
"error": "Watch has empty history, at least one fetch of the page is required.",
"code": 400
}), 400
#
try:
result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore)
return jsonify(result)
except Exception as e:
return abort(500, str(e))
@edit_blueprint.route("/highlight_submit_ignore_url", methods=['POST'])
@login_optionally_required

View File

@@ -1,8 +1,7 @@
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 time
from copy import deepcopy
from loguru import logger
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
@@ -78,7 +77,42 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
return output
@views_blueprint.route("/diff/<string:uuid>", methods=['GET', 'POST'])
@views_blueprint.route("/diff/<string:uuid>", methods=['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
def diff_history_page(uuid):
from changedetectionio import forms
@@ -96,60 +130,31 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# For submission of requesting an extract
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
dates = list(history.keys())
if len(dates) < 2:
flash("Not enough saved change detection snapshots to produce a report.", "error")
return redirect(url_for('watchlist.index'))
# If a "from_version" was requested, then find it (or the closest one)
# Also set "from version" to be the closest version to the one that was last viewed.
# Save the current newest history as the most recently viewed
datastore.set_last_viewed(uuid, time.time())
best_last_viewed_timestamp = watch.get_from_version_based_on_last_viewed
from_version_timestamp = best_last_viewed_timestamp if best_last_viewed_timestamp else dates[-2]
from_version = request.args.get('from_version', from_version_timestamp )
# Read as binary and force decode as UTF-8
# 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]
# Use the current one if nothing was specified
to_version = request.args.get('to_version', str(dates[-1]))
try:
from_version_file_contents = watch.get_history_snapshot(dates[from_version_index])
to_version_file_contents = watch.get_history_snapshot(timestamp=to_version)
except Exception as e:
from_version_file_contents = f"Unable to read to-version at index {dates[from_version_index]}.\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]
logger.error(f"Unable to read watch history to-version for version {to_version}: {str(e)}")
to_version_file_contents = f"Unable to read to-version at {to_version}.\n"
try:
to_version_file_contents = watch.get_history_snapshot(dates[to_version_index])
from_version_file_contents = watch.get_history_snapshot(timestamp=from_version)
except Exception as e:
to_version_file_contents = "Unable to read to-version at index{}.\n".format(dates[to_version_index])
logger.error(f"Unable to read watch history from-version for version {from_version}: {str(e)}")
from_version_file_contents = f"Unable to read to-version {from_version}.\n"
screenshot_url = watch.get_screenshot()
@@ -163,6 +168,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
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')
datastore.set_last_viewed(uuid, time.time())
output = render_template("diff.html",
current_diff_url=watch['url'],
from_version=str(from_version),
@@ -205,7 +212,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
add_paused = request.form.get('edit_and_watch_submit_button') != None
processor = request.form.get('processor', 'text_json_diff')
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})
extras = {'paused': add_paused, 'processor': processor}
LLM_prompt = request.form.get('LLM_prompt', '').strip()
if LLM_prompt:
extras['LLM_prompt'] = LLM_prompt
extras['LLM_send_type'] = request.form.get('LLM_send_type', 'text')
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras=extras)
if new_uuid:
if add_paused:

View File

@@ -4,12 +4,8 @@
<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>let nowtimeserver={{ now_time_server }};</script>
<script>
// Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() {
feather.replace();
});
</script>
<script>let favicon_baseURL="{{ url_for('static_content', group='favicon', filename="PLACEHOLDER")}}";</script>
<style>
.checking-now .last-checked {
background-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.05) 40%, rgba(0,0,0,0.1) 100%);
@@ -30,8 +26,12 @@ document.addEventListener('DOMContentLoaded', function() {
{{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
</div>
<div id="watch-group-tag">
<i data-feather="tag" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>
{{ render_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="Watch group / tag", class="transparent-field") }}
</div>
{%- include 'edit/llm_prompt.html' -%}
<div id="quick-watch-processor-type">
{{ render_simple_field(form.processor) }}
</div>
@@ -80,16 +80,23 @@ 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" -%}
{%- 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 class="empty-cell"></th>
<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>
{%- if any_has_restock_price_processor -%}
<th>Restock &amp; Price</th>
@@ -105,9 +112,11 @@ 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>
</tr>
{%- endif -%}
{%- 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 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'),
@@ -116,49 +125,63 @@ 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 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 '',
'checking-now' if checking_now else '',
'notification_muted' if watch.notification_muted 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(' ') }}">
<td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td>
<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 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-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-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 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>
{%- 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 -%}
<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" 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}}&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 -%}
{%- 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 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>
</td>
{%- if any_has_restock_price_processor -%}
<td class="restock-and-price">
@@ -195,13 +218,15 @@ document.addEventListener('DOMContentLoaded', function() {
Not yet
{%- endif -%}
</td>
<td>
<td class="buttons">
<div>
{%- 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="{{ 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_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>
</div>
</td>
</tr>
{%- endfor -%}

View File

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

View File

@@ -1,6 +1,8 @@
import pluggy
from loguru import logger
LEVENSHTEIN_MAX_LEN_FOR_EDIT_STATS=100000
# Support both plugin systems
conditions_hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
global_hookimpl = pluggy.HookimplMarker("changedetectionio")
@@ -72,7 +74,17 @@ def ui_edit_stats_extras(watch):
"""Generate the HTML for Levenshtein stats - shared by both plugin systems"""
if len(watch.history.keys()) < 2:
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:
lev_data = levenshtein_ratio_recent_history(watch)
if not lev_data or not isinstance(lev_data, dict):

View File

@@ -28,6 +28,7 @@ from changedetectionio.content_fetchers.requests import fetcher as html_requests
import importlib.resources
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')
FAVICON_FETCHER_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('favicon-fetcher.js').read_text(encoding='utf-8')
def available_fetchers():

View File

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

View File

@@ -5,7 +5,7 @@ from urllib.parse import urlparse
from loguru import logger
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
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, FAVICON_FETCHER_JS
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable
@@ -234,6 +234,12 @@ class fetcher(Fetcher):
await browser.close()
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:
screenshot = await capture_full_page_async(self.page)
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
@@ -274,6 +280,7 @@ class fetcher(Fetcher):
await self.page.request_gc()
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
# 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
# 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, \
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \
SCREENSHOT_MAX_TOTAL_HEIGHT
SCREENSHOT_MAX_TOTAL_HEIGHT, FAVICON_FETCHER_JS
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \
BrowserConnectError
@@ -179,10 +179,8 @@ class fetcher(Fetcher):
except Exception as e:
raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'")
# Better is to launch chrome with the URL as arg
# 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()
# more reliable is to just request a new page
self.page = await browser.newPage()
if '--window-size' in self.browser_connection_url:
# Be sure the viewport is always the window-size, this is often not the same thing
@@ -292,6 +290,11 @@ class fetcher(Fetcher):
await browser.close()
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:
screenshot = await capture_full_page(page=self.page)

View File

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

View File

@@ -19,12 +19,10 @@ from flask import (
Flask,
abort,
flash,
make_response,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from flask_compress import Compress as FlaskCompress
@@ -40,7 +38,7 @@ from loguru import logger
from changedetectionio import __version__
from changedetectionio import queuedWatchMetaData
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon
from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule
@@ -307,7 +305,9 @@ def changedetection_app(config=None, datastore_o=None):
watch_api.add_resource(WatchSingleHistory,
'/api/v1/watch/<string:uuid>/history/<string:timestamp>',
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,
'/api/v1/watch/<string:uuid>/history',
resource_class_kwargs={'datastore': datastore})
@@ -427,6 +427,32 @@ def changedetection_app(config=None, datastore_o=None):
except FileNotFoundError:
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':
# Could be sensitive, follow password requirements

View File

@@ -55,6 +55,18 @@ valid_method = {
default_method = 'GET'
allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
LLM_example_texts = ['Tell me simply "Price, In stock"',
'Give me a list of all products for sale in this text',
'Tell me simply "Yes" "No" or "Maybe" if you think the weather outlook is good for a 4-day small camping trip',
'Look at this restaurant menu and only give me list of meals you think are good for type 2 diabetics, if nothing is found just say "nothing"',
]
LLM_send_type_choices = [('text', 'Plain text after filters'),
('above_fold_text', 'Text above the fold'),
('Screenshot', 'Screenshot / Selection'),
('HTML', 'HTML Source')
]
class StringListField(StringField):
widget = widgets.TextArea()
@@ -515,11 +527,15 @@ class ValidateCSSJSONXPATHInput(object):
class quickWatchForm(Form):
from . import processors
import random
url = fields.URLField('URL', validators=[validateURL()])
tags = StringTagUUID('Group tag', [validators.Optional()])
watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
LLM_prompt = TextAreaField(u'AI Prompt', [validators.Optional()], render_kw={"placeholder": f'Example, "{random.choice(LLM_example_texts)}"'})
LLM_send_type = RadioField(u'LLM Send', choices=LLM_send_type_choices, default="text")
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
@@ -527,6 +543,7 @@ class quickWatchForm(Form):
# Common to a single watch and the global settings
class commonSettingsForm(Form):
from . import processors
import random
def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs):
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
@@ -544,6 +561,8 @@ class commonSettingsForm(Form):
timezone = StringField("Timezone for watch schedule", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")])
LLM_prompt = TextAreaField(u'AI Prompt', [validators.Optional()], render_kw={"placeholder": f'Example, "{random.choice(LLM_example_texts)}"'})
LLM_send_type = RadioField(u'LLM Send', choices=LLM_send_type_choices, default="text")
class importForm(Form):
from . import processors
@@ -740,6 +759,30 @@ 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()])
class globalSettingsApplicationAIKeysForm(Form):
openai = StringField('OpenAI Key',
validators=[validators.Optional()],
render_kw={"placeholder": 'xxxxxxxxx'}
)
gemini = StringField('Google Gemini Key',
validators=[validators.Optional()],
render_kw={"placeholder": 'ooooooooo'}
)
class globalSettingsApplicationAIForm(Form):
#@todo use only configured types?
LLM_backend = RadioField(u'LLM Backend',
choices=[('openai', 'Open AI'), ('gemini', 'Gemini')],
default="text")
# So that we can pass this to our LLM/__init__.py as a keys dict
API_keys = FormField(globalSettingsApplicationAIKeysForm)
# datastore.data['settings']['application']..
class globalSettingsApplicationForm(commonSettingsForm):
@@ -773,6 +816,8 @@ class globalSettingsApplicationForm(commonSettingsForm):
message="Should contain zero or more attempts")])
ui = FormField(globalSettingsApplicationUIForm)
ai = FormField(globalSettingsApplicationAIForm)
class globalSettingsForm(Form):
# Define these as FormFields/"sub forms", this way it matches the JSON storage

View File

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

View File

@@ -103,6 +103,13 @@ class model(watch_base):
return 'DISABLED'
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):
import pathlib
@@ -413,6 +420,132 @@ class model(watch_base):
# False is not an option for AppRise, must be type 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):
fname = os.path.join(self.watch_data_dir, filename)
if os.path.isfile(fname):

View File

@@ -38,6 +38,9 @@ class watch_base(dict):
'last_error': False,
'last_notification_error': None,
'last_viewed': 0, # history key value of the last viewed via the [diff] link
'LLM_prompt': None,
'LLM_send_type': None,
'LLM_backend': None,
'method': 'GET',
'notification_alert_count': 0,
'notification_body': None,

View File

@@ -0,0 +1,64 @@
import importlib
from langchain_core.messages import SystemMessage, HumanMessage
SYSTEM_MESSAGE = (
"You are a text analyser who will attempt to give the most concise information "
"to the request, the information should be returned in a way that if I ask you again "
"I should get the same answer if the outcome is the same. The goal is to cut down "
"or reduce the text changes from you when i ask the same question about similar content "
"Always list items in exactly the same order and wording as found in the source text. "
)
class LLM_integrate:
PROVIDER_MAP = {
"openai": ("langchain_openai", "ChatOpenAI"),
"azure": ("langchain_community.chat_models", "AzureChatOpenAI"),
"gemini": ("langchain_google_genai", "ChatGoogleGenerativeAI")
}
def __init__(self, api_keys: dict):
"""
api_keys = {
"openai": "sk-xxx",
"azure": "AZURE_KEY",
"gemini": "GEMINI_KEY"
}
"""
self.api_keys = api_keys
def run(self, provider: str, model: str, message: str):
module_name, class_name = self.PROVIDER_MAP[provider]
# Import the class dynamically
module = importlib.import_module(module_name)
LLMClass = getattr(module, class_name)
# Create the LLM object
llm_kwargs = {}
if provider == "openai":
llm_kwargs = dict(api_key=self.api_keys.get("openai", ''),
model=model,
# https://api.python.langchain.com/en/latest/chat_models/langchain_openai.chat_models.base.ChatOpenAI.html#langchain_openai.chat_models.base.ChatOpenAI.temperature
temperature=0 # most deterministic,
)
elif provider == "azure":
llm_kwargs = dict(
api_key=self.api_keys["azure"],
azure_endpoint="https://<your-endpoint>.openai.azure.com",
deployment_name=model
)
elif provider == "gemini":
llm_kwargs = dict(api_key=self.api_keys.get("gemini"), model=model)
llm = LLMClass(**llm_kwargs)
# Build your messages
messages = [
SystemMessage(content=SYSTEM_MESSAGE),
HumanMessage(content=message)
]
# Run the model asynchronously
result = llm.invoke(messages)
return result.content

View File

@@ -1,5 +1,6 @@
from abc import abstractmethod
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.processors.LLM import LLM_integrate
from changedetectionio.strtobool import strtobool
from copy import deepcopy
from loguru import logger

View File

@@ -7,7 +7,7 @@ import re
import urllib3
from changedetectionio.conditions import execute_ruleset_against_all_plugins
from changedetectionio.processors import difference_detection_processor
from changedetectionio.processors import difference_detection_processor, LLM_integrate
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
from changedetectionio import html_tools, content_fetchers
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
@@ -293,6 +293,30 @@ class perform_site_check(difference_detection_processor):
# we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here.
stripped_text_from_html = stripped_text_from_html.replace("\n\n", "\n")
stripped_text_from_html = '\n'.join(sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower()))
### OPENAI?
# And here we run LLM integration based on the content we received
LLM_keys = self.datastore.data['settings']['application']['ai'].get('API_keys', {})
if watch.get('LLM_prompt') and stripped_text_from_html and LLM_keys:
response = ""
try:
integrator = LLM_integrate(api_keys=LLM_keys)
response = integrator.run(
provider="openai",
model="gpt-4.1", #gpt-4-turbo
message=f"{watch.get('LLM_prompt')}\n----------- Content follows-----------\n\n{stripped_text_from_html}"
)
except Exception as e:
logger.critical(f"Error running LLM integration {str(e)} (type etc)")
raise(e)
x = 1
# todo is there something special when tokens are used up etc?
else:
stripped_text_from_html = response
# logger.trace("LLM done")
finally:
logger.debug("LLM request done (type etc)")
### CALCULATE MD5
# If there's text to ignore

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@@ -104,8 +104,18 @@ $(document).ready(function () {
});
});
// Listen for periodically emitted watch data
console.log('Adding watch_update event listener');
// So that the favicon is only updated when the server has written the scraped favicon to disk.
socket.on('watch_bumped_favicon', function (watch) {
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) {
const watch = data.watch;
@@ -116,29 +126,28 @@ $(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 data:', watch);
console.log('General stats:', general_stats);
// Updating watch table rows
const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]');
console.log('Found watch row elements:', $watchRow.length);
if ($watchRow.length) {
$($watchRow).toggleClass('checking-now', watch.checking_now);
$($watchRow).toggleClass('queued', watch.queued);
$($watchRow).toggleClass('unviewed', watch.unviewed);
$($watchRow).toggleClass('has-error', watch.has_error);
$($watchRow).toggleClass('has-favicon', watch.has_favicon);
$($watchRow).toggleClass('notification_muted', watch.notification_muted);
$($watchRow).toggleClass('paused', watch.paused);
$($watchRow).toggleClass('single-history', watch.history_n === 1);
$($watchRow).toggleClass('multiple-history', watch.history_n >= 2);
$('td.title-col .error-text', $watchRow).html(watch.error_text)
$('td.last-changed', $watchRow).text(watch.last_changed_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('eta_complete', watch.last_checked + watch.fetch_time);
console.log('Updated UI for watch:', watch.uuid);
}

View File

@@ -21,6 +21,7 @@ function request_textpreview_update() {
namespace: 'watchEdit'
}).done(function (data) {
console.debug(data['duration'])
$('#error-text').text(data['duration']);
$('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']);
$('#filters-and-triggers #text-preview-inner')
.text(data['after_filter'])
@@ -37,9 +38,8 @@ function request_textpreview_update() {
}).fail(function (error) {
if (error.statusText === 'abort') {
console.log('Request was aborted due to a new request being fired.');
} else {
$('#filters-and-triggers #text-preview-inner').text('There was an error communicating with the server.');
}
$('#error-text').text(error.responseJSON['error']);
})
}

File diff suppressed because one or more lines are too long

View File

@@ -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"
}
}

View File

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

View File

@@ -1,3 +1,5 @@
@use "_llm-prompt";
ul#conditions_match_logic {
list-style: none;
input, label, li {

View File

@@ -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 {

View File

@@ -0,0 +1,92 @@
.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

@@ -0,0 +1,59 @@
#form-quick-watch-add #openai-prompt {
color: var(--color-white);
}
#llm-prompt-all {
.label {
display: block !important;
}
textarea {
white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word; /* legacy support */
font-size: 13px;
}
ul {
list-style: none;
padding-left: 0px;
li {
display: flex;
align-items: center;
gap: 0.5em;
padding-bottom: 0.3em;
> * {
margin: 0px;
padding: 0px;
}
label {
font-weight: normal;
}
}
}
}
@media (min-width: 768px) {
#llm-prompt-all {
display: grid;
grid-template-columns: 1fr auto auto;
column-gap: 1.5rem;
align-items: start;
font-size: 0.9rem;
padding: 0.3rem;
#llm-prompt {
/* ensure the textarea stretches horizontally */
width: 100%;
textarea {
width: 100%;
box-sizing: border-box;
}
}
}
}

View File

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

View File

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

View File

@@ -2,21 +2,25 @@
* -- 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/_edit";
@import "parts/_conditions_table";
@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 {
color: var(--color-text);
@@ -184,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;
@@ -694,114 +704,6 @@ footer {
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 {
@@ -890,7 +792,9 @@ textarea::placeholder {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
background-color: var(--color-background-tab);
svg {
stroke: var(--color-text-tab);
}
&:not(.active) {
&:hover {
background-color: var(--color-background-tab-hover);
@@ -900,11 +804,13 @@ textarea::placeholder {
&.active,
:target {
background-color: var(--color-background);
a {
color: var(--color-text-tab-active);
font-weight: bold;
}
svg {
stroke: var(--color-text-tab-active);
}
}
a {
@@ -1056,8 +962,6 @@ ul {
}
}
@import "parts/_visualselector";
#webdriver_delay {
width: 5em;
}
@@ -1175,17 +1079,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

View File

@@ -38,6 +38,12 @@
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
{% endif %}
<script>
// Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() {
feather.replace();
});
</script>
</head>
<body class="">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table %}
{% from '_helpers.html' import render_field, render_simple_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table %}
{% from '_common_fields.html' import render_common_settings_form %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
@@ -52,7 +52,7 @@
<!-- should goto extra forms? -->
{% if watch['processor'] == 'text_json_diff' %}
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">AI, Filters &amp; Triggers</a></li>
<li class="tab" id="conditions-tab"><a href="#conditions">Conditions</a></li>
{% endif %}
<li class="tab"><a href="#notifications">Notifications</a></li>
@@ -316,7 +316,6 @@ Math: {{ 1 + 1 }}") }}
</li>
</ul>
</div>
{% include "edit/include_subtract.html" %}
<div class="text-filtering border-fieldset">
<fieldset class="pure-group" id="text-filtering-type-options">
@@ -364,6 +363,7 @@ Math: {{ 1 + 1 }}") }}
</div>
</div>
</div>
<p id="error-text"></p>
</div>
</div>
</div>

View File

@@ -1,4 +1,7 @@
<div class="pure-control-group">
<div class="pure-control-group">
{%- include 'edit/llm_prompt.html' -%}
</div>
<div class="pure-control-group">
{% set field = render_field(form.include_filters,
rows=5,
placeholder=has_tag_filters_extra+"#example

View File

@@ -0,0 +1,12 @@
<div class="pure-control-group" id="ai-filter-options">
<div id="openai-prompt">
<div id="llm-prompt-all">
<div id="llm-prompt">
{{ render_simple_field(form.LLM_prompt, rows=5) }}
</div>
<div id="llm-send-type">
{{ render_simple_field(form.LLM_send_type) }}
</div>
</div>
</div>
</div>

View File

@@ -2,19 +2,24 @@
import time
from flask import url_for
import os
from ..util import live_server_setup, wait_for_all_checks
import logging
# Requires playwright to be installed
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(
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
)
@@ -30,11 +35,51 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage):
assert b"1 Imported" in res.data
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
follow_redirects=True
)
logging.getLogger().info("Looking for correct fetched HTML (text) from server")
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,3 +79,48 @@ 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')
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"
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,13 +419,20 @@ def check_json_ext_filter(json_filter, client, live_server):
res = client.get(url_for("watchlist.index"))
assert b'unviewed' in res.data
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
res = client.get(url_for("ui.ui_views.preview_page", uuid="first"))
# We should never see 'ForSale' because we are selecting on 'Sold' in the rule,
# But we should know it triggered ('unviewed' assert above)
assert b'ForSale' not 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)
assert b'Deleted' in res.data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

@@ -69,6 +69,9 @@ werkzeug==3.0.6
# Templating, so far just in the URLs but in the future can be for the notifications also
jinja2~=3.1
jinja2-time
langchain~=0.3
langchain-openai~=0.3
openpyxl
# https://peps.python.org/pep-0508/#environment-markers
# https://github.com/dgtlmoon/changedetection.io/pull/1009
@@ -117,6 +120,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