dgtlmoon 2025-05-09 07:43:26 +00:00 zatwierdzone przez GitHub
commit b59e43bd07
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
26 zmienionych plików z 513 dodań i 168 usunięć

Wyświetl plik

@ -4,11 +4,14 @@
__version__ = '0.49.16'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
# Set environment variables before importing other modules
import os
os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
# Import eventlet for WSGI server - no monkey patching to avoid conflicts
import eventlet
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
import eventlet.wsgi
import getopt
import platform
@ -141,7 +144,27 @@ def main():
logger.critical(str(e))
return
# Get the Flask app
app = changedetection_app(app_config, datastore)
# Now initialize Socket.IO after the app is fully set up
try:
from changedetectionio.realtime.socket_server import ChangeDetectionSocketIO
from changedetectionio.flask_app import socketio_server
import threading
# Create the Socket.IO server
socketio_server = ChangeDetectionSocketIO(app, datastore)
# Run the Socket.IO server in a separate thread on port 5005
socket_thread = threading.Thread(target=socketio_server.run,
kwargs={'host': host, 'port': 5005})
socket_thread.daemon = True
socket_thread.start()
logger.info("Socket.IO server initialized successfully on port 5005")
except Exception as e:
logger.warning(f"Failed to initialize Socket.IO server: {str(e)}")
signal.signal(signal.SIGTERM, sigshutdown_handler)
signal.signal(signal.SIGINT, sigshutdown_handler)
@ -204,5 +227,7 @@ def main():
server_side=True), app)
else:
# We'll integrate the Socket.IO server with the WSGI server
# The Socket.IO server is already attached to the Flask app
eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app)

Wyświetl plik

@ -8,7 +8,7 @@ from changedetectionio.blueprint.ui.edit import construct_blueprint as construct
from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint
from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint
def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData):
def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData, watch_check_completed):
ui_blueprint = Blueprint('ui', __name__, template_folder="templates")
# Register the edit blueprint
@ -20,7 +20,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
ui_blueprint.register_blueprint(notification_blueprint)
# Register the views blueprint
views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData)
views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData, watch_check_completed)
ui_blueprint.register_blueprint(views_blueprint)
# Import the login decorator
@ -35,7 +35,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
flash('Watch not found', 'error')
else:
flash("Cleared snapshot history for watch {}".format(uuid))
return redirect(url_for('watchlist.index'))
@ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
@ -47,7 +46,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
if confirmtext == 'clear':
for uuid in datastore.data['watching'].keys():
datastore.clear_watch_history(uuid)
flash("Cleared snapshot history for all watches")
else:
flash('Incorrect confirmation text.', 'error')
@ -248,6 +246,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
flash(f"{len(uuids)} watches were tagged")
if uuids:
for uuid in uuids:
watch_check_completed.send(watch_uuid=uuid)
return redirect(url_for('watchlist.index'))

Wyświetl plik

@ -8,7 +8,7 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio import html_tools
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_completed):
views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates")
@views_blueprint.route("/preview/<string:uuid>", methods=['GET'])

Wyświetl plik

@ -84,7 +84,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
has_proxies=datastore.proxy_list,
has_unviewed=datastore.has_unviewed,
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
now_time_server=time.time(),
now_time_server=round(time.time()),
pagination=pagination,
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
search_q=request.args.get('q', '').strip(),

Wyświetl plik

@ -100,14 +100,14 @@
{% endif %}
{% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) %}
{% set is_unviewed = watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}
{% set checking_now = is_checking_now(watch) %}
<tr id="{{ watch.uuid }}"
<tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}"
class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }}
{# socket.js also sets these vars on the row for update #}
{% if watch.last_error is defined and watch.last_error != False %}error{% endif %}
{% if watch.last_notification_error is defined and watch.last_notification_error != False %}error{% endif %}
{% if watch.paused is defined and watch.paused != False %}paused{% endif %}
{% if is_unviewed %}unviewed{% endif %}
{% if watch.has_unviewed %}unviewed{% endif %}
{% if watch.has_restock_info %} has-restock-info {% if watch['restock']['in_stock'] %}in-stock{% else %}not-in-stock{% endif %} {% else %}no-restock-info{% endif %}
{% if watch.uuid in queued_uuids %}queued{% endif %}
{% if checking_now %}checking-now{% endif %}
@ -143,7 +143,7 @@
<a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try other proxies/location</a>&nbsp;
{% endif %}
<a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try adding external proxies/locations</a>
{% endif %}
{% if 'empty result or contain only an image' in watch.last_error %}
<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Detecting-changes-in-images">more help here</a>.
@ -190,12 +190,13 @@
</td>
{% endif %}
{#last_checked becomes fetch-start-time#}
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" {% if checking_now %} data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" {% endif %} >
{% if checking_now %}
<span class="spinner"></span><span> Checking now</span>
{% else %}
{{watch|format_last_checked_time|safe}}</td>
{% endif %}
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" >
<div class="spinner-wrapper" style="display:none;" >
<span class="spinner"></span><span>&nbsp;Checking now</span>
</div>
<span class="innertext">{{watch|format_last_checked_time|safe}}</span>
</td>
<td class="last-changed" data-timestamp="{{ watch.last_changed }}">{% if watch.history_n >=2 and watch.last_changed >0 %}
{{watch.last_changed|format_timestamp_timeago}}
@ -204,15 +205,17 @@
{% endif %}
</td>
<td>
<a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}"
class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a>
<a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" class="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>
{% if watch.history_n >= 2 %}
{% set open_diff_in_new_tab = datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') %}
{% set target_attr = ' target="' ~ watch.uuid ~ '"' if open_diff_in_new_tab else '' %}
{% if is_unviewed %}
{% if watch.has_unviewed %}
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a>
{% else %}
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a>
@ -251,4 +254,4 @@
</div>
</form>
</div>
{% endblock %}
{% endblock %}

Wyświetl plik

@ -0,0 +1,27 @@
import queue
from blinker import signal
class SignalPriorityQueue(queue.PriorityQueue):
"""
Extended PriorityQueue that sends a signal when items with a UUID are added.
This class extends the standard PriorityQueue and adds a signal emission
after an item is put into the queue. If the item contains a UUID, the signal
is sent with that UUID as a parameter.
"""
def __init__(self, maxsize=0):
super().__init__(maxsize)
def put(self, item, block=True, timeout=None):
# Call the parent's put method first
super().put(item, block, timeout)
# After putting the item in the queue, check if it has a UUID and emit signal
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
uuid = item.item['uuid']
# Get the signal and send it if it exists
watch_check_completed = signal('watch_check_completed')
if watch_check_completed:
# Send the watch_uuid parameter
watch_check_completed.send(watch_uuid=uuid)

Wyświetl plik

@ -7,9 +7,11 @@ import queue
import threading
import time
import timeago
from blinker import signal
from changedetectionio.strtobool import strtobool
from threading import Event
from changedetectionio.custom_queue import SignalPriorityQueue
from flask import (
Flask,
@ -28,8 +30,13 @@ from flask_login import current_user
from flask_paginate import Pagination, get_page_parameter
from flask_restful import abort, Api
from flask_cors import CORS
# Create specific signals for application events
# Make this a global singleton to avoid multiple signal objects
watch_check_completed = signal('watch_check_completed', doc='Signal sent when a watch check is completed')
from flask_wtf import CSRFProtect
from loguru import logger
import eventlet
from changedetectionio import __version__
from changedetectionio import queuedWatchMetaData
@ -45,7 +52,7 @@ ticker_thread = None
extra_stylesheets = []
update_q = queue.PriorityQueue()
update_q = SignalPriorityQueue()
notification_q = queue.Queue()
MAX_QUEUE_SIZE = 2000
@ -54,6 +61,9 @@ app = Flask(__name__,
static_folder="static",
template_folder="templates")
# Will be initialized in changedetection_app
socketio_server = None
# Enable CORS, especially useful for the Chrome extension to operate from anywhere
CORS(app)
@ -215,12 +225,15 @@ class User(flask_login.UserMixin):
def changedetection_app(config=None, datastore_o=None):
logger.trace("TRACE log is enabled")
global datastore
global datastore, socketio_server
datastore = datastore_o
# so far just for read-only via tests, but this will be moved eventually to be the main source
# (instead of the global var)
app.config['DATASTORE'] = datastore_o
# Store the signal in the app config to ensure it's accessible everywhere
app.config['WATCH_CHECK_COMPLETED_SIGNAL'] = watch_check_completed
login_manager = flask_login.LoginManager(app)
login_manager.login_view = 'login'
@ -444,7 +457,7 @@ def changedetection_app(config=None, datastore_o=None):
# watchlist UI buttons etc
import changedetectionio.blueprint.ui as ui
app.register_blueprint(ui.construct_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData))
app.register_blueprint(ui.construct_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData, watch_check_completed))
import changedetectionio.blueprint.watchlist as watchlist
app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='')
@ -467,6 +480,8 @@ def changedetection_app(config=None, datastore_o=None):
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')):
threading.Thread(target=check_for_new_version).start()
# Return the Flask app - the Socket.IO will be attached to it but initialized separately
# This avoids circular dependencies
return app

Wyświetl plik

@ -436,55 +436,27 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False
return re.sub(pattern, repl, html_content)
def html_to_text_sub_worker(conn, html_content: str, render_anchor_tag_content=False, is_rss=False):
# NOTE!! ANYTHING LIBXML, HTML5LIB ETC WILL CAUSE SOME SMALL MEMORY LEAK IN THE LOCAL "LIB" IMPLEMENTATION OUTSIDE PYTHON
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False, timeout=10) -> str:
from inscriptis import get_text
from inscriptis.model.config import ParserConfig
"""Converts html string to a string with just the text. If ignoring
rendering anchor tag content is enable, anchor tag content are also
included in the text
:param html_content: string with html content
:param render_anchor_tag_content: boolean flag indicating whether to extract
hyperlinks (the anchor tag content) together with text. This refers to the
'href' inside 'a' tags.
Anchor tag content is rendered in the following manner:
'[ text ](anchor tag content)'
:return: extracted text from the HTML
"""
# if anchor tag content flag is set to True define a config for
# extracting this content
if render_anchor_tag_content:
parser_config = ParserConfig(
annotation_rules={"a": ["hyperlink"]},
display_links=True
)
# otherwise set config to None/default
else:
parser_config = None
# RSS Mode - Inscriptis will treat `title` as something else.
# Make it as a regular block display element (//item/title)
# This is a bit of a hack - the real way it to use XSLT to convert it to HTML #1874
if is_rss:
html_content = re.sub(r'<title([\s>])', r'<h1\1', html_content)
html_content = re.sub(r'</title>', r'</h1>', html_content)
text_content = get_text(html_content, config=parser_config)
conn.send(text_content)
conn.close()
# NOTE!! ANYTHING LIBXML, HTML5LIB ETC WILL CAUSE SOME SMALL MEMORY LEAK IN THE LOCAL "LIB" IMPLEMENTATION OUTSIDE PYTHON
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False):
from multiprocessing import Process, Pipe
parent_conn, child_conn = Pipe()
p = Process(target=html_to_text_sub_worker, args=(child_conn, html_content, render_anchor_tag_content, is_rss))
p.start()
text = parent_conn.recv()
p.join()
return text
return text_content
# Does LD+JSON exist with a @type=='product' and a .price set anywhere?
def has_ldjson_product_info(content):

Wyświetl plik

@ -1,3 +1,5 @@
from blinker import signal
from changedetectionio.strtobool import strtobool
from changedetectionio.safe_jinja import render as jinja_render
from . import watch_base
@ -60,6 +62,10 @@ class model(watch_base):
return False
@property
def has_unviewed(self):
return int(self.newest_history_key) > int(self['last_viewed']) and self.__history_n >= 2
def ensure_data_dir_exists(self):
if not os.path.isdir(self.watch_data_dir):
logger.debug(f"> Creating data dir {self.watch_data_dir}")
@ -120,6 +126,10 @@ class model(watch_base):
'remote_server_reply': None,
'track_ldjson_price_data': None
})
watch_check_completed = signal('watch_check_completed')
if watch_check_completed:
watch_check_completed.send(watch_uuid=self.get('uuid'))
return
@property

Wyświetl plik

@ -0,0 +1,3 @@
"""
Socket.IO realtime updates module for changedetection.io
"""

Wyświetl plik

@ -0,0 +1,127 @@
import timeago
from flask import Flask
from flask_socketio import SocketIO
import threading
import json
import time
from loguru import logger
import blinker
from changedetectionio.flask_app import _jinja2_filter_datetime, watch_check_completed
class SignalHandler:
"""A standalone class to receive signals"""
def __init__(self, socketio_instance):
self.socketio_instance = socketio_instance
# Get signal from app config
app_signal = socketio_instance.main_app.config.get('WATCH_CHECK_COMPLETED_SIGNAL')
if app_signal:
app_signal.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from app config")
else:
# Fallback if not in app config
from changedetectionio.flask_app import watch_check_completed as wcc
wcc.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from direct import")
def handle_signal(self, *args, **kwargs):
logger.info(f"SignalHandler: Signal received with {len(args)} args and {len(kwargs)} kwargs")
# Safely extract the watch UUID from kwargs
watch_uuid = kwargs.get('watch_uuid')
if watch_uuid:
# Get the datastore from the socket instance
datastore = self.socketio_instance.datastore
# Get the watch object from the datastore
watch = datastore.data['watching'].get(watch_uuid)
if watch:
# Forward to the socket instance with the watch parameter
self.socketio_instance.handle_watch_update(watch=watch)
logger.info(f"Signal handler processed watch UUID {watch_uuid}")
else:
logger.warning(f"Watch UUID {watch_uuid} not found in datastore")
class ChangeDetectionSocketIO:
def __init__(self, app, datastore):
self.main_app = app
self.datastore = datastore
# Use threading mode instead of eventlet
self.socketio = SocketIO(self.main_app,
async_mode='threading',
cors_allowed_origins="*",
logger=False,
engineio_logger=False)
# Set up event handlers
self.socketio.on_event('connect', self.handle_connect)
self.socketio.on_event('disconnect', self.handle_disconnect)
# Don't patch the update_watch method - this was causing issues
# Just start a background thread to periodically emit watch status
self.thread = None
self.thread_lock = threading.Lock()
# Create a dedicated signal handler
self.signal_handler = SignalHandler(self)
def handle_connect(self):
"""Handle client connection"""
logger.info("Socket.IO: Client connected")
def handle_disconnect(self):
"""Handle client disconnection"""
logger.info("Socket.IO: Client disconnected")
def handle_watch_update(self, **kwargs):
"""Handle watch update signal from blinker"""
try:
watch = kwargs.get('watch')
# Emit the watch update to all connected clients
with self.main_app.app_context():
from changedetectionio.flask_app import running_update_threads, update_q
# Get list of watches that are currently running
running_uuids = []
for t in running_update_threads:
if hasattr(t, 'current_uuid') and t.current_uuid:
running_uuids.append(t.current_uuid)
# Get list of watches in the queue
queue_list = []
for q_item in update_q.queue:
if hasattr(q_item, 'item') and 'uuid' in q_item.item:
queue_list.append(q_item.item['uuid'])
# Create a simplified watch data object to send to clients
watch_data = {
'checking_now': True if watch.get('uuid') in running_uuids else False,
'fetch_time': watch.get('fetch_time'),
'has_error': watch.get('last_error') or watch.get('last_notification_error'),
'last_changed': watch.get('last_changed'),
'last_checked': watch.get('last_checked'),
'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.get('last_changed',0)) >0 else 'Not yet',
'queued': True if watch.get('uuid') in queue_list else False,
'unviewed': watch.has_unviewed,
'uuid': watch.get('uuid'),
}
self.socketio.emit("watch_update", watch_data)
logger.debug(f"Socket.IO: Emitted update for watch {watch.get('uuid')}")
except Exception as e:
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def run(self, host='0.0.0.0', port=5005):
"""Run the Socket.IO server on a separate port"""
# Start the background task when the server starts
#self.start_background_task()
# Run the Socket.IO server
# Use 0.0.0.0 to listen on all interfaces
logger.info(f"Starting Socket.IO server on http://{host}:{port}")
self.socketio.run(self.main_app, host=host, port=port, debug=False, use_reloader=False, allow_unsafe_werkzeug=True)

Wyświetl plik

@ -38,6 +38,9 @@ pytest tests/test_backend.py
pytest tests/test_rss.py
pytest tests/test_unique_lines.py
# Try high concurrency
FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l
# Check file:// will pickup a file when enabled
echo "Hello world" > /tmp/test-file.txt
ALLOW_FILE_URI=yes pytest tests/test_security.py

Wyświetl plik

@ -0,0 +1,56 @@
// Socket.IO client-side integration for changedetection.io
$(document).ready(function () {
// Try to create the socket connection to port 5005 - if it fails, the site will still work normally
try {
// Connect to the dedicated Socket.IO server on port 5005
const socket = io('http://127.0.0.1:5005');
// Connection status logging
socket.on('connect', function () {
console.log('Socket.IO connected');
});
socket.on('disconnect', function () {
console.log('Socket.IO disconnected');
});
socket.on('checking_now', function (uuid_list) {
console.log("Got checking now update");
// Remove 'checking-now' class where it should no longer be
$('.watch-table tbody tr.checking-now').each(function () {
if (!uuid_list.includes($(this).data('watch-uuid'))) {
$(this).removeClass('checking-now');
}
});
// Add the class on the rows where it should be
uuid_list.forEach(function (uuid) {
$('.watch-table tbody tr[data-watch-uuid="' + uuid + '"]').addClass('checking-now');
});
});
// Listen for periodically emitted watch data
socket.on('watch_update', function (watch) {
console.log(`Watch update ${watch.uuid}`);
const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]');
if ($watchRow.length) {
$($watchRow).toggleClass('checking-now', watch.checking_now);
$($watchRow).toggleClass('queued', watch.queued);
$($watchRow).toggleClass('unviewed', watch.unviewed);
$($watchRow).toggleClass('error', watch.has_error);
$('td.last-changed', $watchRow).text(watch.last_checked_text)
$('td.last-checked .innertext', $watchRow).text(watch.last_checked_text)
$('td.last-checked', $watchRow).data('timestamp', watch.last_checked).data('fetchduration', watch.fetch_time);
$('td.last-checked', $watchRow).data('eta_complete', watch.last_checked + watch.fetch_time);
}
});
} catch (e) {
// If Socket.IO fails to initialize, just log it and continue
console.log('Socket.IO initialization error:', e);
}
});

Wyświetl plik

@ -68,7 +68,7 @@ $(function () {
if (eta_complete + 2 > nowtimeserver && fetch_duration > 3) {
const remaining_seconds = Math.abs(eta_complete) - nowtimeserver - 1;
let r = (1.0 - (remaining_seconds / fetch_duration)) * 100;
let r = Math.round((1.0 - (remaining_seconds / fetch_duration)) * 100);
if (r < 10) {
r = 10;
}
@ -76,7 +76,6 @@ $(function () {
r = 100;
}
$(this).css('background-size', `${r}% 100%`);
//$(this).text(`${r}% remain ${remaining_seconds}`);
} else {
$(this).css('background-size', `100% 100%`);
}

Wyświetl plik

@ -0,0 +1 @@
// Styles for Socket.IO real-time updates

Wyświetl plik

@ -0,0 +1,72 @@
/* table related */
.watch-table {
width: 100%;
font-size: 80%;
tr {
&.unviewed {
font-weight: bold;
}
&.error {
color: var(--color-watch-table-error);
}
color: var(--color-watch-table-row-text);
}
td {
white-space: nowrap;
&.title-col {
word-break: break-all;
white-space: normal;
}
}
th {
white-space: nowrap;
a {
font-weight: normal;
&.active {
font-weight: bolder;
}
&.inactive {
.arrow {
display: none;
}
}
}
}
.title-col a[target="_blank"]::after,
.current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px;
}
/* Row with 'checking-now' */
tr.checking-now {
td.last-checked {
.spinner-wrapper {
display: inline-block !important;
}
.innertext {
display: none !important;
}
}
}
tr.queued {
a.recheck {
display: none !important;
}
a.already-in-queue-button {
display: inline-block !important;
}
}
}

Wyświetl plik

@ -13,6 +13,7 @@
@import "parts/_menu";
@import "parts/_love";
@import "parts/preview_text_filter";
@import "parts/_watch_table";
@import "parts/_edit";
@import "parts/_conditions_table";
@ -169,56 +170,6 @@ code {
color: var(--color-text);
}
/* table related */
.watch-table {
width: 100%;
font-size: 80%;
tr {
&.unviewed {
font-weight: bold;
}
&.error {
color: var(--color-watch-table-error);
}
color: var(--color-watch-table-row-text);
}
td {
white-space: nowrap;
&.title-col {
word-break: break-all;
white-space: normal;
}
}
th {
white-space: nowrap;
a {
font-weight: normal;
&.active {
font-weight: bolder;
}
&.inactive {
.arrow {
display: none;
}
}
}
}
.title-col a[target="_blank"]::after,
.current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px;
}
}
.inline-tag {
white-space: nowrap;
border-radius: 5px;

Wyświetl plik

@ -523,6 +523,43 @@ body.preview-text-enabled {
z-index: 3;
box-shadow: 1px 1px 4px var(--color-shadow-jump); }
/* table related */
.watch-table {
width: 100%;
font-size: 80%;
/* Row with 'checking-now' */ }
.watch-table tr {
color: var(--color-watch-table-row-text); }
.watch-table tr.unviewed {
font-weight: bold; }
.watch-table tr.error {
color: var(--color-watch-table-error); }
.watch-table td {
white-space: nowrap; }
.watch-table td.title-col {
word-break: break-all;
white-space: normal; }
.watch-table th {
white-space: nowrap; }
.watch-table th a {
font-weight: normal; }
.watch-table th a.active {
font-weight: bolder; }
.watch-table th a.inactive .arrow {
display: none; }
.watch-table .title-col a[target="_blank"]::after,
.watch-table .current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px; }
.watch-table tr.checking-now td.last-checked .spinner-wrapper {
display: inline-block !important; }
.watch-table tr.checking-now td.last-checked .innertext {
display: none !important; }
.watch-table tr.queued a.recheck {
display: none !important; }
.watch-table tr.queued a.already-in-queue-button {
display: inline-block !important; }
ul#conditions_match_logic {
list-style: none; }
ul#conditions_match_logic input, ul#conditions_match_logic label, ul#conditions_match_logic li {
@ -735,34 +772,6 @@ code {
background: var(--color-background-code);
color: var(--color-text); }
/* table related */
.watch-table {
width: 100%;
font-size: 80%; }
.watch-table tr {
color: var(--color-watch-table-row-text); }
.watch-table tr.unviewed {
font-weight: bold; }
.watch-table tr.error {
color: var(--color-watch-table-error); }
.watch-table td {
white-space: nowrap; }
.watch-table td.title-col {
word-break: break-all;
white-space: normal; }
.watch-table th {
white-space: nowrap; }
.watch-table th a {
font-weight: normal; }
.watch-table th a.active {
font-weight: bolder; }
.watch-table th a.inactive .arrow {
display: none; }
.watch-table .title-col a[target="_blank"]::after,
.watch-table .current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px; }
.inline-tag, .watch-tag-list, .tracking-ldjson-price-data, .restock-label {
white-space: nowrap;
border-radius: 5px;

Wyświetl plik

@ -17,6 +17,7 @@ import threading
import time
import uuid as uuid_builder
from loguru import logger
from blinker import signal
from .processors import get_custom_watch_obj_for_processor
from .processors.restock_diff import Restock
@ -166,6 +167,10 @@ class ChangeDetectionStore:
self.data['watching'][uuid].update({'last_viewed': int(timestamp)})
self.needs_write = True
watch_check_completed = signal('watch_check_completed')
if watch_check_completed:
watch_check_completed.send(watch_uuid=uuid)
def remove_password(self):
self.__data['settings']['application']['password'] = False
self.needs_write = True

Wyświetl plik

@ -31,6 +31,10 @@
</script>
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js" integrity="sha384-c79GN5VsunZvi+Q/WObgk2in0CbZsHnjEqvFxC5DxHn9lTfNce2WW6h2pH6u/kF+" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/timeago.js/4.0.2/timeago.min.js" integrity="sha512-SVDh1zH5N9ChofSlNAK43lcNS7lWze6DTVx1JCXH1Tmno+0/1jMpdbR8YDgDUfcUrPp1xyE53G42GFrcM0CMVg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="{{url_for('static_content', group='js', filename='socket.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='timeago-init.js')}}" defer></script>
</head>
<body class="">

Wyświetl plik

@ -4,14 +4,14 @@ import time
from flask import url_for
from .util import live_server_setup, wait_for_all_checks
# test pages with http://username@password:foobar.com/ work
def test_basic_auth(client, live_server, measure_memory_usage):
live_server_setup(live_server)
# Add our URL to the import page
test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@")
# This page will echo back any auth info
test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@")
time.sleep(1)
res = client.post(
url_for("imports.import_page"),
data={"urls": test_url},
@ -34,4 +34,4 @@ def test_basic_auth(client, live_server, measure_memory_usage):
follow_redirects=True
)
assert b'myuser mypass basic' in res.data
assert b'myuser mypass basic' in res.data

Wyświetl plik

@ -10,8 +10,8 @@ from urllib.parse import urlparse, parse_qs
def test_consistent_history(client, live_server, measure_memory_usage):
live_server_setup(live_server)
r = range(1, 30)
workers = int(os.getenv("FETCH_WORKERS", 10))
r = range(1, 10+workers)
for one in r:
test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True)
@ -35,8 +35,7 @@ def test_consistent_history(client, live_server, measure_memory_usage):
)
assert b"Settings updated." in res.data
time.sleep(2)
wait_for_all_checks(client)
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
@ -46,11 +45,16 @@ def test_consistent_history(client, live_server, measure_memory_usage):
# assert the right amount of watches was found in the JSON
assert len(json_obj['watching']) == len(r), "Correct number of watches was found in the JSON"
i=0
# each one should have a history.txt containing just one line
i=0
for w in json_obj['watching'].keys():
i+1
# res = client.get(url_for("watchlist.index"))
# with open('/tmp/debug.html', 'wb') as f:
# f.write(res.data)
history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt')
assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}"
assert os.path.isfile(history_txt_index_file), f"history.txt for i: {i} should exist where I expect it at {history_txt_index_file}"
# Same like in model.Watch
with open(history_txt_index_file, "r") as f:
@ -58,8 +62,8 @@ def test_consistent_history(client, live_server, measure_memory_usage):
assert len(tmp_history) == 1, "History.txt should contain 1 line"
# Should be two files,. the history.txt , and the snapshot.txt
files_in_watch_dir = os.listdir(os.path.join(live_server.app.config['DATASTORE'].datastore_path,
w))
files_in_watch_dir = os.listdir(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w))
# Find the snapshot one
for fname in files_in_watch_dir:
if fname != 'history.txt' and 'html' not in fname:
@ -75,7 +79,6 @@ def test_consistent_history(client, live_server, measure_memory_usage):
assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot"
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"

Wyświetl plik

@ -219,6 +219,15 @@ def test_rss_bad_chars_breaking(client, live_server):
rss_token = extract_rss_token_from_UI(client)
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
i=0
from loguru import logger
# Because chardet could take a long time
while i<=10:
logger.debug(f"History was {live_server.app.config['DATASTORE'].data['watching'][uuid].history_n}..")
if live_server.app.config['DATASTORE'].data['watching'][uuid].history_n ==2:
break
i+=1
time.sleep(2)
assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2
# Check RSS feed is still working

Wyświetl plik

@ -126,21 +126,56 @@ def extract_UUID_from_client(client):
uuid = m.group(1)
return uuid.strip()
def wait_for_all_checks(client):
# actually this is not entirely true, it can still be 'processing' but not in the queue
# Loop waiting until done..
attempt=0
# because sub-second rechecks are problematic in testing, use lots of delays
time.sleep(1)
while attempt < 60:
res = client.get(url_for("watchlist.index"))
if not b'Checking now' in res.data:
break
logging.getLogger().info(f"Waiting for watch-list to not say 'Checking now'.. {attempt}")
time.sleep(1)
def wait_for_all_checks(client=None):
"""
Waits until the queue is empty and remains empty for at least `required_empty_duration` seconds,
and also ensures no running threads have `current_uuid` set.
Retries for up to `max_attempts` times, sleeping `wait_between_attempts` seconds between checks.
"""
from changedetectionio.flask_app import update_q as global_update_q, running_update_threads
# Configuration
attempt = 0
i=0
max_attempts = 60
wait_between_attempts = 1
required_empty_duration = 0.6
logger = logging.getLogger()
time.sleep(0.5)
empty_since = None
while attempt < max_attempts:
q_length = global_update_q.qsize()
# Check if any threads are still processing
time.sleep(wait_between_attempts)
any_threads_busy = any(t.current_uuid for t in running_update_threads)
if q_length == 0 and not any_threads_busy:
if empty_since is None:
empty_since = time.time()
logger.info(f"Queue empty and no active threads at attempt {attempt}, starting empty timer...")
elif time.time() - empty_since >= required_empty_duration:
logger.info(f"Queue has been empty and threads idle for {required_empty_duration} seconds. Done waiting.")
break
else:
logger.info(f"Still waiting: queue empty and no active threads, but not yet {required_empty_duration} seconds...")
else:
if q_length != 0:
logger.info(f"Queue not empty (size={q_length}), resetting timer.")
if any_threads_busy:
busy_threads = [t.name for t in running_update_threads if t.current_uuid]
logger.info(f"Threads still busy: {busy_threads}, resetting timer.")
empty_since = None
attempt += 1
time.sleep(1)
time.sleep(wait_between_attempts)
def live_server_setup(live_server):

Wyświetl plik

@ -2,6 +2,7 @@ from .processors.exceptions import ProcessorException
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
from changedetectionio import html_tools
from changedetectionio.flask_app import watch_check_completed
import importlib
import os
@ -242,17 +243,16 @@ class update_worker(threading.Thread):
os.unlink(full_path)
def run(self):
while not self.app.config.exit.is_set():
update_handler = None
watch = None
try:
queued_item_data = self.q.get(block=False)
except queue.Empty:
pass
else:
uuid = queued_item_data.item.get('uuid')
fetch_start_time = round(time.time()) # Also used for a unique history key for now
self.current_uuid = uuid
@ -272,6 +272,8 @@ class update_worker(threading.Thread):
logger.info(f"Processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}")
try:
watch_check_completed.send(watch_uuid=watch['uuid'])
# Processor is what we are using for detecting the "Change"
processor = watch.get('processor', 'text_json_diff')
@ -588,12 +590,18 @@ class update_worker(threading.Thread):
'check_count': count
})
self.current_uuid = None # Done
self.q.task_done()
# Send signal for watch check completion with the watch data
if watch:
logger.info(f"Sending watch_check_completed signal for UUID {watch['uuid']}")
watch_check_completed.send(watch_uuid=watch['uuid'])
update_handler = None
logger.debug(f"Watch {uuid} done in {time.time()-fetch_start_time:.2f}s")
# Give the CPU time to interrupt
time.sleep(0.1)

Wyświetl plik

@ -9,6 +9,9 @@ flask_restful
flask_cors # For the Chrome extension to operate
flask_wtf~=1.2
flask~=2.3
flask-socketio>=5.5.1
python-socketio>=5.13.0
python-engineio>=4.12.0
inscriptis~=2.2
pytz
timeago~=1.0
@ -115,3 +118,6 @@ psutil==7.0.0
ruff >= 0.11.2
pre_commit >= 4.2.0
# For events between checking and socketio updates
blinker