mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-28 01:26:16 +00:00
* PDF - Keep the original PDF of the newest two versions
The history snapshot of a PDF watch is only the pdftohtml text, and the "last fetched" .html.br
for a PDF is just a checksum, so the actual documents were never kept. Store {timestamp}.pdf
alongside the snapshot (same as {timestamp}.html.br does for HTML) and prune to the newest two
history entries, so the latest two versions are always available for a visual PDF compare.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test - Browser PDF test, fix race with an immediate fallback recheck, check the original PDFs are kept
A duplicate queue entry (scheduler + import) can run the html_requests fallback check right
after the failed browser check, so by the time the test looks the error may already be cleared.
Accept both outcomes, the error message itself is covered in tests/test_pdf.py.
Also check both original PDF versions are stored, whichever fetcher got them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
6.7 KiB
Python
139 lines
6.7 KiB
Python
import time
|
|
from flask import Blueprint, request, redirect, url_for, flash
|
|
from flask_babel import gettext
|
|
from loguru import logger
|
|
from changedetectionio.store import ChangeDetectionStore
|
|
from changedetectionio.auth_decorator import login_optionally_required
|
|
from changedetectionio import worker_pool
|
|
|
|
|
|
def run_preloaded_first_check(datastore, uuid):
|
|
"""Run the watch's chosen processor once against the snapshot the Add Watch page already
|
|
fetched (parked as preload-fetch.json + screenshot + xpath in the watch dir), producing
|
|
the first history snapshot WITHOUT any network IO.
|
|
|
|
Processors are fast and CPU-only here, so we run them inline on submit. Best-effort:
|
|
any failure (or no preload present) returns False and leaves the watch without history,
|
|
so a normal queued check will populate it instead. Returns True if a snapshot was written.
|
|
"""
|
|
from changedetectionio.processors import get_processor_module
|
|
from changedetectionio import html_tools
|
|
|
|
watch = datastore.data['watching'].get(uuid)
|
|
if not watch:
|
|
return False
|
|
|
|
try:
|
|
processor_module = get_processor_module(watch.get('processor', 'text_json_diff'))
|
|
if not processor_module:
|
|
return False
|
|
|
|
handler = processor_module.perform_site_check(datastore=datastore, watch_uuid=uuid)
|
|
|
|
# Populate the fetcher from the parked snapshot instead of hitting the network.
|
|
if not handler._consume_preloaded_fetch():
|
|
return False
|
|
|
|
changed_detected, update_obj, contents = handler.run_changedetection(watch=watch)
|
|
|
|
# Mirror the worker's first-snapshot save path (the parts that apply with no network).
|
|
timestamp = int(time.time())
|
|
update_obj['content-type'] = str(handler.fetcher.get_all_headers().get('content-type', '') or "").lower()
|
|
update_obj['last_error'] = False
|
|
update_obj['last_checked'] = timestamp
|
|
watch.reset_watch_edited_flag()
|
|
datastore.update_watch(uuid=uuid, update_obj=update_obj)
|
|
|
|
watch.save_history_blob(contents=contents,
|
|
timestamp=timestamp,
|
|
snapshot_id=update_obj.get('previous_md5', 'none'))
|
|
if handler.fetcher.content:
|
|
watch.save_last_fetched_html(timestamp=timestamp, contents=handler.fetcher.content)
|
|
watch.save_last_fetched_pdf(timestamp=timestamp, contents=getattr(handler.fetcher, 'raw_content', None))
|
|
|
|
# Page title is shown in the list / used in notifications.
|
|
try:
|
|
if 'html' in update_obj.get('content-type', ''):
|
|
page_title = html_tools.extract_title(data=handler.fetcher.content)
|
|
if page_title:
|
|
datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title.strip()[:2000]})
|
|
except Exception as e:
|
|
logger.debug(f"Add Watch preloaded first-check: title extraction failed for {uuid}: {e}")
|
|
|
|
logger.info(f"Add Watch: created first snapshot for {uuid} from preloaded fetch (no network)")
|
|
return True
|
|
|
|
except Exception as e:
|
|
# Any processor error (empty text, no matching filters, etc.) - fall back to a normal check.
|
|
logger.warning(f"Add Watch: preloaded first-check failed for {uuid}, falling back to a normal check: {e}")
|
|
return False
|
|
|
|
|
|
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update):
|
|
views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates")
|
|
|
|
@views_blueprint.route("/form/add/quickwatch", methods=['POST'])
|
|
@login_optionally_required
|
|
def form_quick_watch_add():
|
|
from changedetectionio import forms
|
|
form = forms.quickWatchForm(request.form)
|
|
|
|
if not form.validate():
|
|
for widget, l in form.errors.items():
|
|
flash(','.join(l), 'error')
|
|
return redirect(url_for('watchlist.index'))
|
|
|
|
url = request.form.get('url').strip()
|
|
if datastore.url_exists(url):
|
|
flash(gettext('Warning, URL {} already exists').format(url), "notice")
|
|
|
|
add_paused = request.form.get('edit_and_watch_submit_button') != None
|
|
from changedetectionio import processors
|
|
processor = request.form.get('processor', processors.get_default_processor())
|
|
llm_intent = request.form.get('llm_intent', '').strip()
|
|
extras = {'paused': add_paused, 'processor': processor}
|
|
|
|
# Browser picked on the Add Watch page (validated against the live-preview capable
|
|
# list by the form). Absent from the watch-list quick-add, which leaves the new
|
|
# watch on the system default as before. A snapshot that recorded its own browser
|
|
# overrides this inside make_temporary_watch_active_watch().
|
|
if form.fetch_backend.data:
|
|
extras['fetch_backend'] = form.fetch_backend.data
|
|
if llm_intent:
|
|
extras['llm_intent'] = llm_intent
|
|
|
|
# Filters picked with the Add Watch visual selector ("by element" mode)
|
|
include_filters = [l.strip() for l in request.form.get('include_filters', '').split('\n') if l.strip()]
|
|
if include_filters:
|
|
extras['include_filters'] = include_filters
|
|
|
|
# If a live preview was fetched on the Add Watch page, promote that snapshot
|
|
# (screenshot + xpath) into the new watch instead of re-fetching. Works for both
|
|
# "Watch" and "Edit & Watch"; falls back to a normal add if the snapshot expired.
|
|
temporary_uuid = request.form.get('temporary_uuid', '').strip()
|
|
new_uuid = datastore.make_temporary_watch_active_watch(
|
|
temp_uuid=temporary_uuid,
|
|
url=url,
|
|
tag=request.form.get('tags', '').strip(),
|
|
extras=extras,
|
|
)
|
|
|
|
if new_uuid:
|
|
# If the Add Watch page already fetched a snapshot, run the processor now (no network)
|
|
# to create the first history snapshot. Returns False if there was no preload or it failed.
|
|
created_first_snapshot = run_preloaded_first_check(datastore, new_uuid)
|
|
|
|
if add_paused:
|
|
flash(gettext('Watch added in Paused state, saving will unpause.'))
|
|
return redirect(url_for('ui.ui_edit.edit_page', uuid=new_uuid, unpause_on_save=1, tag=request.args.get('tag')))
|
|
else:
|
|
# Only queue a network check if we couldn't build the first snapshot from the
|
|
# preloaded fetch - otherwise the watch is already populated.
|
|
if not created_first_snapshot:
|
|
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
|
flash(gettext("Watch added."))
|
|
|
|
return redirect(url_for('watchlist.index', tag=request.args.get('tag','')))
|
|
|
|
return views_blueprint
|