Compare commits

..

6 Commits

Author SHA1 Message Date
dgtlmoon
430d2130b3 test tweak
Some checks failed
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-11 07:30:09 +02:00
dgtlmoon
7e58fed0ab Merge branch 'master' into text-filter-extract-lines-containing-subtext 2026-04-11 07:22:35 +02:00
dgtlmoon
4be295b613 test tweak 2026-04-11 07:04:09 +02:00
dgtlmoon
fcba83724a Rebuilding api-spec docs 2026-04-11 04:57:22 +02:00
dgtlmoon
2a09f21722 update tests and api spec 2026-04-11 04:57:05 +02:00
dgtlmoon
ac9f220147 Text filters - New simpler filter "Extract lines containing text" 2026-04-11 04:21:54 +02:00
16 changed files with 96 additions and 313 deletions

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.54.9'
__version__ = '0.54.8'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError

View File

@@ -38,39 +38,26 @@
if (a.size !== b.size) {
return b.size - a.size;
}
// Second priority: apple-touch-icon over regular icon
const isAppleA = /apple-touch-icon/.test(a.rel);
const isAppleB = /apple-touch-icon/.test(b.rel);
if (isAppleA && !isAppleB) return -1;
if (!isAppleA && isAppleB) return 1;
// Third priority: icons with no size attribute (fallback icons) last
const hasNoSizeA = !a.hasSizes;
const hasNoSizeB = !b.hasSizes;
if (hasNoSizeA && !hasNoSizeB) return 1;
if (!hasNoSizeA && hasNoSizeB) return -1;
return 0;
});
const timeoutMs = 2000;
// 1 MB — matches the server-side limit in bump_favicon()
const MAX_BYTES = 1 * 1024 * 1024;
for (const icon of icons) {
try {
// Inline data URI — no network fetch needed, data is already here
if (icon.href.startsWith('data:')) {
const match = icon.href.match(/^data:([^;]+);base64,([A-Za-z0-9+/=]+)$/);
if (!match) continue;
const mime_type = match[1];
const base64 = match[2];
// Rough size check: base64 is ~4/3 the binary size
if (base64.length * 0.75 > MAX_BYTES) continue;
return { url: icon.href, mime_type, base64 };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -87,15 +74,12 @@
const blob = await resp.blob();
if (blob.size > MAX_BYTES) continue;
// Convert blob to base64
const reader = new FileReader();
return await new Promise(resolve => {
reader.onloadend = () => {
resolve({
url: icon.href,
mime_type: blob.type,
base64: reader.result.split(",")[1]
});
};
@@ -114,3 +98,4 @@
// Auto-execute and return result for page.evaluate()
return await window.getFaviconAsBlob();
})();

View File

@@ -798,50 +798,24 @@ class model(EntityPersistenceMixin, watch_base):
# Also in the case that the file didnt exist
return True
def bump_favicon(self, url, favicon_base_64: str, mime_type: str = None) -> None:
def bump_favicon(self, url, favicon_base_64: str) -> None:
from urllib.parse import urlparse
import base64
import binascii
import re
decoded = None
MAX_FAVICON_BYTES = 1 * 1024 * 1024 # 1 MB
MIME_TO_EXT = {
'image/png': 'png',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/svg+xml': 'svg',
'image/webp': 'webp',
'image/bmp': 'bmp',
}
extension = None
# If the caller already resolved the MIME type (e.g. from blob.type or a data URI),
# use that directly — it's more reliable than guessing from a URL path.
if mime_type:
extension = MIME_TO_EXT.get(mime_type.lower().split(';')[0].strip(), None)
# Fall back to extracting extension from URL path, unless it's a data URI.
if not extension and url and not url.startswith('data:'):
if url:
try:
parsed = urlparse(url)
filename = os.path.basename(parsed.path)
(_base, ext) = filename.lower().strip().rsplit('.', 1)
extension = ext
(base, extension) = filename.lower().strip().rsplit('.', 1)
except ValueError:
logger.warning(f"UUID: {self.get('uuid')} Cant work out file extension from '{url}', defaulting to ico")
# Handle data URIs: extract MIME type from the URI itself when not already known
if not extension and url and url.startswith('data:'):
m = re.match(r'^data:([^;]+);base64,', url)
if m:
extension = MIME_TO_EXT.get(m.group(1).lower(), None)
if not extension:
extension = 'ico'
logger.error(f"UUID: {self.get('uuid')} Cant work out file extension from '{url}'")
return None
else:
# Assume favicon.ico
base = "favicon"
extension = "ico"
fname = os.path.join(self.data_dir, f"favicon.{extension}")
@@ -850,27 +824,22 @@ class model(EntityPersistenceMixin, watch_base):
decoded = base64.b64decode(favicon_base_64, validate=True)
except (binascii.Error, ValueError) as e:
logger.warning(f"UUID: {self.get('uuid')} FavIcon save data (Base64) corrupt? {str(e)}")
return None
else:
if decoded:
try:
with open(fname, 'wb') as f:
f.write(decoded)
if len(decoded) > MAX_FAVICON_BYTES:
logger.warning(f"UUID: {self.get('uuid')} Favicon too large ({len(decoded)} bytes), skipping")
return None
# Invalidate module-level favicon filename cache for this watch
_FAVICON_FILENAME_CACHE.pop(self.data_dir, None)
try:
with open(fname, 'wb') as f:
f.write(decoded)
# A signal that could trigger the socket server to update the browser also
watch_check_update = signal('watch_favicon_bump')
if watch_check_update:
watch_check_update.send(watch_uuid=self.get('uuid'))
# Invalidate module-level favicon filename cache for this watch
_FAVICON_FILENAME_CACHE.pop(self.data_dir, None)
# A signal that could trigger the socket server to update the browser also
watch_check_update = signal('watch_favicon_bump')
if watch_check_update:
watch_check_update.send(watch_uuid=self.get('uuid'))
except Exception as e:
logger.warning(f"UUID: {self.get('uuid')} error saving FavIcon to {fname} - {str(e)}")
return None
except Exception as e:
logger.warning(f"UUID: {self.get('uuid')} error saving FavIcon to {fname} - {str(e)}")
# @todo - Store some checksum and only write when its different
logger.debug(f"UUID: {self.get('uuid')} updated favicon to at {fname}")

View File

@@ -338,7 +338,6 @@ class watch_base(dict):
# These are set by processors/workers and should not trigger edited flag
additional_system_fields = {
'last_check_status', # Set by processors
'last_filter_config_hash', # Set by text_json_diff processor, internal skip-cache
'restock', # Set by restock processor
'last_viewed', # Set by mark_all_viewed endpoint
}

View File

@@ -259,12 +259,9 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
elif (url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks')
or url.startswith('https://discord.com/api'))\
and 'html' in requested_output_format:
# Discord doesn't render HTML — convert markup to plain text equivalents.
#   is injected upstream to preserve double-spaces for HTML email clients;
# Discord displays it as the literal string " " so strip it here.
# Discord doesn't support HTML, replace <br> with newlines
n_body = n_body.strip().replace('<br>', '\n')
n_body = n_body.replace('</br>', '\n')
n_body = n_body.replace('&nbsp;', ' ')
n_body = newline_re.sub('\n', n_body)
# Don't replace placeholders or truncate here - let the custom Discord plugin handle it

View File

@@ -97,6 +97,7 @@ class difference_detection_processor():
logger.warning(f"Failed to read checksum file for {self.watch_uuid}: {e}")
self.last_raw_content_checksum = None
async def validate_iana_url(self):
"""Pre-flight SSRF check — runs DNS lookup in executor to avoid blocking the event loop.
Covers all fetchers (requests, playwright, puppeteer, plugins) since every fetch goes

View File

@@ -105,30 +105,6 @@ class FilterConfig:
def text_should_not_be_present(self):
return self._get_merged_rules('text_should_not_be_present')
def get_filter_config_hash(self):
"""
Stable hash of the effective filter configuration.
Used by the skip-logic in run_changedetection() so that any change to
global settings, tag overrides, or watch filters automatically invalidates
the raw-content-unchanged shortcut — without needing scattered
clear_all_last_checksums() calls at every settings mutation site.
"""
app = self.datastore.data['settings']['application']
config = {
'extract_lines_containing': sorted(self.extract_lines_containing),
'extract_text': sorted(self.extract_text),
'ignore_text': sorted(self.ignore_text),
'include_filters': sorted(self.include_filters),
'subtractive_selectors': sorted(self.subtractive_selectors),
'text_should_not_be_present': sorted(self.text_should_not_be_present),
'trigger_text': sorted(self.trigger_text),
# Global processing flags not captured by the filter lists above
'ignore_whitespace': app.get('ignore_whitespace', False),
'strip_ignored_lines': app.get('strip_ignored_lines', False),
}
return hashlib.md5(json.dumps(config, sort_keys=True).encode()).hexdigest()
@property
def has_include_filters(self):
return bool(self.include_filters) and bool(self.include_filters[0].strip())
@@ -416,26 +392,19 @@ class perform_site_check(difference_detection_processor):
raise Exception("Watch no longer exists.")
current_raw_document_checksum = self.get_raw_document_checksum()
# Build filter config up front so we can hash it for the skip check.
filter_config = FilterConfig(watch, self.datastore)
current_filter_config_hash = filter_config.get_filter_config_hash()
# Skip only when ALL of these hold:
# 1. raw HTML is unchanged
# 2. watch config was not edited (was_edited covers per-watch field changes)
# 3. effective filter config is unchanged (covers global/tag setting changes that
# bypass was_edited — e.g. global_ignore_text, global_subtractive_selectors)
# last_filter_config_hash being False means first run or upgrade: don't skip.
# Skip processing only if BOTH conditions are true:
# 1. HTML content unchanged (checksum matches last saved checksum)
# 2. Watch configuration was not edited (including trigger_text, filters, etc.)
# The was_edited flag handles all watch configuration changes, so we don't need
# separate checks for trigger_text or other processing rules.
if (not force_reprocess and
not watch.was_edited and
self.last_raw_content_checksum and
self.last_raw_content_checksum == current_raw_document_checksum and
watch.get('last_filter_config_hash') and
watch.get('last_filter_config_hash') == current_filter_config_hash):
self.last_raw_content_checksum == current_raw_document_checksum):
raise checksumFromPreviousCheckWasTheSame()
# Initialize remaining components
# Initialize components
filter_config = FilterConfig(watch, self.datastore)
content_processor = ContentProcessor(self.fetcher, watch, filter_config, self.datastore)
transformer = ContentTransformer()
rule_engine = RuleEngine()
@@ -456,7 +425,6 @@ class perform_site_check(difference_detection_processor):
# Save the raw content checksum to file (processor implementation detail, not watch config)
self.update_last_raw_content_checksum(current_raw_document_checksum)
update_obj['last_filter_config_hash'] = current_filter_config_hash
# === CONTENT PREPROCESSING ===
# Avoid creating unnecessary intermediate string copies by reassigning only when needed
@@ -587,8 +555,8 @@ class perform_site_check(difference_detection_processor):
# === BLOCKING RULES EVALUATION ===
blocked = False
# Check trigger_text - use text_for_checksuming so ignore_text can suppress trigger_text
if rule_engine.evaluate_trigger_text(text_for_checksuming, filter_config.trigger_text):
# Check trigger_text
if rule_engine.evaluate_trigger_text(stripped_text, filter_config.trigger_text):
blocked = True
# Check text_should_not_be_present

View File

@@ -1,20 +1,5 @@
function checkDiscordHtmlWarning() {
var urls = $('textarea.notification-urls').val() || '';
var format = $('select.notification-format').val() || '';
var isDiscord = /discord:\/\/|https:\/\/discord(?:app)?\.com\/api/i.test(urls);
var isHtml = format === 'html' || format === 'htmlcolor';
if (isDiscord && isHtml) {
$('#discord-html-format-warning').show();
} else {
$('#discord-html-format-warning').hide();
}
}
$(document).ready(function () {
$('textarea.notification-urls, select.notification-format').on('change input', checkDiscordHtmlWarning);
checkDiscordHtmlWarning();
$('#add-email-helper').click(function (e) {
e.preventDefault();
email = prompt("Destination email");

View File

@@ -195,10 +195,6 @@
<div class="">
{{ render_field(form.notification_format , class="notification-format") }}
<span class="pure-form-message-inline">{{ _('Format for all notifications') }}</span>
<div id="discord-html-format-warning" class="inline-warning" style="display: none; margin-top: 6px;">
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="{{ _('Note') }}" title="{{ _('Note') }}">
{{ _('Discord does not render HTML — switch to') }} <strong>{{ _('Plain Text') }}</strong> {{ _('format to avoid') }} <code>&amp;nbsp;</code> {{ _('and other HTML entities appearing literally in your notifications.') }}
</div>
</div>
</div>
{% endmacro %}

View File

@@ -10,7 +10,7 @@ xpath://body/div/span[contains(@class, 'example-class')]",
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the &lt;element&gt; contains &lt;![CDATA[]]&gt;</strong></span><br>
{% endif %}
<span class="pure-form-message-inline">One CSS, xPath 1 &amp; 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
<span data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show advanced help and tips') }}</span><br>
<span data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</span><br>
<ul id="advanced-help-selectors" style="display: none;">
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
<li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
@@ -47,9 +47,9 @@ nav
//*[contains(text(), 'Advertisement')]") }}
<span class="pure-form-message-inline">
<ul>
<li> {{ _('Remove HTML element(s) by CSS and XPath selectors before text conversion.') }} </li>
<li> {{ _("Don't paste HTML here, use only CSS and XPath selectors") }} </li>
<li> {{ _('Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.') }} </li>
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
</ul>
</span>
</fieldset>

View File

@@ -381,14 +381,14 @@ def test_extract_lines_containing_with_ignore_text(client, live_server, measure_
f.write(initial_data)
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'paused': True})
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
# Set filters BEFORE the first check so the baseline is always filtered+ignored.
# (Setting them after an initial unfiltered check creates a race: the forced recheck
# that updates previous_md5 must complete before the next content write, which is
# timing-sensitive and fails intermittently on slower systems / Python 3.14.)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
'extract_lines_containing': 'celsius',
'ignore_text': 'Feels like',
@@ -400,12 +400,13 @@ def test_extract_lines_containing_with_ignore_text(client, live_server, measure_
},
follow_redirects=True
)
assert b"unpaused" in res.data
assert b"Updated watch." in res.data
# First check — establishes filtered+ignored baseline. previous_md5 was False so
# a change is always detected here; mark_all_viewed clears it before we assert.
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
# Sanity: preview should only show celsius lines
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid), follow_redirects=True)
@@ -426,10 +427,10 @@ def test_extract_lines_containing_with_ignore_text(client, live_server, measure_
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' not in res.data, "Changing an ignored line should not trigger a change notification"
assert b'has-unread-changes' not in res.data, \
"Changing an ignored line should not trigger a change notification"
client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
time.sleep(1)
# Change the non-ignored celsius line — SHOULD trigger
triggered_data = """<html><body>
@@ -445,7 +446,8 @@ def test_extract_lines_containing_with_ignore_text(client, live_server, measure_
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' in res.data, "Changing a non-ignored line should trigger a change notification"
assert b'has-unread-changes' in res.data, \
"Changing a non-ignored line should trigger a change notification"
delete_all_watches(client)

View File

@@ -50,85 +50,6 @@ def test_favicon(client, live_server, measure_memory_usage, datastore_path):
res = client.get(url_for('static_content', group='js', filename='../styles/styles.css'))
assert res.status_code != 200
def test_favicon_inline_data_uri(client, live_server, measure_memory_usage, datastore_path):
"""
bump_favicon() must handle a data URI as the url parameter.
Previously this logged "Cant work out file extension from 'data:image/png;base64,...'" and bailed.
The mime_type from the data URI should be used to pick the correct extension.
"""
import base64
import os
# 1x1 transparent PNG (minimal valid PNG bytes)
PNG_BYTES = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01'
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
png_b64 = base64.b64encode(PNG_BYTES).decode()
data_uri = f"data:image/png;base64,{png_b64}"
uuid = client.application.config.get('DATASTORE').add_watch(url='https://localhost')
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
# Should NOT raise / bail — must save as favicon.png
watch.bump_favicon(url=data_uri, favicon_base_64=png_b64, mime_type='image/png')
favicon_fname = watch.get_favicon_filename()
assert favicon_fname is not None, "Favicon should have been saved"
assert favicon_fname.endswith('.png'), f"Expected .png extension, got: {favicon_fname}"
full_path = os.path.join(watch.data_dir, favicon_fname)
assert os.path.getsize(full_path) == len(PNG_BYTES)
# Also verify it's served correctly via the static route
res = client.get(url_for('static_content', group='favicon', filename=uuid))
assert res.status_code == 200
assert res.data == PNG_BYTES
def test_favicon_mime_type_overrides_url_extension(client, live_server, measure_memory_usage, datastore_path):
"""
mime_type parameter takes precedence over the URL path extension.
A URL ending in .ico but with mime_type='image/png' should save as .png.
"""
import base64
import os
PNG_BYTES = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01'
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
png_b64 = base64.b64encode(PNG_BYTES).decode()
uuid = client.application.config.get('DATASTORE').add_watch(url='https://localhost')
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
watch.bump_favicon(url='https://example.com/favicon.ico', favicon_base_64=png_b64, mime_type='image/png')
favicon_fname = watch.get_favicon_filename()
assert favicon_fname is not None
assert favicon_fname.endswith('.png'), f"mime_type should override URL extension, got: {favicon_fname}"
def test_favicon_oversized_rejected(client, live_server, measure_memory_usage, datastore_path):
"""Favicons larger than 1 MB must be silently dropped."""
import base64
import os
oversized = b'\x00' * (1 * 1024 * 1024 + 1)
oversized_b64 = base64.b64encode(oversized).decode()
uuid = client.application.config.get('DATASTORE').add_watch(url='https://localhost')
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
result = watch.bump_favicon(url='https://example.com/big.png', favicon_base_64=oversized_b64, mime_type='image/png')
assert result is None, "bump_favicon should return None for oversized favicon"
assert watch.get_favicon_filename() is None, "No favicon file should have been written"
def test_bad_access(client, live_server, measure_memory_usage, datastore_path):
res = client.post(

View File

@@ -70,10 +70,6 @@ def test_trigger_functionality(client, live_server, measure_memory_usage, datast
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# And set the trigger text as 'ignore text', it should then not trigger
live_server.app.config['DATASTORE'].data['settings']['application']['global_ignore_text'] = [trigger_text]
# Trigger a check
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
@@ -126,16 +122,6 @@ def test_trigger_functionality(client, live_server, measure_memory_usage, datast
# Now set the content which contains the trigger text
set_modified_with_trigger_text_response(datastore_path=datastore_path)
# There is a "ignore text" set of the change that should be also the trigger, it should not trigger
# because the ignore text should be stripped from the response, therefor, the trigger should not fire
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' not in res.data
live_server.app.config['DATASTORE'].data['settings']['application']['global_ignore_text'] = []
# check that the trigger fired once we stopped ignore it
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))

View File

@@ -78,7 +78,7 @@ msgstr "ファイルは .zip バックアップファイルでなければなり
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr "バックアップファイルが大きすぎます(最大 %(mb)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
@@ -137,7 +137,7 @@ msgstr "注意:これはメインアプリケーションの設定を上書き
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr "最大アップロードサイズ: %(upload)s MB、最大解凍サイズ: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
@@ -215,7 +215,7 @@ msgstr ".XLSX & Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr "バックアップ復元"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
@@ -433,11 +433,10 @@ msgid "After this many consecutive times that the CSS/xPath filter is missing, s
msgstr "CSS/XPath フィルタがこの回数連続して見つからない場合、通知を送信"
# 訳注: "Set to [N] to disable" → 「[N]に設定すると無効になります」
# 述語の訳を後半にまとめた
# 前半は日本語では不要なため、空白1文字で非表示にする空文字は英語にフォールバックするため
# 前半の断片に訳を置くと語順が崩れるため、後半にまとめた
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to"
msgstr " "
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "to disable"
@@ -473,7 +472,7 @@ msgstr "すべてのウォッチのデフォルトプロキシを選択"
# 訳注: "Base URL used for the {{base_url}} token in notification links."
# → 「通知リンクの {{base_url}} トークンに使用するベースURL。」
# 修飾語と被修飾語の訳を入れ替えた
# 英語と語順が逆になるため、前後の断片で訳を入れ替えた
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr "通知リンクの"
@@ -522,7 +521,7 @@ msgstr "ページが完全にレンダリングされるのを待つのに問題
# 訳注: "This will wait [n] seconds before extracting the text."
# → 「テキスト抽出前に [n] 秒間待機します。」
# 時間条件と述語の訳を入れ替え
# 前半に文脈、後半に述語を置くよう訳を分担し
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "This will wait"
msgstr "テキスト抽出前に"
@@ -573,7 +572,7 @@ msgstr "ブラウザが検出されるすべての方法"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr "Bright Data プロキシを使用して接続します。詳細はこちら。"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
@@ -606,7 +605,7 @@ msgstr "テキスト変換前に CSS および XPath セレクターで HTML 要
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr "ここにHTMLを貼り付けないでください。CSSとXPathセレクターのみを使用してください"
msgstr "ここにHTMLを貼り付けないでください。CSSとXPathセレクターのみを使用してください"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
@@ -774,7 +773,7 @@ msgstr "ヒント"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr "ブロックされたWebサイトには「Residential」や「Mobile」プロキシタイプが「Data Center」より効果的な場合があります。"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Name\" will be used for selecting the proxy in the Watch Edit settings"
@@ -847,38 +846,35 @@ msgstr "フィルタとトリガー"
msgid ""
"Automatically applies this tag to any watch whose URL matches. Supports wildcards: <code>*example.com*</code> or "
"plain substring: <code>github.com/myorg</code>"
msgstr "URLが一致するウォッチにこのタグを自動適用します。ワイルドカード: <code>*example.com*</code> または部分文字列: <code>github.com/myorg</code> に対応"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr "現在マッチしているウォッチ"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr "タグの色"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr "カスタム色"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr "タグ名に基づく自動生成色を使用する場合はチェックを外してください。"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr "これらの設定は"
# 訳注: "These settings are [added] to any existing watch configurations."
# → 「これらの設定は既存のすべてのウォッチ設定に[追加]されます。」
# 目的語と述語の訳を入れ替えた
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "added"
msgstr "既存のすべてのウォッチ設定に"
msgstr "追加されます"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "to any existing watch configurations."
msgstr "追加されます。"
msgstr "(既存のすべてのウォッチ設定に)"
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Text filtering"
@@ -900,12 +896,9 @@ msgstr "注意!"
msgid "Lookout!"
msgstr "注意!"
# 訳注: "There are" + <a>"system-wide notification URLs enabled"</a> + ", " + ...
# → 「 件のシステム全体の通知URLが有効化されています、...」
# 前半は日本語では不要なため、空白1文字で非表示にする空文字は英語にフォールバックするため
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "There are"
msgstr " "
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "system-wide notification URLs enabled"
@@ -1167,12 +1160,9 @@ msgstr "プレビューを表示できません - 取得/チェックが完了
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "これにより、すべてのウォッチのバージョン履歴スナップショットが削除されますが、URLのリストは保持されます"
# 訳注: "You may like to use the [BACKUP] link first."
# → 「先に[バックアップ]リンクをご利用ください。」
# 副詞と述語の訳を入れ替えた
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "You may like to use the"
msgstr "先に"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "BACKUP"
@@ -1180,7 +1170,7 @@ msgstr "バックアップ"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "link first."
msgstr "リンクをご利用ください。"
msgstr "リンクを先にご利用ください。"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "Confirmation text"
@@ -1308,7 +1298,7 @@ msgstr "最新のリクエストからの現在のエラースクリーンショ
# 訳注: "Pro-tip: You can enable [option] from settings."
# → 「プロのヒント:設定から [option] を有効にできます。」
# 修飾語と述語の訳を入れ替え
# "from settings" を前半に移し、述語を後半にまとめ
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Pro-tip: You can enable"
msgstr "プロのヒント:設定から"
@@ -1387,7 +1377,7 @@ msgstr "メイン一覧ページで使用される組織タグ/グループ名"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr "URLパターンによる自動適用"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
@@ -1563,7 +1553,7 @@ msgstr "追加"
# 訳注: "So it's always better to select [X] when you're interested in new content."
# → 「そのため、新しいコンテンツに興味がある場合は [X] を選択することをおすすめします。」
# 条件節と述語の訳を入れ替え
# 英語と語順が逆になるため、条件節を前半に、述語を後半に移し
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "So it's always better to select"
msgstr "そのため、新しいコンテンツに興味がある場合は"
@@ -2256,7 +2246,7 @@ msgstr "要素を削除"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr "指定テキストを含む行を抽出"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
@@ -2296,7 +2286,7 @@ msgstr "テキストをアルファベット順にソート"
#: changedetectionio/forms.py
msgid "Strip ignored lines"
msgstr "無視する行を除"
msgstr "無視行を除"
#: changedetectionio/forms.py
msgid "Trim whitespace before and after text"
@@ -2758,7 +2748,7 @@ msgstr "changedetection.io が生成したプレビューページのURL。"
#: changedetectionio/templates/_common_fields.html
#, python-format
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
msgstr "変更の日時。format= を受け付けます(例: change_datetime(format='%A'))。デフォルトは '%Y-%m-%d %H:%M:%S %Z'"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
@@ -2768,16 +2758,13 @@ msgstr "ウォッチの差分出力のURL。"
msgid "The diff output - only changes, additions, and removals"
msgstr "差分出力 - 変更、追加、削除のみ"
# 訳注: "All diff variants accept [codes] args, e.g. [examples]"
# → 「すべての差分バリアントは [codes] 引数を受け付けます。例: [examples]」
# 述語の訳を後半にまとめた
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr "すべての差分バリアントは"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr "引数を受け付けます。例:"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
@@ -2819,13 +2806,13 @@ msgstr "差分出力 - 統一フォーマットのパッチ"
msgid ""
"Only the changed words/values from the previous version — e.g. the old price. Best when a single value changes per "
"line; multiple changed fragments are joined by newline."
msgstr "前バージョンから変更された単語/値のみ — 例: 旧価格。1行あたり1つの値が変わる場合に最適。複数の変更箇所は改行で結合されます。"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid ""
"Only the changed words/values from the new version — e.g. the new price. Best when a single value changes per line; "
"multiple changed fragments are joined by newline."
msgstr "新バージョンの変更された単語/値のみ — 例: 新価格。1行あたり1つの値が変わる場合に最適。複数の変更箇所は改行で結合されます。"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
@@ -2837,7 +2824,7 @@ msgstr "フィルタからトリガーを発動させたテキスト"
# 訳注: "Warning: Contents of [token1] and [token2] depend on how the difference algorithm perceives the change."
# → 「警告: [token1] および [token2] の内容は、差分アルゴリズムが変更をどのように認識するかによって異なります。」
# 主語の訳を後半にまとめた
# 述語「の内容は〜異なります」を後半の断片にまとめた
#: changedetectionio/templates/_common_fields.html
msgid "Warning: Contents of"
msgstr "警告:"
@@ -2870,15 +2857,9 @@ msgstr "ほぼすべてのサービスへの通知に対応!"
msgid "Please read the notification services wiki here for important configuration notes"
msgstr "重要な設定に関するメモについては、通知サービスのWikiをこちらでお読みください"
# 訳注: 2箇所で使用される共通フラグメント
# (1) _common_fields.html: "Use" + <a>AppRise Notification URLs</a> + "for notification to just about any service!"
# → 「 AppRise 通知URL ほぼすべてのサービスへの通知に対応!」
# (2) text-options.html: "Use" + <code>//(?aiLmsux))</code> + "type flags (more" + <a>information here</a> + ")"
# → 「 //(?aiLmsux)) タイプフラグを使用できます(詳細 はこちら)」
# 前半は日本語では不要なため、空白1文字で非表示にする空文字は英語にフォールバックするため
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html
msgid "Use"
msgstr " "
msgstr "使用:"
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
@@ -2890,7 +2871,7 @@ msgstr "(または"
# 訳注: "[service] only supports a maximum [2,000 characters] of notification text, including the title."
# → 「[service] がサポートする通知テキストは最大 [2,000文字] です(タイトルを含む)。」
# 助詞を補い、目的語と述語の訳を入れ替え
# "only supports a maximum" に主語の助詞「が」を付けて文を成立させ
#: changedetectionio/templates/_common_fields.html
msgid "only supports a maximum"
msgstr "がサポートする通知テキストは最大"
@@ -2929,11 +2910,10 @@ msgstr "詳細なヘルプはこちら"
# 訳注: "Accepts the {{token}} placeholders listed below"
# → 「{{token}} 以下のプレースホルダーを受け付けます」
# 述語の訳を後半にまとめた
# 前半は日本語では不要なため、空白1文字で非表示にする空文字は英語にフォールバックするため
# 前半の断片に訳を置くと語順が崩れるため、後半にまとめた
#: changedetectionio/templates/_common_fields.html
msgid "Accepts the"
msgstr " "
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "placeholders listed below"
@@ -3033,7 +3013,7 @@ msgstr "playwright 環境変数を有効にする"
# 訳注: "and uncomment the [code] in the [filename] file"
# → 「そして [code] のコメントを [filename] ファイル内で解除してください」
# 目的語と述語の訳を入れ替え
# 3つの断片に訳を分散させて自然な語順にし
#: changedetectionio/templates/_helpers.html
msgid "and uncomment the"
msgstr "そして"
@@ -3138,11 +3118,9 @@ msgstr "検索"
msgid "URL or Title"
msgstr "URLまたはタイトル"
# 訳注: "URL or Title in 'タグ名'" → 「URLまたはタイトル - 'タグ名'」
# 前置詞 "in" を区切り文字に置き換えた
#: changedetectionio/templates/base.html
msgid "in"
msgstr "-"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Enter search term..."
@@ -3196,26 +3174,23 @@ msgstr "ここのすべての行が存在しない必要があります(各行
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
msgstr "これらの単語やフレーズのいずれかを含む行のみを保持(プレーンテキスト、大文字小文字区別なし)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "One entry per line — any line in the page text that contains a match is kept"
msgstr "1行に1エントリ — ページテキスト内でマッチを含む行が保持されます"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr "正規表現のより簡単な代替手段 — 特定のトピックに関する行だけが必要な場合に使用"
msgstr ""
# 訳注: "Example: enter [celsius] to keep only lines mentioning temperature readings"
# → 「例: [celsius] と入力すると、温度に関する行のみが保持されます」
# 述語の訳を後半にまとめた
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr "例:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
msgstr "と入力すると、温度に関する行のみが保持されます"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
@@ -3231,7 +3206,7 @@ msgstr "行頭の空白を考慮することを忘れないでください"
#: changedetectionio/templates/edit/text-options.html
msgid "type flags (more"
msgstr "タイプフラグを使用できます(詳細"
msgstr "タイプフラグ(詳細"
#: changedetectionio/templates/edit/text-options.html
msgid "information here"

View File

@@ -484,8 +484,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Store favicon if necessary
if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64'),
mime_type=update_handler.fetcher.favicon_blob.get('mime_type')
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
)
datastore.update_watch(uuid=uuid, update_obj=final_updates)