Compare commits

..

21 Commits

Author SHA1 Message Date
dgtlmoon
50fe822d8e Adding random tips
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 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
2026-02-04 14:22:26 +01:00
dgtlmoon
a7ea79e1fc Improve test for missing processor 2026-02-04 14:10:30 +01:00
dgtlmoon
9921d303de tweaks for when the processor is not found 2026-02-04 13:56:55 +01:00
dgtlmoon
7832787b84 Improve tests 2026-02-04 13:36:43 +01:00
dgtlmoon
bd5f7187f7 oops 2026-02-04 13:24:33 +01:00
dgtlmoon
e2f9fdb384 Merge branch 'processor-plugin-improvements' of github.com:dgtlmoon/changedetection.io into processor-plugin-improvements 2026-02-04 13:13:10 +01:00
dgtlmoon
261f88b272 Add test 2026-02-04 13:13:00 +01:00
dgtlmoon
8d2e668b42 Fix name 2026-02-04 13:12:57 +01:00
dgtlmoon
c7050077be Apply suggestion from @dgtlmoon
Some checks failed
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 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
2026-02-03 15:55:17 +01:00
dgtlmoon
5f25e3825c Closes #3814 2026-02-03 15:49:54 +01:00
dgtlmoon
041c1ad531 Better support for capabilities 2026-02-03 15:39:50 +01:00
dgtlmoon
e958acebed Processors can specify what capabilities they support (visual selector, text filters etc) 2026-02-03 15:31:44 +01:00
dgtlmoon
b826d9b236 Bump docs
Some checks failed
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 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
2026-02-03 13:51:15 +01:00
dgtlmoon
bc3efbff27 Merge branch 'master' into processor-plugin-improvements 2026-02-03 11:12:57 +01:00
dgtlmoon
063ee38099 Merge branch 'master' into processor-plugin-improvements
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 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
2026-01-18 00:56:30 +01:00
dgtlmoon
5007b8201e Merge branch 'master' into processor-plugin-improvements
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 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
2026-01-15 23:14:30 +01:00
dgtlmoon
7e853a4b46 Configurable paackages 2026-01-14 19:01:30 +01:00
dgtlmoon
4dc5301de4 Downgrade for osint plugin and handle missing processors better 2026-01-14 18:22:23 +01:00
dgtlmoon
edf0989cd4 Put the type badge first in the list 2026-01-14 17:34:54 +01:00
dgtlmoon
423b546948 Refactor of processor extra configs 2026-01-14 17:29:20 +01:00
dgtlmoon
c1c810a79a Misc fixes for processor plugins 2026-01-14 16:59:20 +01:00
42 changed files with 365 additions and 1266 deletions

View File

@@ -66,42 +66,47 @@ class Watch(Resource):
@validate_openapi_request('getWatch')
def get(self, uuid):
"""Get information about a single watch, recheck, pause, or mute."""
# Get watch reference first (for pause/mute operations)
watch_obj = self.datastore.data['watching'].get(uuid)
if not watch_obj:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
import time
from copy import deepcopy
watch = None
# Retry up to 20 times if dict is being modified
# With sleep(0), this is fast: ~200µs best case, ~20ms worst case under heavy load
for attempt in range(20):
try:
watch = deepcopy(self.datastore.data['watching'].get(uuid))
break
except RuntimeError:
# Dict changed during deepcopy, retry after yielding to scheduler
# sleep(0) releases GIL and yields - no fixed delay, just lets other threads run
if attempt < 19: # Don't yield on last attempt
time.sleep(0) # Yield to scheduler (microseconds, not milliseconds)
# Create a dict copy for JSON response (with lock for thread safety)
# This is much faster than deepcopy and doesn't copy the datastore reference
# WARNING: dict() is a SHALLOW copy - nested dicts are shared with original!
# Only safe because we only ADD scalar properties (line 97-101), never modify nested dicts
# If you need to modify nested dicts, use: from copy import deepcopy; watch = deepcopy(dict(watch_obj))
with self.datastore.lock:
watch = dict(watch_obj)
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
if request.args.get('recheck'):
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
return "OK", 200
if request.args.get('paused', '') == 'paused':
watch_obj.pause()
self.datastore.data['watching'].get(uuid).pause()
return "OK", 200
elif request.args.get('paused', '') == 'unpaused':
watch_obj.unpause()
self.datastore.data['watching'].get(uuid).unpause()
return "OK", 200
if request.args.get('muted', '') == 'muted':
watch_obj.mute()
self.datastore.data['watching'].get(uuid).mute()
return "OK", 200
elif request.args.get('muted', '') == 'unmuted':
watch_obj.unmute()
self.datastore.data['watching'].get(uuid).unmute()
return "OK", 200
# Return without history, get that via another API call
# Properties are not returned as a JSON, so add the required props manually
watch['history_n'] = watch_obj.history_n
watch['history_n'] = watch.history_n
# attr .last_changed will check for the last written text snapshot on change
watch['last_changed'] = watch_obj.last_changed
watch['viewed'] = watch_obj.viewed
watch['link'] = watch_obj.link,
watch['last_changed'] = watch.last_changed
watch['viewed'] = watch.viewed
watch['link'] = watch.link,
return watch

View File

@@ -3,10 +3,10 @@
{% from '_helpers.html' import render_simple_field, render_field %}
<div class="edit-form">
<div class="box-wrap inner">
<h2>{{ _('Backups') }}</h2>
<h4>{{ _('Backups') }}</h4>
{% if backup_running %}
<p>
<span class="spinner"></span>&nbsp;<strong>{{ _('A backup is running!') }}</strong>
<strong>{{ _('A backup is running!') }}</strong>
</p>
{% endif %}
<p>

View File

@@ -25,7 +25,7 @@
<li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li>
<li class="tab"><a href="#api">{{ _('API') }}</a></li>
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
<li class="tab"><a href="{{ url_for('backups.index') }}">{{ _('Backups') }}</a></li>
<li class="tab"><a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('Backups') }}</a></li>
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
{% if plugin_tabs %}
@@ -59,14 +59,6 @@
{{ _('Set to') }} <strong>0</strong> {{ _('to disable') }}
</span>
</div>
<div class="pure-control-group">
{{ render_field(form.application.form.history_snapshot_max_length, class="history_snapshot_max_length") }}
<span class="pure-form-message-inline">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}
<br>
{{ _('Set to empty to disable / no limit') }}
</span>
</div>
<div class="pure-control-group">
{% if not hide_remove_pass %}
{% if current_user.is_authenticated %}

View File

@@ -197,7 +197,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Recast it if need be to right data Watch handler
watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor'))
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, __datastore=datastore.data, default=datastore.data['watching'][uuid])
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch."))
# Cleanup any browsersteps session for this watch

View File

@@ -115,13 +115,6 @@
{{ _('Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.') }}
</span>
</div>
<div class="pure-control-group">
{{ render_field(form.history_snapshot_max_length, class="history_snapshot_max_length") }}
<span class="pure-form-message-inline">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}
<br>
{{ _('Set to empty to use system settings default') }}
</span>
</div>
<div class="pure-control-group">
{{ render_ternary_field(form.use_page_title_in_list) }}
</div>

View File

@@ -26,7 +26,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
add_paused = request.form.get('edit_and_watch_submit_button') != None
from changedetectionio import processors
processor = request.form.get('processor', processors.get_default_processor())
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags','').strip(), extras={'paused': add_paused, 'processor': processor})
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})
if new_uuid:
if add_paused:
@@ -39,4 +39,4 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
return redirect(url_for('watchlist.index', tag=request.args.get('tag','')))
return views_blueprint
return views_blueprint

View File

@@ -14,46 +14,6 @@
// Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() {
feather.replace();
// Intersection Observer for lazy loading favicons
// Only load favicon images when they enter the viewport
if ('IntersectionObserver' in window) {
const faviconObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
// Load the actual favicon
img.src = src;
img.removeAttribute('data-src');
}
// Stop observing this image
observer.unobserve(img);
}
});
}, {
// Start loading slightly before the image enters viewport
rootMargin: '50px',
threshold: 0.01
});
// Observe all lazy favicon images
document.querySelectorAll('.lazy-favicon').forEach(img => {
faviconObserver.observe(img);
});
} else {
// Fallback for older browsers: load all favicons immediately
document.querySelectorAll('.lazy-favicon').forEach(img => {
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
}
});
}
});
</script>
<style>
@@ -246,17 +206,8 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<td class="title-col inline">
<div class="flex-wrapper">
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
<div>
{# Intersection Observer lazy loading: store real URL in data-src, load only when visible in viewport #}
<img alt="Favicon thumbnail"
class="favicon lazy-favicon"
loading="lazy"
decoding="async"
fetchpriority="low"
{% if favicon %}
data-src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}"
{% endif %}
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'>
<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>

View File

@@ -1,4 +1,3 @@
import asyncio
import gc
import json
import os
@@ -350,7 +349,12 @@ class fetcher(Fetcher):
if self.status_code != 200 and not ignore_status_codes:
screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)
# Finally block will handle cleanup
# Cleanup before raising to prevent memory leak
await self.page.close()
await context.close()
await browser.close()
# Force garbage collection to release Playwright resources immediately
gc.collect()
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0:
@@ -366,7 +370,12 @@ class fetcher(Fetcher):
try:
await self.iterate_browser_steps(start_url=url)
except BrowserStepsStepException:
# Finally block will handle cleanup
try:
await context.close()
await browser.close()
except Exception as e:
# Fine, could be messy situation
pass
raise
await self.page.wait_for_timeout(extra_wait * 1000)
@@ -415,40 +424,35 @@ class fetcher(Fetcher):
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
finally:
# Clean up resources properly with timeouts to prevent hanging
# Request garbage collection one more time before closing
try:
if hasattr(self, 'page') and self.page:
await self.page.request_gc()
await asyncio.wait_for(self.page.close(), timeout=5.0)
logger.debug(f"Successfully closed page for {url}")
except asyncio.TimeoutError:
logger.warning(f"Timed out closing page for {url} (5s)")
except Exception as e:
logger.warning(f"Error closing page for {url}: {e}")
finally:
self.page = None
await self.page.request_gc()
except:
pass
# Clean up resources properly
try:
await self.page.request_gc()
except:
pass
try:
if context:
await asyncio.wait_for(context.close(), timeout=5.0)
logger.debug(f"Successfully closed context for {url}")
except asyncio.TimeoutError:
logger.warning(f"Timed out closing context for {url} (5s)")
except Exception as e:
logger.warning(f"Error closing context for {url}: {e}")
finally:
context = None
await self.page.close()
except:
pass
self.page = None
try:
if browser:
await asyncio.wait_for(browser.close(), timeout=5.0)
logger.debug(f"Successfully closed browser connection for {url}")
except asyncio.TimeoutError:
logger.warning(f"Timed out closing browser connection for {url} (5s)")
except Exception as e:
logger.warning(f"Error closing browser for {url}: {e}")
finally:
browser = None
await context.close()
except:
pass
context = None
try:
await browser.close()
except:
pass
browser = None
# Force Python GC to release Playwright resources immediately
# Playwright objects can have circular references that delay cleanup

View File

@@ -1,5 +1,4 @@
import asyncio
import gc
import json
import os
import websockets.exceptions
@@ -222,36 +221,19 @@ class fetcher(Fetcher):
self.browser_connection_url += f"{r}--proxy-server={proxy_url}"
async def quit(self, watch=None):
watch_uuid = watch.get('uuid') if watch else 'unknown'
# Close page
try:
if hasattr(self, 'page') and self.page:
await asyncio.wait_for(self.page.close(), timeout=5.0)
logger.debug(f"[{watch_uuid}] Page closed successfully")
except asyncio.TimeoutError:
logger.warning(f"[{watch_uuid}] Timed out closing page (5s)")
await self.page.close()
del self.page
except Exception as e:
logger.warning(f"[{watch_uuid}] Error closing page: {e}")
finally:
self.page = None
pass
# Close browser connection
try:
if hasattr(self, 'browser') and self.browser:
await asyncio.wait_for(self.browser.close(), timeout=5.0)
logger.debug(f"[{watch_uuid}] Browser closed successfully")
except asyncio.TimeoutError:
logger.warning(f"[{watch_uuid}] Timed out closing browser (5s)")
await self.browser.close()
del self.browser
except Exception as e:
logger.warning(f"[{watch_uuid}] Error closing browser: {e}")
finally:
self.browser = None
pass
logger.info(f"[{watch_uuid}] Cleanup puppeteer complete")
# Force garbage collection to release resources
gc.collect()
logger.info("Cleanup puppeteer complete.")
async def fetch_page(self,
current_include_filters,
@@ -281,11 +263,9 @@ class fetcher(Fetcher):
# Connect directly using the specified browser_ws_endpoint
# @todo timeout
try:
logger.debug(f"[{watch_uuid}] Connecting to browser at {self.browser_connection_url}")
self.browser = await pyppeteer_instance.connect(browserWSEndpoint=self.browser_connection_url,
ignoreHTTPSErrors=True
)
logger.debug(f"[{watch_uuid}] Browser connected successfully")
except websockets.exceptions.InvalidStatusCode as e:
raise BrowserConnectError(msg=f"Error while trying to connect the browser, Code {e.status_code} (check your access, whitelist IP, password etc)")
except websockets.exceptions.InvalidURI:
@@ -294,18 +274,7 @@ class fetcher(Fetcher):
raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'")
# more reliable is to just request a new page
try:
logger.debug(f"[{watch_uuid}] Creating new page")
self.page = await self.browser.newPage()
logger.debug(f"[{watch_uuid}] Page created successfully")
except Exception as e:
logger.error(f"[{watch_uuid}] Failed to create new page: {e}")
# Browser is connected but page creation failed - must cleanup browser
try:
await asyncio.wait_for(self.browser.close(), timeout=3.0)
except Exception as cleanup_error:
logger.error(f"[{watch_uuid}] Failed to cleanup browser after page creation failure: {cleanup_error}")
raise
self.page = await self.browser.newPage()
# Add console handler to capture console.log from favicon fetcher
#self.page.on('console', lambda msg: logger.debug(f"Browser console [{msg.type}]: {msg.text}"))
@@ -374,12 +343,6 @@ class fetcher(Fetcher):
w = extra_wait - 2 if extra_wait > 4 else 2
logger.debug(f"Waiting {w} seconds before calling Page.stopLoading...")
await asyncio.sleep(w)
# Check if page still exists (might have been closed due to error during sleep)
if not self.page or not hasattr(self.page, '_client'):
logger.debug("Page already closed, skipping stopLoading")
return
logger.debug("Issuing stopLoading command...")
await self.page._client.send('Page.stopLoading')
logger.debug("stopLoading command sent!")
@@ -405,9 +368,7 @@ class fetcher(Fetcher):
asyncio.create_task(handle_frame_navigation())
response = await self.page.goto(url, timeout=0)
await asyncio.sleep(1 + extra_wait)
# Check if page still exists before sending command
if self.page and hasattr(self.page, '_client'):
await self.page._client.send('Page.stopLoading')
await self.page._client.send('Page.stopLoading')
if response:
break
@@ -476,9 +437,15 @@ class fetcher(Fetcher):
logger.debug(f"Screenshot format {self.screenshot_format}")
self.screenshot = await capture_full_page(page=self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)
# Force garbage collection - pyppeteer base64 decode creates temporary buffers
# Force aggressive memory cleanup - pyppeteer base64 decode creates temporary buffers
import gc
gc.collect()
# Release C-level memory from base64 decode back to OS
try:
import ctypes
ctypes.CDLL('libc.so.6').malloc_trim(0)
except Exception:
pass
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
"visualselector_xpath_selectors": visualselector_xpath_selectors,
"max_height": MAX_TOTAL_HEIGHT

View File

@@ -837,8 +837,6 @@ class processor_text_json_diff_form(commonSettingsForm):
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> in list'), default=None)
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
def extra_tab_content(self):
return None
@@ -1036,8 +1034,6 @@ class globalSettingsApplicationForm(commonSettingsForm):
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0,
message=_l("Should contain zero or more attempts"))])
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
ui = FormField(globalSettingsApplicationUIForm)

View File

@@ -52,13 +52,7 @@ def render(template_str, **args: t.Any) -> str:
return output[:JINJA2_MAX_RETURN_PAYLOAD_SIZE]
def render_fully_escaped(content):
"""
Escape HTML content safely.
MEMORY LEAK FIX: Use markupsafe.escape() directly instead of creating
Jinja2 environments (was causing 1M+ compilations per page load).
Simpler, faster, and no concerns about environment state.
"""
from markupsafe import escape
return str(escape(content))
env = jinja2.sandbox.ImmutableSandboxedEnvironment(autoescape=True)
template = env.from_string("{{ some_html|e }}")
return template.render(some_html=content)

View File

@@ -46,7 +46,6 @@ class model(dict):
'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT,
'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum
'global_subtractive_selectors': [],
'history_snapshot_max_length': None,
'ignore_whitespace': True,
'ignore_status_codes': False, #@todo implement, as ternary.
'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison

View File

@@ -5,11 +5,6 @@ from changedetectionio.model import watch_base
class model(watch_base):
def __init__(self, *arg, **kw):
# Store datastore reference (optional for Tags, but good for consistency)
self.__datastore = kw.get('__datastore')
if kw.get('__datastore'):
del kw['__datastore']
super(model, self).__init__(*arg, **kw)
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')

View File

@@ -1,6 +1,3 @@
import gc
from copy import copy
from blinker import signal
from changedetectionio.validate_url import is_safe_valid_url
@@ -16,7 +13,7 @@ from .. import jinja2_custom as safe_jinja
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024*20))
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
@@ -112,15 +109,8 @@ class model(watch_base):
self.__datastore_path = kw.get('datastore_path')
if kw.get('datastore_path'):
del kw['datastore_path']
self.__datastore = kw.get('__datastore')
if not self.__datastore:
raise ValueError("Watch object requires '__datastore' reference - cannot access global settings without it")
if kw.get('__datastore'):
del kw['__datastore']
super(model, self).__init__(*arg, **kw)
if kw.get('default'):
self.update(kw['default'])
del kw['default']
@@ -131,9 +121,6 @@ class model(watch_base):
# Be sure the cached timestamp is ready
bump = self.history
# Note: __deepcopy__, __getstate__, and __setstate__ are inherited from watch_base
# This prevents memory leaks by sharing __datastore reference instead of copying it
@property
def viewed(self):
# Don't return viewed when last_viewed is 0 and newest_key is 0
@@ -436,49 +423,16 @@ class model(watch_base):
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
def _write_atomic(self, dest, data, mode='wb'):
def _write_atomic(self, dest, data):
"""Write data atomically to dest using a temp file"""
import tempfile
with tempfile.NamedTemporaryFile(mode, delete=False, dir=self.watch_data_dir) as tmp:
tmp.write(data)
tmp.flush()
os.fsync(tmp.fileno())
tmp_path = tmp.name
os.replace(tmp_path, dest)
def history_trim(self, newest_n_items):
from pathlib import Path
# Sort by timestamp (key)
sorted_items = sorted(self.history.items(), key=lambda x: int(x[0]))
keep_part = dict(sorted_items[-newest_n_items:])
delete_part = dict(sorted_items[:-newest_n_items])
logger.info( f"[{self.get('uuid')}] Trimming history to most recent {newest_n_items} items, keeping {len(keep_part)} items deleting {len(delete_part)} items.")
if delete_part:
for item in delete_part.items():
try:
Path(item[1]).unlink(missing_ok=True)
except Exception as e:
logger.critical(f"{str(e)}")
finally:
logger.debug(f"[{self.get('uuid')}] Deleted {item[1]} history snapshot")
try:
dest = os.path.join(self.watch_data_dir, self.history_index_filename)
output = "\r\n".join(
f"{k},{Path(v).name}"
for k, v in keep_part.items()
)+"\r\n"
self._write_atomic(dest=dest, data=output, mode='w')
except Exception as e:
logger.critical(f"{str(e)}")
finally:
logger.debug(f"[{self.get('uuid')}] Updated history index {dest}")
# reimport
bump = self.history
gc.collect()
if not os.path.exists(dest):
import tempfile
with tempfile.NamedTemporaryFile('wb', delete=False, dir=self.watch_data_dir) as tmp:
tmp.write(data)
tmp.flush()
os.fsync(tmp.fileno())
tmp_path = tmp.name
os.replace(tmp_path, dest)
# Save some text file to the appropriate path and bump the history
# result_obj from fetch_site_status.run()
@@ -487,6 +441,7 @@ class model(watch_base):
logger.trace(f"{self.get('uuid')} - Updating {self.history_index_filename} with timestamp {timestamp}")
self.ensure_data_dir_exists()
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
# Binary data - detect file type and save without compression
@@ -546,15 +501,6 @@ class model(watch_base):
self.__newest_history_key = timestamp
self.__history_n += 1
maxlen = (
self.get('history_snapshot_max_length')
or (self.__datastore and self.__datastore['settings']['application'].get('history_snapshot_max_length'))
)
if maxlen and self.__history_n and self.__history_n > maxlen:
self.history_trim(newest_n_items=maxlen)
# @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status
return snapshot_fname
@@ -667,11 +613,6 @@ class model(watch_base):
try:
with open(fname, 'wb') as f:
f.write(decoded)
# Invalidate favicon filename cache
if hasattr(self, '_favicon_filename_cache'):
delattr(self, '_favicon_filename_cache')
# A signal that could trigger the socket server to update the browser also
watch_check_update = signal('watch_favicon_bump')
if watch_check_update:
@@ -688,32 +629,20 @@ class model(watch_base):
Find any favicon.* file in the current working directory
and return the contents of the newest one.
MEMORY LEAK FIX: Cache the result to avoid repeated glob.glob() operations.
glob.glob() causes millions of fnmatch allocations when called for every watch on page load.
Returns:
str: Basename of the newest favicon file, or None if not found.
bytes: Contents of the newest favicon file, or None if not found.
"""
# Check cache first (prevents 26M+ allocations from repeated glob operations)
cache_key = '_favicon_filename_cache'
if hasattr(self, cache_key):
return getattr(self, cache_key)
import glob
# Search for all favicon.* files
files = glob.glob(os.path.join(self.watch_data_dir, "favicon.*"))
if not files:
result = None
else:
# Find the newest by modification time
newest_file = max(files, key=os.path.getmtime)
result = os.path.basename(newest_file)
return None
# Cache the result
setattr(self, cache_key, result)
return result
# 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.

View File

@@ -32,7 +32,6 @@ class watch_base(dict):
'filter_text_replaced': True,
'follow_price_changes': True,
'has_ldjson_price_data': None,
'history_snapshot_max_length': None,
'headers': {}, # Extra headers to send
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'ignore_status_codes': None,
@@ -140,100 +139,4 @@ class watch_base(dict):
super(watch_base, self).__init__(*arg, **kw)
if self.get('default'):
del self['default']
def __deepcopy__(self, memo):
"""
Custom deepcopy for all watch_base subclasses (Watch, Tag, etc.).
CRITICAL FIX: Prevents copying large reference objects like __datastore
which would cause exponential memory growth when Watch objects are deepcopied.
This is called by:
- api/Watch.py:76 (API endpoint)
- api/Tags.py:28 (Tags API)
- processors/base.py:26 (EVERY processor run)
- store/__init__.py:544 (clone watch)
- And other locations
"""
from copy import deepcopy
# Create new instance without calling __init__
cls = self.__class__
new_obj = cls.__new__(cls)
memo[id(self)] = new_obj
# Copy the dict data (all the settings)
for key, value in self.items():
new_obj[key] = deepcopy(value, memo)
# Copy instance attributes dynamically
# This handles Watch-specific attrs (like __datastore) and any future subclass attrs
for attr_name in dir(self):
# Skip methods, special attrs, and dict keys
if attr_name.startswith('_') and not attr_name.startswith('__'):
# This catches _model__datastore, _model__history_n, etc.
try:
attr_value = getattr(self, attr_name)
# Special handling: Share references to large objects instead of copying
# Examples: __datastore, __app_reference, __global_settings, etc.
if attr_name.endswith('__datastore') or attr_name.endswith('__app'):
# Share the reference (don't copy!) to prevent memory leaks
setattr(new_obj, attr_name, attr_value)
# Skip cache attributes - let them regenerate on demand
elif 'cache' in attr_name.lower():
pass # Don't copy caches
# Copy regular instance attributes
elif not callable(attr_value):
setattr(new_obj, attr_name, attr_value)
except AttributeError:
pass # Attribute doesn't exist in this instance
return new_obj
def __getstate__(self):
"""
Custom pickle serialization for all watch_base subclasses.
Excludes large reference objects (like __datastore) from serialization.
"""
# Get the dict data
state = dict(self)
# Collect instance attributes (excluding methods and large references)
instance_attrs = {}
for attr_name in dir(self):
if attr_name.startswith('_') and not attr_name.startswith('__'):
try:
attr_value = getattr(self, attr_name)
# Exclude large reference objects and caches from serialization
if not (attr_name.endswith('__datastore') or
attr_name.endswith('__app') or
'cache' in attr_name.lower() or
callable(attr_value)):
instance_attrs[attr_name] = attr_value
except AttributeError:
pass
if instance_attrs:
state['__instance_metadata__'] = instance_attrs
return state
def __setstate__(self, state):
"""
Custom pickle deserialization for all watch_base subclasses.
WARNING: Large reference objects (like __datastore) are NOT restored!
Caller must restore these references after unpickling if needed.
"""
# Extract metadata
metadata = state.pop('__instance_metadata__', {})
# Restore dict data
self.update(state)
# Restore instance attributes
for attr_name, attr_value in metadata.items():
setattr(self, attr_name, attr_value)
del self['default']

View File

@@ -23,14 +23,7 @@ class difference_detection_processor():
def __init__(self, datastore, watch_uuid):
self.datastore = datastore
self.watch_uuid = watch_uuid
# Create a stable snapshot of the watch for processing
# Why deepcopy?
# 1. Prevents "dict changed during iteration" errors if watch is modified during processing
# 2. Preserves Watch object with properties (.link, .is_pdf, etc.) - can't use dict()
# 3. Safe now: Watch.__deepcopy__() shares datastore ref (no memory leak) but copies dict data
self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
# Generic fetcher that should be extended (requests, playwright etc)
self.fetcher = Fetcher()

View File

@@ -64,7 +64,7 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
# Only update vars that came in via the AJAX post
p = {k: v for k, v in form.data.items() if k in form_data.keys()}
tmp_watch.update(p)
blank_watch_no_filters = watch_model(datastore_path=datastore.datastore_path, __datastore=datastore.data)
blank_watch_no_filters = watch_model()
blank_watch_no_filters['url'] = tmp_watch.get('url')
latest_filename = next(reversed(tmp_watch.history))

View File

@@ -67,7 +67,7 @@ echo "-------------------- Running rest of tests in parallel -------------------
# REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser
FETCH_WORKERS=2 REMOVE_REQUESTS_OLD_SCREENSHOTS=false \
pytest tests/test_*.py \
-n 8 \
-n 18 \
--dist=load \
-vvv \
-s \

View File

@@ -9,14 +9,18 @@ from flask import (
)
from flask_babel import gettext
from ..model import App, Watch
from copy import deepcopy
from ..blueprint.rss import RSS_CONTENT_FORMAT_DEFAULT
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
from ..model import App, Watch, USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from copy import deepcopy, copy
from os import path, unlink
from threading import Lock
import json
import os
import re
import secrets
import sys
import threading
import time
import uuid as uuid_builder
from loguru import logger
@@ -31,6 +35,7 @@ except ImportError:
HAS_ORJSON = False
from ..processors import get_custom_watch_obj_for_processor
from ..processors.restock_diff import Restock
# Import the base class and helpers
from .file_saving_datastore import FileSavingDataStore, load_all_watches, save_watch_atomic, save_json_atomic
@@ -132,19 +137,6 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
)
logger.info(f"Tag: {uuid} {tag['title']}")
def _rehydrate_watches(self):
"""Rehydrate watch entities from stored data (converts dicts to Watch objects)."""
watch_count = len(self.__data.get('watching', {}))
if watch_count == 0:
return
logger.info(f"Rehydrating {watch_count} watches...")
watching_rehydrated = {}
for uuid, watch_dict in self.__data.get('watching', {}).items():
watching_rehydrated[uuid] = self.rehydrate_entity(uuid, watch_dict)
self.__data['watching'] = watching_rehydrated
logger.success(f"Rehydrated {watch_count} watches into Watch objects")
def _load_state(self):
"""
@@ -182,7 +174,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
self.json_store_path = os.path.join(self.datastore_path, "changedetection.json")
# Base definition for all watchers (deepcopy part of #569)
self.generic_definition = deepcopy(Watch.model(datastore_path=datastore_path, __datastore=self.__data, default={}))
self.generic_definition = deepcopy(Watch.model(datastore_path=datastore_path, default={}))
# Load build SHA if available (Docker deployments)
if path.isfile('changedetectionio/source.txt'):
@@ -226,29 +218,23 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
logger.critical(f"Legacy datastore detected at {self.datastore_path}/url-watches.json")
logger.critical("Migration will be triggered via update_26")
# Load the legacy datastore
# Load the legacy datastore to get its schema_version
from .legacy_loader import load_legacy_format
legacy_path = os.path.join(self.datastore_path, "url-watches.json")
legacy_data = load_legacy_format(legacy_path)
with open(legacy_path) as f:
self.__data = json.load(f)
if not legacy_data:
if not self.__data:
raise Exception("Failed to load legacy datastore from url-watches.json")
# Store the loaded data
self.__data = legacy_data
# CRITICAL: Rehydrate watches from dicts into Watch objects
# This ensures watches have their methods available during migration
self._rehydrate_watches()
# update_26 will save watches to individual files and create changedetection.json
# Next startup will load from new format normally
# update_26 will load the legacy data again and migrate to new format
# Only run updates AFTER the legacy schema version (e.g., if legacy is at 25, only run 26+)
self.run_updates()
else:
# Fresh install - create new datastore
logger.warning(f"No datastore found, creating new datastore at {self.datastore_path}")
logger.critical(f"No datastore found, creating new datastore at {self.datastore_path}")
# Set schema version to latest (no updates needed)
updates_available = self.get_updates_available()
@@ -322,7 +308,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
if entity.get('processor') != 'text_json_diff':
logger.trace(f"Loading Watch object '{watch_class.__module__}.{watch_class.__name__}' for UUID {uuid}")
entity = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, default=entity)
entity = watch_class(datastore_path=self.datastore_path, default=entity)
return entity
# ============================================================================
@@ -541,11 +527,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# Clone a watch by UUID
def clone(self, uuid):
url = self.data['watching'][uuid].get('url')
# No need to deepcopy here - add_watch() will deepcopy extras anyway (line 569)
# Just pass a dict copy (with lock for thread safety)
# NOTE: dict() is shallow copy but safe since add_watch() deepcopies it
with self.lock:
extras = dict(self.data['watching'][uuid])
extras = deepcopy(self.data['watching'][uuid])
new_uuid = self.add_watch(url=url, extras=extras)
watch = self.data['watching'][new_uuid]
return new_uuid
@@ -657,7 +639,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# If the processor also has its own Watch implementation
watch_class = get_custom_watch_obj_for_processor(apply_extras.get('processor'))
new_watch = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, url=url)
new_watch = watch_class(datastore_path=self.datastore_path, url=url)
new_uuid = new_watch.get('uuid')
@@ -876,14 +858,10 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# So we use the same model as a Watch
with self.lock:
from ..model import Tag
new_tag = Tag.model(
datastore_path=self.datastore_path,
__datastore=self.__data,
default={
'title': title.strip(),
'date_created': int(time.time())
}
)
new_tag = Tag.model(datastore_path=self.datastore_path, default={
'title': title.strip(),
'date_created': int(time.time())
})
new_uuid = new_tag.get('uuid')

View File

@@ -182,86 +182,3 @@ def test_check_text_history_view(client, live_server, measure_memory_usage, data
assert b'test-one' not in res.data
delete_all_watches(client)
def test_history_trim_global_only(client, live_server, measure_memory_usage, datastore_path):
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
uuid = None
limit = 3
for i in range(0, 10):
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(f"<html>test {i}</html>")
if not uuid:
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
if i ==8:
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
history_n = len(list(watch.history.keys()))
logger.debug(f"History length should be at limit {limit} and it is {history_n}")
assert history_n == limit
if i == 6:
res = client.post(
url_for("settings.settings_page"),
data={"application-history_snapshot_max_length": limit},
follow_redirects=True
)
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
assert b'Settings updated' in res.data
delete_all_watches(client)
def test_history_trim_global_override_in_watch(client, live_server, measure_memory_usage, datastore_path):
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
uuid = None
limit = 3
res = client.post(
url_for("settings.settings_page"),
data={"application-history_snapshot_max_length": 10000},
follow_redirects=True
)
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
assert b'Settings updated' in res.data
for i in range(0, 10):
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(f"<html>test {i}</html>")
if not uuid:
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests",
"time_between_check_use_default": "y", "history_snapshot_max_length": str(limit)},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
if i == 8:
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
history_n = len(list(watch.history.keys()))
logger.debug(f"History length should be at limit {limit} and it is {history_n}")
assert history_n == limit
if i == 6:
res = client.post(
url_for("settings.settings_page"),
data={"application-history_snapshot_max_length": limit},
follow_redirects=True
)
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
assert b'Settings updated' in res.data
delete_all_watches(client)

View File

@@ -5,24 +5,15 @@
import unittest
import os
import pickle
from copy import deepcopy
from changedetectionio.model import Watch, Tag
from changedetectionio.model import Watch
# mostly
class TestDiffBuilder(unittest.TestCase):
def test_watch_get_suggested_from_diff_timestamp(self):
import uuid as uuid_builder
# Create minimal mock datastore for tests
mock_datastore = {
'settings': {
'application': {}
},
'watching': {}
}
watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})
watch = Watch.model(datastore_path='/tmp', default={})
watch.ensure_data_dir_exists()
@@ -58,7 +49,7 @@ class TestDiffBuilder(unittest.TestCase):
assert p == "109", "Correct when its the same time"
# new empty one
watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})
watch = Watch.model(datastore_path='/tmp', default={})
p = watch.get_from_version_based_on_last_viewed
assert p == None, "None when no history available"
@@ -70,184 +61,5 @@ class TestDiffBuilder(unittest.TestCase):
p = watch.get_from_version_based_on_last_viewed
assert p == "100", "Correct with only one history snapshot"
def test_watch_deepcopy_doesnt_copy_datastore(self):
"""
CRITICAL: Ensure deepcopy(watch) shares __datastore instead of copying it.
Without this, deepcopy causes exponential memory growth:
- 100 watches × deepcopy each = 10,000 watch objects in memory (100²)
- Memory grows from 120MB → 2GB
This test prevents regressions in the __deepcopy__ implementation.
"""
# Create mock datastore with multiple watches
mock_datastore = {
'settings': {'application': {'history_snapshot_max_length': 10}},
'watching': {}
}
# Create 3 watches that all reference the same datastore
watches = []
for i in range(3):
watch = Watch.model(
__datastore=mock_datastore,
datastore_path='/tmp/test',
default={'url': f'https://example{i}.com', 'title': f'Watch {i}'}
)
mock_datastore['watching'][watch['uuid']] = watch
watches.append(watch)
# Test 1: Deepcopy shares datastore reference (doesn't copy it)
watch_copy = deepcopy(watches[0])
self.assertIsNotNone(watch_copy._model__datastore,
"__datastore should exist in copied watch")
self.assertIs(watch_copy._model__datastore, watches[0]._model__datastore,
"__datastore should be SHARED (same object), not copied")
self.assertIs(watch_copy._model__datastore, mock_datastore,
"__datastore should reference the original datastore")
# Test 2: Dict data is properly copied (not shared)
self.assertEqual(watch_copy['title'], 'Watch 0', "Dict data should be copied")
watch_copy['title'] = 'MODIFIED'
self.assertNotEqual(watches[0]['title'], 'MODIFIED',
"Modifying copy should not affect original")
# Test 3: Verify no nested datastore copies in watch dict
# The dict should only contain watch settings, not the datastore
watch_dict = dict(watch_copy)
self.assertNotIn('__datastore', watch_dict,
"__datastore should not be in dict keys")
self.assertNotIn('_model__datastore', watch_dict,
"_model__datastore should not be in dict keys")
# Test 4: Multiple deepcopies don't cause exponential memory growth
# If datastore was copied, each copy would contain 3 watches,
# and those watches would contain the datastore, etc. (infinite recursion)
copies = []
for _ in range(5):
copies.append(deepcopy(watches[0]))
# All copies should share the same datastore
for copy in copies:
self.assertIs(copy._model__datastore, mock_datastore,
"All copies should share the original datastore")
def test_watch_pickle_doesnt_serialize_datastore(self):
"""
Ensure pickle/unpickle doesn't serialize __datastore.
This is important for multiprocessing and caching - we don't want
to serialize the entire datastore when pickling a watch.
"""
mock_datastore = {
'settings': {'application': {}},
'watching': {}
}
watch = Watch.model(
__datastore=mock_datastore,
datastore_path='/tmp/test',
default={'url': 'https://example.com', 'title': 'Test Watch'}
)
# Pickle and unpickle
pickled = pickle.dumps(watch)
unpickled_watch = pickle.loads(pickled)
# Test 1: Watch data is preserved
self.assertEqual(unpickled_watch['url'], 'https://example.com',
"Dict data should be preserved after pickle/unpickle")
# Test 2: __datastore is NOT serialized (attribute shouldn't exist after unpickle)
self.assertFalse(hasattr(unpickled_watch, '_model__datastore'),
"__datastore attribute should not exist after unpickle (not serialized)")
# Test 3: Pickled data shouldn't contain the large datastore object
# If datastore was serialized, the pickle size would be much larger
pickle_size = len(pickled)
# A single watch should be small (< 10KB), not include entire datastore
self.assertLess(pickle_size, 10000,
f"Pickled watch too large ({pickle_size} bytes) - might include datastore")
def test_tag_deepcopy_works(self):
"""
Ensure Tag objects (which also inherit from watch_base) can be deepcopied.
Tags now have optional __datastore for consistency with Watch objects.
"""
mock_datastore = {
'settings': {'application': {}},
'watching': {}
}
# Test 1: Tag without datastore (backward compatibility)
tag_without_ds = Tag.model(
datastore_path='/tmp/test',
default={'title': 'Test Tag', 'overrides_watch': True}
)
tag_copy1 = deepcopy(tag_without_ds)
self.assertEqual(tag_copy1['title'], 'Test Tag', "Tag data should be copied")
# Test 2: Tag with datastore (new pattern for consistency)
tag_with_ds = Tag.model(
datastore_path='/tmp/test',
__datastore=mock_datastore,
default={'title': 'Test Tag With DS', 'overrides_watch': True}
)
# Deepcopy should work
tag_copy2 = deepcopy(tag_with_ds)
# Test 3: Dict data is copied
self.assertEqual(tag_copy2['title'], 'Test Tag With DS', "Tag data should be copied")
# Test 4: Modifications to copy don't affect original
tag_copy2['title'] = 'MODIFIED'
self.assertNotEqual(tag_with_ds['title'], 'MODIFIED',
"Modifying copy should not affect original")
# Test 5: Tag with datastore shares it (doesn't copy it)
if hasattr(tag_with_ds, '_model__datastore'):
self.assertIs(tag_copy2._model__datastore, tag_with_ds._model__datastore,
"Tag should share __datastore reference like Watch does")
def test_watch_copy_performance(self):
"""
Verify that our __deepcopy__ implementation doesn't cause performance issues.
With the fix, deepcopy should be fast because we're sharing datastore
instead of copying it.
"""
import time
# Create a watch with large datastore (many watches)
mock_datastore = {
'settings': {'application': {}},
'watching': {}
}
# Add 100 watches to the datastore
for i in range(100):
w = Watch.model(
__datastore=mock_datastore,
datastore_path='/tmp/test',
default={'url': f'https://example{i}.com'}
)
mock_datastore['watching'][w['uuid']] = w
# Time how long deepcopy takes
watch = list(mock_datastore['watching'].values())[0]
start = time.time()
for _ in range(10):
_ = deepcopy(watch)
elapsed = time.time() - start
# Should be fast (< 0.1 seconds for 10 copies)
# If datastore was copied, it would take much longer
self.assertLess(elapsed, 0.5,
f"Deepcopy too slow ({elapsed:.3f}s for 10 copies) - might be copying datastore")
if __name__ == '__main__':
unittest.main()

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: cs\n"
@@ -327,14 +327,6 @@ msgstr "Nastavit na"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -353,10 +345,6 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Vyberte výchozí proxy pro všechny monitory"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -666,6 +654,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Vyberte výchozí proxy pro všechny monitory"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Verze Pythonu:"
@@ -991,12 +983,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1245,10 +1232,6 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1597,18 +1580,6 @@ msgstr "zobrazeno <b>{start} - {end}</b> {record_name} z celkem <b>{total}</b>"
msgid "records"
msgstr "záznamy"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Více informací"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Přidejte nové monitory zjišťování změn webové stránky"
@@ -1621,6 +1592,18 @@ msgstr "Monitorovat tuto URL!"
msgid "Edit first then Watch"
msgstr "Upravit a monitorovat"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "Vytvořte odkaz ke sdílení"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "Tip: Můžete také přidat „sdílené“ monitory."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Více informací"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pauza"
@@ -2134,10 +2117,6 @@ msgstr "Přiřaďte kteroukoli z následujících možností"
msgid "Use page <title> in list"
msgstr "V seznamu použijte stránku <title>"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Když je metoda požadavku nastavena na GET, tělo musí být prázdné"
@@ -2460,20 +2439,15 @@ msgstr "Změny textu webové stránky/HTML, JSON a PDF"
msgid "Detects all text changes where possible"
msgstr "Detekuje všechny změny textu, kde je to možné"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Tělo pro všechna oznámení — Můžete použít"
@@ -3082,12 +3056,3 @@ msgstr "Hlavní nastavení"
#~ msgid "Cleared snapshot history for all watches"
#~ msgstr "Vymazat/resetovat historii"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr "Vytvořte odkaz ke sdílení"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Tip: Můžete také přidat „sdílené“ monitory."

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-14 03:57+0100\n"
"Last-Translator: \n"
"Language: de\n"
@@ -333,14 +333,6 @@ msgstr "Setzen auf"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -359,10 +351,6 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Wählen Sie einen Standard-Proxy für alle Überwachungen"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -676,6 +664,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Wählen Sie einen Standard-Proxy für alle Überwachungen"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python-Version:"
@@ -1007,13 +999,8 @@ msgstr "In den Modus {} gewechselt."
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr "Das Bearbeitungsformular für den Prozessor/das Plugin „{}“ kann nicht geladen werden. Fehlt das Plugin?"
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
@@ -1267,10 +1254,6 @@ msgstr ""
"Sendet eine Benachrichtigung, wenn der Filter auf der Seite nicht mehr sichtbar ist. So wissen Sie, wann sich die "
"Seite geändert hat und Ihr Filter nicht mehr funktioniert."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr "Methode (default), bei der Ihre überwachte Website kein Javascript zum Rendern benötigt."
@@ -1635,18 +1618,6 @@ msgstr "zeige <b>{start} - {end}</b> {record_name} von insgesamt <b>{total}</b>"
msgid "records"
msgstr "Einträge"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Weitere Informationen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Fügen Sie eine neue Überwachung zur Erkennung von Webseitenänderungen hinzu"
@@ -1659,6 +1630,18 @@ msgstr "Diese URL überwachen!"
msgid "Edit first then Watch"
msgstr "Bearbeiten > Überwachen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "Erstellen Sie einen Link zum Teilen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Weitere Informationen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pause"
@@ -2179,10 +2162,6 @@ msgstr "Entspricht einer der folgenden Bedingungen"
msgid "Use page <title> in list"
msgstr "Verwenden Sie Seite <Titel> in der Liste"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Der Textkörper muss leer sein, wenn die Anforderungsmethode auf GET gesetzt ist"
@@ -2507,20 +2486,15 @@ msgstr "Änderungen an Webseitentext/HTML, JSON und PDF"
msgid "Detects all text changes where possible"
msgstr "Erkennt nach Möglichkeit alle Textänderungen"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "Fehler beim Abrufen der Metadaten für {}"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Das Protokoll wird nicht unterstützt oder das URL-Format ist ungültig."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Inhalt für alle Benachrichtigungen — Sie können verwenden"
@@ -3197,12 +3171,3 @@ msgstr "Haupteinstellungen"
#~ msgid "No watches available to recheck."
#~ msgstr "Keine Überwachungen verfügbar, um erneut zu überprüfen."
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr "Das Bearbeitungsformular für den Prozessor/das Plugin „{}“ kann nicht geladen werden. Fehlt das Plugin?"
#~ msgid "Create a shareable link"
#~ msgstr "Erstellen Sie einen Link zum Teilen"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-12 16:33+0100\n"
"Last-Translator: British English Translation Team\n"
"Language: en_GB\n"
@@ -325,14 +325,6 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -349,10 +341,6 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -662,6 +650,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -987,12 +979,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1241,10 +1228,6 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1593,18 +1576,6 @@ msgstr ""
msgid "records"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr ""
@@ -1617,6 +1588,18 @@ msgstr ""
msgid "Edit first then Watch"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr ""
@@ -2130,10 +2113,6 @@ msgstr ""
msgid "Use page <title> in list"
msgstr ""
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr ""
@@ -2456,20 +2435,15 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""
@@ -3027,12 +3001,3 @@ msgstr ""
#~ msgid "No watches available to recheck."
#~ msgstr ""
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-12 16:37+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en_US\n"
@@ -325,14 +325,6 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -349,10 +341,6 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -662,6 +650,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -987,12 +979,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1241,10 +1228,6 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1593,18 +1576,6 @@ msgstr ""
msgid "records"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr ""
@@ -1617,6 +1588,18 @@ msgstr ""
msgid "Edit first then Watch"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr ""
@@ -2130,10 +2113,6 @@ msgstr ""
msgid "Use page <title> in list"
msgstr ""
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr ""
@@ -2456,20 +2435,15 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""
@@ -3027,12 +3001,3 @@ msgstr ""
#~ msgid "No watches available to recheck."
#~ msgstr ""
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
@@ -327,14 +327,6 @@ msgstr "Définir à"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -353,10 +345,6 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Choisir un proxy par défaut pour tous les moniteurs"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -666,6 +654,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Choisir un proxy par défaut pour tous les moniteurs"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Version Python :"
@@ -991,12 +983,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1247,10 +1234,6 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1599,18 +1582,6 @@ msgstr "affichage de <b>{start} - {end}</b> {record_name} sur un total de <b>{to
msgid "records"
msgstr "enregistrements"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Plus d'informations"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Ajouter une nouvelle surveillance de détection de changement de page Web"
@@ -1623,6 +1594,18 @@ msgstr "Surveillez cette URL !"
msgid "Edit first then Watch"
msgstr "Modifier > Surveiller"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "Créer un lien partageable"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Plus d'informations"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pause"
@@ -2140,10 +2123,6 @@ msgstr "Faites correspondre l'un des éléments suivants"
msgid "Use page <title> in list"
msgstr "Utiliser la page <titre> dans la liste"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Le corps doit être vide lorsque la méthode de requête est définie sur GET"
@@ -2466,20 +2445,15 @@ msgstr "Modifications du texte de la page Web/HTML, JSON et PDF"
msgid "Detects all text changes where possible"
msgstr "Détecte toutes les modifications de texte lorsque cela est possible"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Corps pour toutes les notifications — Vous pouvez utiliser"
@@ -3090,12 +3064,3 @@ msgstr "Paramètres principaux"
#~ msgid "Cleared snapshot history for all watches"
#~ msgstr "Effacer/réinitialiser l'historique"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr "Créer un lien partageable"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: it\n"
@@ -327,14 +327,6 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -351,10 +343,6 @@ msgstr "Consenti accesso alla pagina cronologia quando la password è attiva (ut
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -664,6 +652,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -989,12 +981,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1243,10 +1230,6 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1595,18 +1578,6 @@ msgstr "visualizzando <b>{start} - {end}</b> {record_name} su un totale di <b>{t
msgid "records"
msgstr "record"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Maggiori informazioni"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Aggiungi un nuovo monitoraggio modifiche pagina web"
@@ -1619,6 +1590,18 @@ msgstr "Monitora questo URL!"
msgid "Edit first then Watch"
msgstr "Modifica > Monitora"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Maggiori informazioni"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pausa"
@@ -2132,10 +2115,6 @@ msgstr "Corrisponde a uno qualsiasi dei seguenti"
msgid "Use page <title> in list"
msgstr "Usa <title> pagina nell'elenco"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Il corpo deve essere vuoto quando il metodo è impostato su GET"
@@ -2458,20 +2437,15 @@ msgstr "Modifiche testo/HTML, JSON e PDF"
msgid "Detects all text changes where possible"
msgstr "Rileva tutte le modifiche di testo possibili"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "Errore nel recupero metadati per {}"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Protocollo non consentito o formato URL non valido"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Corpo per tutte le notifiche — Puoi usare"
@@ -3062,12 +3036,3 @@ msgstr "Impostazioni principali"
#~ msgid "Queue"
#~ msgstr "In coda"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ko\n"
@@ -325,14 +325,6 @@ msgstr "설정:"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -349,10 +341,6 @@ msgstr "비밀번호 활성화 시 변경 기록 페이지 액세스 허용 (dif
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "모든 모니터의 기본 프록시 선택"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -662,6 +650,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "모든 모니터의 기본 프록시 선택"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "파이썬 버전:"
@@ -987,12 +979,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1241,10 +1228,6 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1593,18 +1576,6 @@ msgstr "총 <b>{total}</b>개 중 <b>{start} - {end}</b>개 {record_name} 표시
msgid "records"
msgstr "기록"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "추가 정보"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "새로운 웹 페이지 변경 감지 감시 추가"
@@ -1617,6 +1588,18 @@ msgstr "이 URL 모니터!"
msgid "Edit first then Watch"
msgstr "편집 후 모니터"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "공유 가능한 링크 만들기"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "팁: '공유' 시계를 추가할 수도 있습니다."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "추가 정보"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "정지시키다"
@@ -2130,10 +2113,6 @@ msgstr "다음 중 하나와 일치"
msgid "Use page <title> in list"
msgstr "목록의 <제목> 페이지 사용"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "요청 방법이 GET으로 설정된 경우 본문이 비어 있어야 합니다."
@@ -2456,20 +2435,15 @@ msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
msgid "Detects all text changes where possible"
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "모든 알림 본문 — 사용 가능:"
@@ -3183,12 +3157,3 @@ msgstr "기본 설정"
#~ msgid "Cleared snapshot history for all watches"
#~ msgstr "기록 지우기/재설정"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr "공유 가능한 링크 만들기"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "팁: '공유' 시계를 추가할 수도 있습니다."

View File

@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.52.9\n"
"Project-Id-Version: changedetection.io 0.52.8\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:29+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -324,14 +324,6 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -348,10 +340,6 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -661,6 +649,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -986,12 +978,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1240,10 +1227,6 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1592,18 +1575,6 @@ msgstr ""
msgid "records"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr ""
@@ -1616,6 +1587,18 @@ msgstr ""
msgid "Edit first then Watch"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr ""
@@ -2129,10 +2112,6 @@ msgstr ""
msgid "Use page <title> in list"
msgstr ""
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr ""
@@ -2455,20 +2434,15 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-18 21:31+0800\n"
"Last-Translator: 吾爱分享 <admin@wuaishare.cn>\n"
"Language: zh\n"
@@ -325,14 +325,6 @@ msgstr "设置为"
msgid "to disable"
msgstr "以禁用"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr "为你的 changedetection.io 应用启用密码保护。"
@@ -349,10 +341,6 @@ msgstr "启用密码时允许访问监视器更改历史页面(便于共享差
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr "当请求无内容返回,或 HTML 不包含任何文本时,是否视为变更?"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "为所有监视器选择默认代理"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr "用于通知链接中的"
@@ -662,6 +650,10 @@ msgid ""
"whitelist the IP access instead"
msgstr "带认证的 SOCKS5 代理仅支持“明文请求”抓取器,其他抓取器请改为白名单 IP"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "为所有监视器选择默认代理"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python 版本:"
@@ -987,13 +979,8 @@ msgstr "已切换到模式 - {}。"
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr "无法加载处理器/插件 '{}' 的编辑表单,插件是否缺失?"
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
@@ -1241,10 +1228,6 @@ msgid ""
"your filter will not work anymore."
msgstr "当页面上找不到该过滤器时发送通知,便于知晓页面已变化且过滤器不再适用。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr "方式(默认),适用于无需 JavaScript 渲染的网站。"
@@ -1593,18 +1576,6 @@ msgstr "显示第 <b>{start} - {end}</b> 条{record_name},共 <b>{total}</b>
msgid "records"
msgstr "记录"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多信息"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "新增网页变更监控"
@@ -1617,6 +1588,18 @@ msgstr "监控此 URL"
msgid "Edit first then Watch"
msgstr "先编辑,再监控"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "创建可分享链接"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "提示:你也可以添加“共享”的监控项。"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多信息"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "暂停"
@@ -2130,10 +2113,6 @@ msgstr "匹配以下任意"
msgid "Use page <title> in list"
msgstr "列表中使用页面 <title>"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "当请求方法为 GET 时,请求正文必须为空"
@@ -2456,20 +2435,15 @@ msgstr "网页文本/HTML、JSON 和 PDF 变更"
msgid "Detects all text changes where possible"
msgstr "尽可能检测所有文本变更"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "获取 {} 的元数据失败"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "监控协议不允许或 URL 格式无效"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "所有通知的正文 — 您可以使用"
@@ -3012,12 +2986,3 @@ msgstr "否"
msgid "Main settings"
msgstr "主设置"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr "无法加载处理器/插件 '{}' 的编辑表单,插件是否缺失?"
#~ msgid "Create a shareable link"
#~ msgstr "创建可分享链接"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "提示:你也可以添加“共享”的监控项。"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
"PO-Revision-Date: 2026-01-15 12:00+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh_Hant_TW\n"
@@ -325,14 +325,6 @@ msgstr "設置為"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -349,10 +341,6 @@ msgstr "啟用密碼時允許匿名存取監測歷史頁面"
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "為所有監測任務選擇預設代理伺服器"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -662,6 +650,10 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "為所有監測任務選擇預設代理伺服器"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python 版本:"
@@ -987,13 +979,8 @@ msgstr "已切換至模式 - {}。"
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr "無法載入處理器 / 外掛 '{}' 的編輯表單,外掛是否遺失?"
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
@@ -1241,10 +1228,6 @@ msgid ""
"your filter will not work anymore."
msgstr "當頁面上找不到過濾器時發送通知,這有助於了解頁面何時變更導致您的過濾器失效。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr "方法(預設),適用於您監測的網站不需要 Javascript 渲染的情況。"
@@ -1593,18 +1576,6 @@ msgstr "顯示第 <b>{start} - {end}</b> 條{record_name},共 <b>{total}</b>
msgid "records"
msgstr "記錄"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多資訊"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "新增網頁變更檢測任務"
@@ -1617,6 +1588,18 @@ msgstr "監測此 URL"
msgid "Edit first then Watch"
msgstr "先編輯後監測"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "建立可分享連結"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "提示:您也可以新增「共享」監測任務。"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多資訊"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "暫停"
@@ -2130,10 +2113,6 @@ msgstr "符合以下任一條件"
msgid "Use page <title> in list"
msgstr "在列表中使用頁面 <title>"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "當請求方法設為 GET 時,內容必須為空"
@@ -2456,20 +2435,15 @@ msgstr "網頁文字 / HTML、JSON 和 PDF 變更"
msgid "Detects all text changes where possible"
msgstr "盡可能檢測所有文字變更"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "讀取 {} 的中繼資料時發生錯誤"
#: changedetectionio/store/__init__.py
#: changedetectionio/store.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "監測協定不被允許或 URL 格式無效"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "所有通知的內文 — 您可以使用"
@@ -3141,12 +3115,3 @@ msgstr "主設定"
#~ msgid "No watches available to recheck."
#~ msgstr "沒有可複查的監測任務。"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr "無法載入處理器 / 外掛 '{}' 的編輯表單,外掛是否遺失?"
#~ msgid "Create a shareable link"
#~ msgstr "建立可分享連結"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "提示:您也可以新增「共享」監測任務。"

View File

@@ -370,7 +370,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
except Exception as e:
import traceback
logger.error(f"Worker {worker_id} exception processing watch UUID: {uuid}")
logger.exception(f"Worker {worker_id} full exception details:")
logger.error(str(e))
logger.error(traceback.format_exc())
datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
process_changedetection_results = False
@@ -434,9 +435,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
await send_content_changed_notification(uuid, notification_q, datastore)
except Exception as e:
logger.critical(f"Worker {worker_id} exception in process_changedetection_results")
logger.exception(f"Worker {worker_id} full exception details:")
logger.critical(str(e))
datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
# Always record attempt count
@@ -451,7 +451,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.debug(f"UUID: {uuid} Page <title> is '{page_title}'")
datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
except Exception as e:
logger.exception(f"Worker {worker_id} full exception details:")
logger.warning(f"UUID: {uuid} Exception when extracting <title> - {str(e)}")
# Record server header
@@ -481,15 +480,22 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
del update_handler
update_handler = None
# Force garbage collection
# Force aggressive memory cleanup after clearing
import gc
gc.collect()
try:
import ctypes
ctypes.CDLL('libc.so.6').malloc_trim(0)
except Exception:
pass
except Exception as e:
import traceback
logger.error(traceback.format_exc())
logger.error(f"Worker {worker_id} unexpected error processing {uuid}: {e}")
logger.exception(f"Worker {worker_id} full exception details:")
logger.error(f"Worker {worker_id} traceback:", exc_info=True)
# Also update the watch with error information
if datastore and uuid in datastore.data['watching']:
datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
@@ -497,43 +503,49 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
finally:
# Always cleanup - this runs whether there was an exception or not
if uuid:
# Call quit() as backup (Puppeteer/Playwright have internal cleanup, but this acts as safety net)
try:
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
await update_handler.fetcher.quit(watch=watch)
except Exception as e:
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
logger.exception(f"Worker {worker_id} full exception details:")
try:
# Release UUID from processing (thread-safe)
worker_pool.release_uuid_from_processing(uuid, worker_id=worker_id)
# Send completion signal
if watch:
#logger.info(f"Worker {worker_id} sending completion signal for UUID {watch['uuid']}")
watch_check_update.send(watch_uuid=watch['uuid'])
# Clean up all memory references BEFORE garbage collection
# Explicitly clean up update_handler and all its references
if update_handler:
# Clear fetcher content using the proper method
if hasattr(update_handler, 'fetcher') and update_handler.fetcher:
update_handler.fetcher.clear_content()
# Clear processor references
if hasattr(update_handler, 'content_processor'):
update_handler.content_processor = None
del update_handler
update_handler = None
# Clear large content variables
# Clear local contents variable if it still exists
if 'contents' in locals():
del contents
# Force garbage collection after all references are cleared
# Note: We don't set watch = None here because:
# 1. watch is just a local reference to datastore.data['watching'][uuid]
# 2. Setting it to None doesn't affect the datastore
# 3. GC can't collect the object anyway (still referenced by datastore)
# 4. It would just cause confusion
# Force garbage collection after cleanup
import gc
gc.collect()
logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
except Exception as cleanup_error:
logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
logger.exception(f"Worker {worker_id} full exception details:")
del(uuid)

View File

@@ -70,7 +70,7 @@ lxml >=4.8.0,!=5.2.0,!=5.2.1,<7
# XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable
# Consider updating to latest stable version periodically
elementpath==5.1.1
elementpath==5.1.0
# For fast image comparison in screenshot change detection
# opencv-python-headless is OPTIONAL (excluded from requirements.txt)