mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-07-08 08:21:14 +00:00
Bettter connection of adding new webpage
This commit is contained in:
@@ -44,6 +44,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
)
|
||||
from changedetectionio.browser_steps.browser_steps import browsersteps_live_ui
|
||||
|
||||
# Opportunistically sweep snapshots that were fetched but never saved.
|
||||
datastore.cleanup_temporary_watches()
|
||||
|
||||
url = (request.args.get('url') or '').strip()
|
||||
if not url or not url.lower().startswith(('http://', 'https://')):
|
||||
return make_response('Please enter a valid http(s):// URL', 400)
|
||||
@@ -64,12 +67,20 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
try:
|
||||
await stepper.connect(proxy=None)
|
||||
await stepper.call_action(action_name="Goto site", selector=None, optional_value=None)
|
||||
return await stepper.get_current_state()
|
||||
(screenshot, xpath_data) = await stepper.get_current_state()
|
||||
# Also grab the rendered HTML so the processor can run on submit without
|
||||
# re-fetching - this is the input the html->text conversion runs against.
|
||||
html = None
|
||||
try:
|
||||
html = await stepper.page.content()
|
||||
except Exception as e:
|
||||
logger.warning(f"Add-watch snapshot: could not capture page HTML for {url}: {e}")
|
||||
return (screenshot, xpath_data, html)
|
||||
finally:
|
||||
await _close_session_resources(session, label=' for add-watch snapshot')
|
||||
|
||||
try:
|
||||
(screenshot, xpath_data) = run_async_in_browser_loop(_fetch_snapshot())
|
||||
(screenshot, xpath_data, html) = run_async_in_browser_loop(_fetch_snapshot())
|
||||
except Exception as e:
|
||||
logger.error(f"Add-watch snapshot fetch failed for {url}: {e}")
|
||||
if 'ECONNREFUSED' in str(e):
|
||||
@@ -80,7 +91,30 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
if not screenshot:
|
||||
return make_response('Could not capture a screenshot for that URL', 502)
|
||||
|
||||
# Park the freshly-fetched data in final watch on-disk format so that, if the user
|
||||
# clicks Watch / Edit & Watch, we can promote it into a real watch with a single
|
||||
# rename() instead of fetching the page all over again.
|
||||
import os, json, zlib, uuid as uuid_builder
|
||||
temp_uuid = str(uuid_builder.uuid4())
|
||||
temp_dir = datastore.get_temporary_watch_dir(temp_uuid)
|
||||
try:
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
with open(os.path.join(temp_dir, "last-screenshot.png"), 'wb') as f:
|
||||
f.write(screenshot)
|
||||
with open(os.path.join(temp_dir, "elements.deflate"), 'wb') as f:
|
||||
f.write(zlib.compress(json.dumps(xpath_data).encode()))
|
||||
# The fetch result the processor will run against on submit (one-shot, consumed
|
||||
# by difference_detection_processor.call_browser). Only written when we got HTML.
|
||||
if html:
|
||||
with open(os.path.join(temp_dir, "preload-fetch.json"), 'w', encoding='utf-8') as f:
|
||||
json.dump({"content": html, "status_code": 200,
|
||||
"headers": {"content-type": "text/html"}}, f)
|
||||
except Exception as e:
|
||||
logger.error(f"Add-watch snapshot: could not park temporary data for {url}: {e}")
|
||||
temp_uuid = None
|
||||
|
||||
return jsonify({
|
||||
"temporary_uuid": temp_uuid,
|
||||
"screenshot": f"data:image/jpeg;base64,{base64.b64encode(screenshot).decode('ascii')}",
|
||||
"xpath_data": xpath_data,
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ $(document).ready(() => {
|
||||
const $byElement = $('#by-element-toggle');
|
||||
const $clear = $('#clear-selector');
|
||||
const $includeFilters = $('#include_filters');
|
||||
const $temporaryUuid = $('#temporary_uuid');
|
||||
|
||||
const vs = window.initVisualSelector({
|
||||
$canvas: $('#selector-canvas'),
|
||||
@@ -47,6 +48,8 @@ $(document).ready(() => {
|
||||
}
|
||||
|
||||
showState('loading');
|
||||
// A previous parked snapshot is now stale; drop it until this fetch succeeds.
|
||||
$temporaryUuid.val('');
|
||||
|
||||
$.ajax({
|
||||
url: add_watch_snapshot_url,
|
||||
@@ -54,6 +57,7 @@ $(document).ready(() => {
|
||||
dataType: 'json',
|
||||
}).done((data) => {
|
||||
showState('ready');
|
||||
$temporaryUuid.val(data.temporary_uuid || '');
|
||||
vs.load({screenshotSrc: data.screenshot, xpathData: data.xpath_data});
|
||||
}).fail((xhr) => {
|
||||
const msg = (xhr && xhr.responseText) ? xhr.responseText : 'Could not fetch a preview for that URL.';
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Filled in by the visual selector when "Select by element" is used -->
|
||||
<input type="hidden" name="include_filters" id="include_filters" value="">
|
||||
<!-- UUID of the parked live snapshot, so submit can promote it without re-fetching -->
|
||||
<input type="hidden" name="temporary_uuid" id="temporary_uuid" value="">
|
||||
|
||||
<fieldset>
|
||||
<legend>{{ _('Add a new web page change detection watch') }}</legend>
|
||||
|
||||
@@ -1,10 +1,73 @@
|
||||
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)
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -35,15 +98,31 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
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
|
||||
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags','').strip(), extras=extras)
|
||||
|
||||
# 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:
|
||||
# Straight into the queue.
|
||||
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
||||
# 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','')))
|
||||
|
||||
@@ -114,6 +114,64 @@ class difference_detection_processor():
|
||||
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
|
||||
)
|
||||
|
||||
def _consume_preloaded_fetch(self):
|
||||
"""One-shot: if the Add Watch page parked a freshly-fetched snapshot for this
|
||||
watch (html + screenshot + xpath, see add_watch_ui/snapshot), populate self.fetcher
|
||||
from it instead of hitting the network. The marker file is deleted after use so
|
||||
every subsequent check fetches live.
|
||||
|
||||
Returns True if a preload was consumed (caller should skip the network fetch).
|
||||
"""
|
||||
import json, zlib
|
||||
|
||||
data_dir = self.watch.data_dir
|
||||
if not data_dir:
|
||||
return False
|
||||
|
||||
preload_path = os.path.join(data_dir, 'preload-fetch.json')
|
||||
if not os.path.isfile(preload_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(preload_path, 'r', encoding='utf-8') as f:
|
||||
meta = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read preloaded fetch for {self.watch.get('uuid')}: {e}")
|
||||
try:
|
||||
os.unlink(preload_path)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Always delete first - this is one-shot regardless of what happens next.
|
||||
try:
|
||||
os.unlink(preload_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
content = meta.get('content')
|
||||
self.fetcher.content = content
|
||||
self.fetcher.raw_content = content.encode('utf-8', errors='replace') if isinstance(content, str) else content
|
||||
self.fetcher.status_code = meta.get('status_code', 200)
|
||||
self.fetcher.headers = meta.get('headers') or {'content-type': 'text/html'}
|
||||
|
||||
# Screenshot + xpath were migrated alongside in final on-disk format - reuse them.
|
||||
screenshot_path = os.path.join(data_dir, 'last-screenshot.png')
|
||||
if os.path.isfile(screenshot_path):
|
||||
with open(screenshot_path, 'rb') as f:
|
||||
self.fetcher.screenshot = f.read()
|
||||
|
||||
elements_path = os.path.join(data_dir, 'elements.deflate')
|
||||
if os.path.isfile(elements_path):
|
||||
try:
|
||||
with open(elements_path, 'rb') as f:
|
||||
self.fetcher.xpath_data = json.loads(zlib.decompress(f.read()))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load preloaded xpath data for {self.watch.get('uuid')}: {e}")
|
||||
|
||||
logger.info(f"Using preloaded Add-Watch snapshot for {self.watch.get('uuid')} - skipping network fetch")
|
||||
return True
|
||||
|
||||
async def call_browser(self, preferred_proxy_id=None):
|
||||
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
@@ -671,7 +671,13 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
self.__data['watching'][uuid].clear_watch()
|
||||
self.__data['watching'][uuid].commit()
|
||||
|
||||
def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=True):
|
||||
def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=True, seed_data_dir=None):
|
||||
"""
|
||||
seed_data_dir: optional path to an existing directory (already in the watch's
|
||||
on-disk format, e.g. last-screenshot.png + elements.deflate) that becomes this
|
||||
watch's data_dir via a single rename(). Used to "promote" a temporary add-watch
|
||||
snapshot into a real watch without re-fetching. See make_temporary_watch_active_watch().
|
||||
"""
|
||||
|
||||
if extras is None:
|
||||
extras = {}
|
||||
@@ -781,6 +787,17 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
apply_extras['date_created'] = int(time.time())
|
||||
|
||||
new_watch.update(apply_extras)
|
||||
|
||||
# Promote a pre-seeded directory into this watch's data_dir via a single rename()
|
||||
# (cheap: same filesystem, no copy). The seed must already be in final on-disk format.
|
||||
if seed_data_dir and os.path.isdir(seed_data_dir):
|
||||
target_dir = os.path.join(self.datastore_path, new_uuid)
|
||||
try:
|
||||
shutil.move(seed_data_dir, target_dir)
|
||||
logger.debug(f"Seeded watch {new_uuid} data_dir from {seed_data_dir}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not seed watch {new_uuid} from {seed_data_dir}: {e}")
|
||||
|
||||
new_watch.ensure_data_dir_exists()
|
||||
self.__data['watching'][new_uuid] = new_watch
|
||||
|
||||
@@ -793,6 +810,49 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
|
||||
return new_uuid
|
||||
|
||||
# Where add-watch-ui parks freshly-fetched (but not-yet-saved) snapshots.
|
||||
TEMPORARY_WATCH_DIR = "temporary"
|
||||
|
||||
def get_temporary_watch_dir(self, temp_uuid):
|
||||
"""Resolve datastore_path/temporary/{temp_uuid}, rejecting anything that would
|
||||
escape the temporary/ directory (the temp_uuid arrives from a client POST field)."""
|
||||
if not temp_uuid or not re.match(r'^[0-9a-fA-F-]{36}$', str(temp_uuid)):
|
||||
return None
|
||||
base = os.path.realpath(os.path.join(self.datastore_path, self.TEMPORARY_WATCH_DIR))
|
||||
target = os.path.realpath(os.path.join(base, str(temp_uuid)))
|
||||
if os.path.dirname(target) != base:
|
||||
logger.warning(f"Rejected temporary watch path outside {base}: {temp_uuid}")
|
||||
return None
|
||||
return target
|
||||
|
||||
def make_temporary_watch_active_watch(self, temp_uuid, url, tag='', extras=None):
|
||||
"""Promote a temporary add-watch snapshot into a real watch.
|
||||
|
||||
The snapshot directory (datastore_path/temporary/{temp_uuid}, already holding
|
||||
last-screenshot.png + elements.deflate in final on-disk format) is renamed into
|
||||
place as the new watch's data_dir - no re-fetch, no copy. If the temp_uuid is
|
||||
missing/expired/invalid we fall back to a normal add_watch() so the UI still works.
|
||||
"""
|
||||
seed_dir = self.get_temporary_watch_dir(temp_uuid)
|
||||
if not (seed_dir and os.path.isdir(seed_dir)):
|
||||
seed_dir = None
|
||||
return self.add_watch(url=url, tag=tag, extras=extras, seed_data_dir=seed_dir)
|
||||
|
||||
def cleanup_temporary_watches(self, ttl_seconds=3600):
|
||||
"""Remove orphaned snapshot dirs (user clicked Go but never submitted)."""
|
||||
base = os.path.join(self.datastore_path, self.TEMPORARY_WATCH_DIR)
|
||||
if not os.path.isdir(base):
|
||||
return
|
||||
now = time.time()
|
||||
for name in os.listdir(base):
|
||||
d = os.path.join(base, name)
|
||||
try:
|
||||
if os.path.isdir(d) and (now - os.path.getmtime(d)) > ttl_seconds:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
logger.debug(f"Cleaned orphaned temporary watch {name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed cleaning temporary watch {d}: {e}")
|
||||
|
||||
def _watch_resource_exists(self, watch_uuid, resource_name):
|
||||
"""
|
||||
Check if a watch-related resource exists.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for promoting an Add-Watch live snapshot into a real watch.
|
||||
|
||||
The Add Watch UI fetches a screenshot + xpath element data once (the "/snapshot"
|
||||
endpoint), parks it under datastore/temporary/{uuid} in final on-disk format, and
|
||||
on submit renames that directory into place as the new watch's data_dir - so the
|
||||
watch is created without fetching the page a second time.
|
||||
|
||||
Run from the tests/ directory:
|
||||
python -m unittest unit/test_temporary_watch_seed.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
import zlib
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
|
||||
|
||||
class TestTemporaryWatchSeed(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.test_datastore_path = tempfile.mkdtemp()
|
||||
self.store = ChangeDetectionStore(
|
||||
datastore_path=self.test_datastore_path,
|
||||
include_default_watches=False,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.stop_thread = True
|
||||
time.sleep(0.5)
|
||||
shutil.rmtree(self.test_datastore_path, ignore_errors=True)
|
||||
|
||||
def _make_temp_snapshot(self, temp_uuid=None):
|
||||
"""Create a temporary/{uuid} dir in final watch on-disk format, as /snapshot does."""
|
||||
temp_uuid = temp_uuid or str(uuid.uuid4())
|
||||
temp_dir = self.store.get_temporary_watch_dir(temp_uuid)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
with open(os.path.join(temp_dir, "last-screenshot.png"), 'wb') as f:
|
||||
f.write(b"FAKE-SCREENSHOT-BYTES")
|
||||
with open(os.path.join(temp_dir, "elements.deflate"), 'wb') as f:
|
||||
f.write(zlib.compress(json.dumps([{"xpath": "//h1", "width": 10}]).encode()))
|
||||
return temp_uuid, temp_dir
|
||||
|
||||
def test_promote_renames_snapshot_into_watch(self):
|
||||
temp_uuid, temp_dir = self._make_temp_snapshot()
|
||||
|
||||
new_uuid = self.store.make_temporary_watch_active_watch(
|
||||
temp_uuid=temp_uuid,
|
||||
url="https://example.com",
|
||||
extras={'paused': True},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(new_uuid)
|
||||
watch = self.store.data['watching'][new_uuid]
|
||||
|
||||
# The two seed files are now in the watch's data_dir...
|
||||
self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "last-screenshot.png")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "elements.deflate")))
|
||||
# ...the visual selector reports ready (no re-fetch needed)...
|
||||
self.assertTrue(self.store.visualselector_data_is_ready(new_uuid))
|
||||
# ...the watch.json was committed alongside the seeded files...
|
||||
self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "watch.json")))
|
||||
# ...and it was a move, not a copy: the temp dir is gone.
|
||||
self.assertFalse(os.path.exists(temp_dir))
|
||||
|
||||
# Screenshot content survived the rename intact.
|
||||
with open(os.path.join(watch.data_dir, "last-screenshot.png"), 'rb') as f:
|
||||
self.assertEqual(f.read(), b"FAKE-SCREENSHOT-BYTES")
|
||||
|
||||
def test_falls_back_to_normal_add_when_uuid_missing(self):
|
||||
# No snapshot was ever parked - submit must still create a working watch.
|
||||
new_uuid = self.store.make_temporary_watch_active_watch(
|
||||
temp_uuid='',
|
||||
url="https://example.com",
|
||||
)
|
||||
self.assertIsNotNone(new_uuid)
|
||||
self.assertIn(new_uuid, self.store.data['watching'])
|
||||
self.assertFalse(self.store.visualselector_data_is_ready(new_uuid))
|
||||
|
||||
def test_falls_back_when_temp_dir_does_not_exist(self):
|
||||
# A well-formed but unknown uuid (e.g. expired/cleaned) must not error.
|
||||
new_uuid = self.store.make_temporary_watch_active_watch(
|
||||
temp_uuid=str(uuid.uuid4()),
|
||||
url="https://example.com",
|
||||
)
|
||||
self.assertIsNotNone(new_uuid)
|
||||
self.assertIn(new_uuid, self.store.data['watching'])
|
||||
|
||||
def test_path_traversal_uuid_is_rejected(self):
|
||||
# The temp_uuid arrives from a client POST field - must not escape temporary/.
|
||||
self.assertIsNone(self.store.get_temporary_watch_dir("../../etc"))
|
||||
self.assertIsNone(self.store.get_temporary_watch_dir("not-a-uuid"))
|
||||
self.assertIsNone(self.store.get_temporary_watch_dir(""))
|
||||
self.assertIsNone(self.store.get_temporary_watch_dir(None))
|
||||
# A valid v4 uuid resolves to a path directly under temporary/
|
||||
good = str(uuid.uuid4())
|
||||
resolved = self.store.get_temporary_watch_dir(good)
|
||||
self.assertIsNotNone(resolved)
|
||||
self.assertEqual(os.path.basename(resolved), good)
|
||||
|
||||
def test_processor_consumes_preloaded_fetch_without_network(self):
|
||||
# Create a watch and drop a preload bundle into its data_dir, as the Add Watch
|
||||
# snapshot -> migrate flow would. The processor's call_browser() seam should then
|
||||
# populate its fetcher from disk instead of hitting the network.
|
||||
from changedetectionio.processors.text_json_diff.processor import perform_site_check
|
||||
|
||||
new_uuid = self.store.add_watch(url="https://example.com", extras={'paused': True})
|
||||
watch = self.store.data['watching'][new_uuid]
|
||||
watch.ensure_data_dir_exists()
|
||||
|
||||
html = "<html><body><h1>Hello preloaded world</h1></body></html>"
|
||||
with open(os.path.join(watch.data_dir, "last-screenshot.png"), 'wb') as f:
|
||||
f.write(b"FAKE-SCREENSHOT-BYTES")
|
||||
with open(os.path.join(watch.data_dir, "elements.deflate"), 'wb') as f:
|
||||
f.write(zlib.compress(json.dumps([{"xpath": "//h1"}]).encode()))
|
||||
preload_path = os.path.join(watch.data_dir, "preload-fetch.json")
|
||||
with open(preload_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({"content": html, "status_code": 200,
|
||||
"headers": {"content-type": "text/html"}}, f)
|
||||
|
||||
handler = perform_site_check(datastore=self.store, watch_uuid=new_uuid)
|
||||
consumed = handler._consume_preloaded_fetch()
|
||||
|
||||
self.assertTrue(consumed)
|
||||
# Fetcher is populated as if a real fetch happened...
|
||||
self.assertEqual(handler.fetcher.content, html)
|
||||
self.assertEqual(handler.fetcher.get_last_status_code(), 200)
|
||||
self.assertEqual(handler.fetcher.get_all_headers().get('content-type'), 'text/html')
|
||||
self.assertEqual(handler.fetcher.screenshot, b"FAKE-SCREENSHOT-BYTES")
|
||||
self.assertEqual(handler.fetcher.xpath_data, [{"xpath": "//h1"}])
|
||||
# ...and the marker is gone, so the next check fetches live (one-shot).
|
||||
self.assertFalse(os.path.exists(preload_path))
|
||||
self.assertFalse(handler._consume_preloaded_fetch())
|
||||
|
||||
def test_processor_runs_changedetection_on_preloaded_content(self):
|
||||
# End-to-end of the worker's two key steps without network: consume preload,
|
||||
# then run the processor -> it should produce extracted text + a checksum.
|
||||
from changedetectionio.processors.text_json_diff.processor import perform_site_check
|
||||
|
||||
new_uuid = self.store.add_watch(url="https://example.com", extras={'paused': True})
|
||||
watch = self.store.data['watching'][new_uuid]
|
||||
watch.ensure_data_dir_exists()
|
||||
with open(os.path.join(watch.data_dir, "preload-fetch.json"), 'w', encoding='utf-8') as f:
|
||||
json.dump({"content": "<html><body><h1>Detect me</h1></body></html>",
|
||||
"status_code": 200, "headers": {"content-type": "text/html"}}, f)
|
||||
|
||||
handler = perform_site_check(datastore=self.store, watch_uuid=new_uuid)
|
||||
self.assertTrue(handler._consume_preloaded_fetch())
|
||||
|
||||
changed_detected, update_obj, contents = handler.run_changedetection(watch=handler.watch)
|
||||
|
||||
# First run is always a change; extracted text must contain the page text.
|
||||
self.assertTrue(changed_detected)
|
||||
self.assertIn("Detect me", contents)
|
||||
self.assertTrue(update_obj.get('previous_md5'))
|
||||
|
||||
def test_run_preloaded_first_check_creates_history(self):
|
||||
# The blueprint helper should turn a parked snapshot into a real first history
|
||||
# snapshot (history.txt + {timestamp}.txt.br) with no network.
|
||||
from changedetectionio.blueprint.ui.views import run_preloaded_first_check
|
||||
|
||||
new_uuid = self.store.add_watch(url="https://example.com", extras={'paused': True})
|
||||
watch = self.store.data['watching'][new_uuid]
|
||||
watch.ensure_data_dir_exists()
|
||||
with open(os.path.join(watch.data_dir, "preload-fetch.json"), 'w', encoding='utf-8') as f:
|
||||
json.dump({"content": "<html><head><title>T</title></head><body><h1>Detect me</h1></body></html>",
|
||||
"status_code": 200, "headers": {"content-type": "text/html"}}, f)
|
||||
|
||||
created = run_preloaded_first_check(self.store, new_uuid)
|
||||
|
||||
self.assertTrue(created)
|
||||
self.assertEqual(watch.history_n, 1)
|
||||
# last_checked is stamped to "now" since we effectively just checked it.
|
||||
self.assertTrue(watch.get('last_checked'))
|
||||
# The extracted text snapshot is retrievable and holds the page text.
|
||||
latest_ts = list(watch.history.keys())[-1]
|
||||
self.assertIn("Detect me", watch.get_history_snapshot(timestamp=latest_ts))
|
||||
# One-shot: the preload marker is consumed.
|
||||
self.assertFalse(os.path.exists(os.path.join(watch.data_dir, "preload-fetch.json")))
|
||||
|
||||
def test_run_preloaded_first_check_no_preload_returns_false(self):
|
||||
from changedetectionio.blueprint.ui.views import run_preloaded_first_check
|
||||
new_uuid = self.store.add_watch(url="https://example.com")
|
||||
self.assertFalse(run_preloaded_first_check(self.store, new_uuid))
|
||||
self.assertEqual(self.store.data['watching'][new_uuid].history_n, 0)
|
||||
|
||||
def test_cleanup_removes_only_stale_snapshots(self):
|
||||
stale_uuid, stale_dir = self._make_temp_snapshot()
|
||||
fresh_uuid, fresh_dir = self._make_temp_snapshot()
|
||||
|
||||
# Age the stale one well past the TTL.
|
||||
old = time.time() - 7200
|
||||
os.utime(stale_dir, (old, old))
|
||||
|
||||
self.store.cleanup_temporary_watches(ttl_seconds=3600)
|
||||
|
||||
self.assertFalse(os.path.exists(stale_dir))
|
||||
self.assertTrue(os.path.exists(fresh_dir))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user