Compare commits

...

54 Commits

Author SHA1 Message Date
dgtlmoon 430d2130b3 test tweak
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 0dbfb02e17 UI - URL field should be just a string field (Not type=url) because URLs with Jinja2 macros could cause false errors #3777
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-11 04:49:28 +02:00
dgtlmoon ac9f220147 Text filters - New simpler filter "Extract lines containing text" 2026-04-11 04:21:54 +02:00
hekwert caa393d5b9 Add complete Turkish translation (#4044) 2026-04-11 10:29:46 +10:00
Jaroslav Lichtblau 17ed9536a3 Czech l12n updates (#4043)
* feat: adding missing Czech translation strings

* feat: adding more missing Czech translation strings

* feat: adding more missing Czech translation strings

* feat: adding more missing Czech translation strings
2026-04-11 10:29:24 +10:00
chaoliang yan b403b08895 fix: XLSX import error messages report wrong row number after failed rows + test (#4036)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-10 13:08:28 +02:00
dgtlmoon 9df2e172f4 Test - word-level diff - Re #4037 - adding test (#4042) 2026-04-10 12:32:02 +02:00
dgtlmoon dc037f85ab Fix/step failure notification crash (#4041) 2026-04-10 12:15:47 +02:00
dgtlmoon 90f157abde Groups - Set custom colour for tag/group/label background (#4040) 2026-04-10 11:48:58 +02:00
chaoliang yan 4294b461c7 fix: pass include_change_type_prefix to word-level diff (#4037) 2026-04-10 11:46:55 +02:00
Maicon Strey 77116f5203 Add Portuguese (Brasil) translation (#4033)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-10 02:46:37 +02:00
dgtlmoon 238d6ba72d Feature - Groups/tag - Apply a group by specifying a wildcard, ie *.mysite.com* (#4032) 2026-04-10 02:45:23 +02:00
dgtlmoon ede06a92bd diff_changed_from/diff_changed_from tokens - improve documentation
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-09 08:32:13 +02:00
dgtlmoon 9d4249c820 Notification - Adding tokens {{diff_changed_from}} and {{diff_changed_to}} #3818 (#4031) 2026-04-09 08:28:08 +02:00
dgtlmoon b5bac1c868 Fix SCREENSHOT_MAX_HEIGHT not enforced: cap viewport step_size and clip stitched output to max capture height #3810 (#4030) 2026-04-09 07:41:20 +02:00
dgtlmoon 0479aa9654 UI - Minor text fix and add link to 'Restock Backup' from Imports 2026-04-09 07:20:11 +02:00
Michal Zuber 746e213398 Update Selenium RemoteConnection to use ClientConfig for timeout (#4027)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-04-08 11:17:02 +02:00
skkzsh 84d97ec9cf Add Japanese translation (ja) (#4019)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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-05 07:55:58 +02:00
dgtlmoon c8f13f5084 UI - German translation: Visual Filter: "Klare Auswahl" is very misleading #4023
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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-04 06:11:38 +02:00
dgtlmoon d74b7d5329 0.54.8 2026-04-04 06:00:23 +02:00
dgtlmoon 31a760c214 CVE-2026-35490 - Authentication Bypass via Decorator Ordering 2026-04-04 05:58:53 +02:00
dependabot[bot] 43bba5a1b6 Update openapi-core requirement from ~=0.22 to ~=0.23 (#4009)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (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
2026-04-03 07:18:17 +02:00
dgtlmoon 7c9eb02df4 Ensure all unit tests are run (#4022) 2026-04-03 07:16:52 +02:00
dgtlmoon 0ad4090d68 Extendable theme pluggy implementation for main theme/template <head> section (#4011)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-03-27 12:28:13 +01:00
dgtlmoon 9a10353d61 Update docker-compose.yml
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-26 22:24:35 +01:00
dgtlmoon f8236848ba Update docker-compose.yml 2026-03-26 19:23:51 +01:00
dgtlmoon 4ba5f6a003 0.54.7
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-26 09:50:02 +01:00
dgtlmoon 05fc885108 Translations - recompiling 2026-03-26 09:47:02 +01:00
Jaroslav Lichtblau f37e448411 fix: Czech translation strings updated (#4008) 2026-03-26 09:45:23 +01:00
dgtlmoon dadc804567 Security: XPath json-doc() Arbitrary File Read Bypass ( Similar fix as CVE-2026-29039 ) 2026-03-26 09:44:17 +01:00
dgtlmoon 65517a9c74 CVE-2026-33981 - Environment Variable Disclosure via jq env Builtin in Include Filters 2026-03-26 09:33:52 +01:00
dgtlmoon 17002b5b23 UI - Settings - Dont let 'password' field autocomplete (chrome)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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
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
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-03-24 09:32:34 +01:00
dgtlmoon c4b890f4fa last_error should be cleared if page content was the same and there was no error (#3997)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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-03-21 20:18:05 +01:00
P. León 2ab172408d fix: correct critical errors in Spanish (es) translation (#3994) 2026-03-21 19:53:13 +01:00
dgtlmoon b98f55030a Restock - Add previous_price to restock values #3987 (#3993)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-20 18:43:36 +01:00
dgtlmoon 6181b09b16 UI - Scan/check all proxies - Regression fix from earlier refactor
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-20 11:40:45 +01:00
dgtlmoon 5f9fa15a6a Realtime - Suppress socket.io errors in logs (#3991)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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 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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-19 18:02:07 +01:00
dgtlmoon 34c2c05bc5 UI - Text tidyup (#3989) 2026-03-19 15:57:05 +01:00
dgtlmoon 0da8dfb09a 0.54.6
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-03-17 11:53:33 +01:00
dgtlmoon b747e06c3e SONP - Attempt to strip out JSONP, treat as plaintext (#3983 #3982) 2026-03-17 11:10:48 +01:00
dgtlmoon 5a4266069b Content Fetchers / Browsers - Improvements for pluggable extra fetchers/browsers. (#3981)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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-03-15 17:35:46 +01:00
Yunhao Jiang 36269717b2 fix: add commit calls for pause and mute operations (#3978)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-13 11:32:15 +01:00
dependabot[bot] 84f2629a0c Bump apprise from 1.9.7 to 1.9.8 (#3979) 2026-03-13 10:00:12 +01:00
dgtlmoon e9d740bd49 0.54.5
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-12 17:11:21 +01:00
dgtlmoon c18421fbe9 CI - YML tidyup 2026-03-12 16:46:14 +01:00
dgtlmoon f29d6a857b Docker image - Improving org.opencontainers labels for dev containers 2026-03-12 16:41:45 +01:00
dgtlmoon fcfe089a53 Docker image - Improving org.opencontainers labels #3794 2026-03-12 16:36:07 +01:00
dgtlmoon b32617d700 API - Invert changes_only flag for include_equal parameter, add test, fixes changesOnly option for history diff API call (#3976) 2026-03-12 16:15:37 +01:00
dgtlmoon 380d8a26a1 UI - Fixing Preview "GO" version button (#3969)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
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
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-03-10 11:52:58 +01:00
dgtlmoon 02c03fc32b API - Create (POST) tag/group through API do not save processor_config_restock_diff values #3966 (#3968) 2026-03-10 11:19:59 +01:00
102 changed files with 14018 additions and 1284 deletions
+18 -1
View File
@@ -103,6 +103,14 @@ jobs:
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=dev
labels: |
org.opencontainers.image.created=${{ github.event.release.published_at }}
org.opencontainers.image.description=Website, webpage change detection, monitoring and notifications.
org.opencontainers.image.documentation=https://changedetection.io
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/dgtlmoon/changedetection.io
org.opencontainers.image.title=changedetection.io
org.opencontainers.image.url=https://changedetection.io
- name: Build and push :dev
id: docker_build
@@ -128,7 +136,7 @@ jobs:
echo "Release tag: ${{ github.event.release.tag_name }}"
echo "Github ref: ${{ github.ref }}"
echo "Github ref name: ${{ github.ref_name }}"
- name: Docker meta :tag
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
uses: docker/metadata-action@v6
@@ -142,6 +150,15 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }}
type=semver,pattern={{major}},value=${{ github.event.release.tag_name }}
type=raw,value=latest
labels: |
org.opencontainers.image.created=${{ github.event.release.published_at }}
org.opencontainers.image.description=Website, webpage change detection, monitoring and notifications.
org.opencontainers.image.documentation=https://changedetection.io
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/dgtlmoon/changedetection.io
org.opencontainers.image.title=changedetection.io
org.opencontainers.image.url=https://changedetection.io
org.opencontainers.image.version=${{ github.event.release.tag_name }}
- name: Build and push :tag
id: docker_build_tag_release
@@ -99,11 +99,7 @@ jobs:
- name: Run Unit Tests
run: |
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest tests/unit/'
# Basic pytest tests with ancillary services
basic-tests:
@@ -587,6 +583,10 @@ jobs:
run: |
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
- name: Plugin get_html_head_extras hook injects into base.html
run: |
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_html_head_extras.py'
# Container startup tests
container-tests:
runs-on: ubuntu-latest
+2 -1
View File
@@ -1,5 +1,6 @@
[python: **.py]
keywords = _:1,_l:1,gettext:1
keywords = _ _l gettext
[jinja2: **/templates/**.html]
encoding = utf-8
keywords = _ _l gettext
+1 -1
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.4'
__version__ = '0.54.8'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
+7
View File
@@ -177,6 +177,13 @@ class Tag(Resource):
new_uuid = self.datastore.add_tag(title=title)
if new_uuid:
# Apply any extra fields (e.g. processor_config_restock_diff) beyond just title
extra = {k: v for k, v in json_data.items() if k != 'title'}
if extra:
tag = self.datastore.data['settings']['application']['tags'].get(new_uuid)
if tag:
tag.update(extra)
tag.commit()
return {'uuid': new_uuid}, 201
else:
return "Invalid or unsupported tag", 400
+3 -3
View File
@@ -338,7 +338,7 @@ class WatchHistoryDiff(Resource):
word_diff = True
# Get boolean diff preferences with defaults from DIFF_PREFERENCES_CONFIG
changes_only = strtobool(request.args.get('changesOnly', 'true'))
changes_only = strtobool(request.args.get('changesOnly', 'false'))
ignore_whitespace = strtobool(request.args.get('ignoreWhitespace', 'false'))
include_removed = strtobool(request.args.get('removed', 'true'))
include_added = strtobool(request.args.get('added', 'true'))
@@ -349,7 +349,7 @@ class WatchHistoryDiff(Resource):
previous_version_file_contents=from_version_file_contents,
newest_version_file_contents=to_version_file_contents,
ignore_junk=ignore_whitespace,
include_equal=changes_only,
include_equal=not changes_only,
include_removed=include_removed,
include_added=include_added,
include_replaced=include_replaced,
@@ -567,4 +567,4 @@ class CreateWatch(Resource):
return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202
return list, 200
return list, 200
@@ -98,8 +98,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
backup_threads = []
@login_optionally_required
@backups_blueprint.route("/request-backup", methods=['GET'])
@login_optionally_required
def request_backup():
if any(thread.is_alive() for thread in backup_threads):
flash(gettext("A backup is already running, check back in a few minutes"), "error")
@@ -141,8 +141,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return backup_info
@login_optionally_required
@backups_blueprint.route("/download/<string:filename>", methods=['GET'])
@login_optionally_required
def download_backup(filename):
import re
filename = filename.strip()
@@ -165,9 +165,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
logger.debug(f"Backup download request for '{full_path}'")
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
@login_optionally_required
@backups_blueprint.route("/", methods=['GET'])
@backups_blueprint.route("/create", methods=['GET'])
@login_optionally_required
def create():
backups = find_backups()
output = render_template("backup_create.html",
@@ -176,8 +176,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
)
return output
@login_optionally_required
@backups_blueprint.route("/remove-backups", methods=['GET'])
@login_optionally_required
def remove_backups():
backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
@@ -174,8 +174,8 @@ def construct_restore_blueprint(datastore):
restore_blueprint = Blueprint('restore', __name__, template_folder="templates")
restore_threads = []
@login_optionally_required
@restore_blueprint.route("/restore", methods=['GET'])
@login_optionally_required
def restore():
form = RestoreForm()
return render_template("backup_restore.html",
@@ -184,8 +184,8 @@ def construct_restore_blueprint(datastore):
max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024),
max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024))
@login_optionally_required
@restore_blueprint.route("/restore/start", methods=['POST'])
@login_optionally_required
def backups_restore_start():
if any(t.is_alive() for t in restore_threads):
flash(gettext("A restore is already running, check back in a few minutes"), "error")
@@ -20,8 +20,7 @@
<p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>
<p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>
<p class="pure-form-message">
{{ _('Max upload size: %(upload)s MB &nbsp;·&nbsp; Max decompressed size: %(decomp)s MB',
upload=max_upload_mb, decomp=max_decompressed_mb) }}
{{ _('Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB', upload=max_upload_mb, decomp=max_decompressed_mb) }}
</p>
<form class="pure-form pure-form-stacked settings"
@@ -102,6 +102,35 @@ def run_async_in_browser_loop(coro):
else:
raise RuntimeError("Browser steps event loop is not available")
async def _close_session_resources(session_data, label=''):
"""Close all browser resources for a session in the correct order.
browserstepper.cleanup() closes page+context but not the browser itself.
For CloakBrowser, browser.close() is what stops the local Chromium process via pw.stop().
For the default CDP path, playwright_context.stop() shuts down the playwright instance.
"""
browserstepper = session_data.get('browserstepper')
if browserstepper:
try:
await browserstepper.cleanup()
except Exception as e:
logger.error(f"Error cleaning up browserstepper{label}: {e}")
browser = session_data.get('browser')
if browser:
try:
await asyncio.wait_for(browser.close(), timeout=5.0)
except Exception as e:
logger.warning(f"Error closing browser{label}: {e}")
playwright_context = session_data.get('playwright_context')
if playwright_context:
try:
await playwright_context.stop()
except Exception as e:
logger.warning(f"Error stopping playwright context{label}: {e}")
def cleanup_expired_sessions():
"""Remove expired browsersteps sessions and cleanup their resources"""
global browsersteps_sessions, browsersteps_watch_to_session
@@ -119,13 +148,10 @@ def cleanup_expired_sessions():
logger.debug(f"Cleaning up expired browsersteps session {session_id}")
session_data = browsersteps_sessions[session_id]
# Cleanup playwright resources asynchronously
browserstepper = session_data.get('browserstepper')
if browserstepper:
try:
run_async_in_browser_loop(browserstepper.cleanup())
except Exception as e:
logger.error(f"Error cleaning up session {session_id}: {e}")
try:
run_async_in_browser_loop(_close_session_resources(session_data, label=f" for session {session_id}"))
except Exception as e:
logger.error(f"Error cleaning up session {session_id}: {e}")
# Remove from sessions dict
del browsersteps_sessions[session_id]
@@ -152,12 +178,10 @@ def cleanup_session_for_watch(watch_uuid):
session_data = browsersteps_sessions.get(session_id)
if session_data:
browserstepper = session_data.get('browserstepper')
if browserstepper:
try:
run_async_in_browser_loop(browserstepper.cleanup())
except Exception as e:
logger.error(f"Error cleaning up session {session_id} for watch {watch_uuid}: {e}")
try:
run_async_in_browser_loop(_close_session_resources(session_data, label=f" for watch {watch_uuid}"))
except Exception as e:
logger.error(f"Error cleaning up session {session_id} for watch {watch_uuid}: {e}")
# Remove from sessions dict
del browsersteps_sessions[session_id]
@@ -178,64 +202,74 @@ def construct_blueprint(datastore: ChangeDetectionStore):
import time
from playwright.async_api import async_playwright
# We keep the playwright session open for many minutes
keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
keepalive_ms = ((keepalive_seconds + 3) * 1000)
browsersteps_start_session = {'start_time': time.time()}
# Create a new async playwright instance for browser steps
playwright_instance = async_playwright()
playwright_context = await playwright_instance.start()
keepalive_ms = ((keepalive_seconds + 3) * 1000)
base_url = os.getenv('PLAYWRIGHT_DRIVER_URL', '').strip('"')
a = "?" if not '?' in base_url else '&'
base_url += a + f"timeout={keepalive_ms}"
browser = await playwright_context.chromium.connect_over_cdp(base_url, timeout=keepalive_ms)
browsersteps_start_session['browser'] = browser
browsersteps_start_session['playwright_context'] = playwright_context
# Build proxy dict first — needed by both the CDP path and fetcher-specific launchers
proxy_id = datastore.get_preferred_proxy_for_watch(uuid=watch_uuid)
proxy = None
if proxy_id:
proxy_url = datastore.proxy_list.get(proxy_id).get('url')
proxy_url = datastore.proxy_list.get(proxy_id, {}).get('url')
if proxy_url:
# Playwright needs separate username and password values
from urllib.parse import urlparse
parsed = urlparse(proxy_url)
proxy = {'server': proxy_url}
if parsed.username:
proxy['username'] = parsed.username
if parsed.password:
proxy['password'] = parsed.password
logger.debug(f"Browser Steps: UUID {watch_uuid} selected proxy {proxy_url}")
# Tell Playwright to connect to Chrome and setup a new session via our stepper interface
# Resolve the fetcher class for this watch so we can ask it to launch its own browser
# if it supports that (e.g. CloakBrowser, which runs locally rather than via CDP)
watch = datastore.data['watching'][watch_uuid]
from changedetectionio import content_fetchers
fetcher_name = watch.get_fetch_backend or 'system'
if fetcher_name == 'system':
fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')
fetcher_class = getattr(content_fetchers, fetcher_name, None)
browser = None
playwright_context = None
# If the fetcher has its own browser launch for the live steps UI, use it.
# get_browsersteps_browser(proxy, keepalive_ms) returns (browser, playwright_context_or_None)
# or None to fall back to the default CDP path.
if fetcher_class and hasattr(fetcher_class, 'get_browsersteps_browser'):
result = await fetcher_class.get_browsersteps_browser(proxy=proxy, keepalive_ms=keepalive_ms)
if result is not None:
browser, playwright_context = result
logger.debug(f"Browser Steps: using fetcher-specific browser for '{fetcher_name}'")
# Default: connect to the remote Playwright/sockpuppetbrowser via CDP
if browser is None:
playwright_instance = async_playwright()
playwright_context = await playwright_instance.start()
base_url = os.getenv('PLAYWRIGHT_DRIVER_URL', '').strip('"')
a = "?" if '?' not in base_url else '&'
base_url += a + f"timeout={keepalive_ms}"
browser = await playwright_context.chromium.connect_over_cdp(base_url, timeout=keepalive_ms)
logger.debug(f"Browser Steps: using CDP connection to {base_url}")
browsersteps_start_session['browser'] = browser
browsersteps_start_session['playwright_context'] = playwright_context
browserstepper = browser_steps.browsersteps_live_ui(
playwright_browser=browser,
proxy=proxy,
start_url=datastore.data['watching'][watch_uuid].link,
headers=datastore.data['watching'][watch_uuid].get('headers')
start_url=watch.link,
headers=watch.get('headers')
)
# Initialize the async connection
await browserstepper.connect(proxy=proxy)
browsersteps_start_session['browserstepper'] = browserstepper
# For test
#await browsersteps_start_session['browserstepper'].action_goto_url(value="http://example.com?time="+str(time.time()))
return browsersteps_start_session
@login_optionally_required
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
@login_optionally_required
def browsersteps_start_session():
# A new session was requested, return sessionID
import uuid
@@ -270,8 +304,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
logger.debug("Starting connection with playwright - done")
return {'browsersteps_session_id': browsersteps_session_id}
@login_optionally_required
@browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
@login_optionally_required
def browser_steps_fetch_screenshot_image():
from flask import (
make_response,
@@ -296,8 +330,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
# A request for an action was received
@login_optionally_required
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
@login_optionally_required
def browsersteps_ui_update():
import base64
@@ -40,12 +40,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
contents = ''
now = time.time()
try:
import asyncio
processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
update_handler = processor_module.perform_site_check(datastore=datastore,
watch_uuid=uuid
)
update_handler.call_browser(preferred_proxy_id=preferred_proxy)
asyncio.run(update_handler.call_browser(preferred_proxy_id=preferred_proxy))
# title, size is len contents not len xfer
except content_fetcher_exceptions.Non200ErrorCodeReceived as e:
if e.status_code == 404:
@@ -160,8 +160,7 @@ class import_xlsx_wachete(Importer):
flash(gettext("Unable to read export XLSX file, something wrong with the file?"), 'error')
return
row_id = 2
for row in wb.active.iter_rows(min_row=row_id):
for row_id, row in enumerate(wb.active.iter_rows(min_row=2), start=2):
try:
extras = {}
data = {}
@@ -212,8 +211,6 @@ class import_xlsx_wachete(Importer):
except Exception as e:
logger.error(e)
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_id), 'error')
else:
row_id += 1
flash(gettext("{} imported from Wachete .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))
@@ -241,10 +238,10 @@ class import_xlsx_custom(Importer):
# @todo cehck atleast 2 rows, same in other method
from changedetectionio.forms import validate_url
row_i = 1
row_i = 0
try:
for row in wb.active.iter_rows():
for row_i, row in enumerate(wb.active.iter_rows(), start=1):
url = None
tags = None
extras = {}
@@ -295,7 +292,5 @@ class import_xlsx_custom(Importer):
except Exception as e:
logger.error(e)
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_i), 'error')
else:
row_i += 1
flash(gettext("{} imported from custom .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))
@@ -9,6 +9,7 @@
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
<li class="tab"><a href="{{url_for('backups.restore.restore')}}">{{ _('Backup Restore') }}</a></li>
</ul>
</div>
@@ -154,9 +154,8 @@
</span>
</div>
<div class="pure-control-group">
<br>
{{ _('Tip:') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">{{ _('Connect using Bright Data and Oxylabs Proxies, find out more here.') }}</a>
<br>
{{ _('Tip:') }} <a href="{{ url_for('settings.settings_page')}}#proxies">{{ _('Connect using Bright Data proxies, find out more here.') }}</a>
</div>
</div>
@@ -352,7 +351,7 @@ nav
</div>
</div>
<p><strong>{{ _('Tip') }}</strong>: {{ _('"Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.') }}</p>
<p><strong>{{ _('Tip') }}</strong>: {{ _('"Residential" and "Mobile" proxy type can be more successful than "Data Center" for blocked websites.') }}</p>
<div class="pure-control-group" id="extra-proxies-setting">
{{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }}
@@ -22,11 +22,14 @@ def construct_blueprint(datastore: ChangeDetectionStore):
tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags'])
from changedetectionio import processors
output = render_template("groups-overview.html",
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
available_tags=sorted_tags,
form=add_form,
generate_tag_colors=processors.generate_processor_badge_colors,
tag_count=tag_count,
wcag_text_color=processors.wcag_text_color,
)
return output
@@ -208,9 +211,17 @@ def construct_blueprint(datastore: ChangeDetectionStore):
template = env.from_string(template_str)
included_content = template.render(**template_args)
# Watches whose URL currently matches this tag's pattern
matching_watches = {
w_uuid: watch
for w_uuid, watch in datastore.data['watching'].items()
if default.matches_url(watch.get('url', ''))
}
output = render_template("edit-tag.html",
extra_form_content=included_content,
extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None,
matching_watches=matching_watches,
settings_application=datastore.data['settings']['application'],
**template_args
)
+3 -4
View File
@@ -10,12 +10,11 @@ from changedetectionio.processors.restock_diff.forms import processor_settings_f
class group_restock_settings_form(restock_settings_form):
overrides_watch = BooleanField('Activate for individual watches in this tag/group?', default=False)
url_match_pattern = StringField('Auto-apply to watches with URLs matching',
render_kw={"placeholder": "e.g. *://example.com/* or github.com/myorg"})
tag_colour = StringField('Tag colour', default='')
class SingleTag(Form):
name = StringField('Tag name', [validators.InputRequired()], render_kw={"placeholder": "Name"})
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
@@ -43,6 +43,46 @@
<div class="pure-control-group">
{{ render_field(form.title, placeholder="https://...", required=true, class="m-d") }}
</div>
<div class="pure-control-group">
{{ render_field(form.url_match_pattern, class="m-d") }}
<span class="pure-form-message-inline">{{ _('Automatically applies this tag to any watch whose URL matches. Supports wildcards: <code>*example.com*</code> or plain substring: <code>github.com/myorg</code>')|safe }}</span>
</div>
{% if matching_watches %}
<div class="pure-control-group">
<label>{{ _('Currently matching watches') }} ({{ matching_watches|length }})</label>
<ul class="tag-url-match-list">
{% for w_uuid, w in matching_watches.items() %}
<li><a href="{{ url_for('ui.ui_edit.edit_page', uuid=w_uuid) }}">{{ w.label }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="pure-control-group">
<label>{{ _('Tag colour') }}</label>
<div style="display:flex; align-items:center; gap:0.75em;">
<input type="checkbox" id="use_custom_colour"
{% if data.get('tag_colour') %}checked{% endif %}>
<label for="use_custom_colour" style="margin:0">{{ _('Custom colour') }}</label>
<input type="color" id="tag_colour_picker"
value="{{ data.get('tag_colour') or '#4f8ef7' }}"
{% if not data.get('tag_colour') %}disabled{% endif %}>
<input type="hidden" name="tag_colour" id="tag_colour_hidden"
value="{{ data.get('tag_colour', '') }}">
</div>
<span class="pure-form-message-inline">{{ _('Leave unchecked to use the auto-generated colour based on the tag name.') }}</span>
</div>
<script>
(function () {
var cb = document.getElementById('use_custom_colour');
var picker = document.getElementById('tag_colour_picker');
var hidden = document.getElementById('tag_colour_hidden');
picker.addEventListener('input', function () { hidden.value = this.value; });
cb.addEventListener('change', function () {
picker.disabled = !this.checked;
hidden.value = this.checked ? picker.value : '';
});
})();
</script>
</fieldset>
</div>
@@ -3,6 +3,26 @@
{% from '_helpers.html' import render_simple_field, render_field %}
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='modal.js')}}"></script>
<style>
{%- for uuid, tag in available_tags -%}
{%- if tag and tag.title -%}
{%- set class_name = tag.title|sanitize_tag_class -%}
{%- if tag.get('tag_colour') -%}
.watch-tag-list.tag-{{ class_name }} { background-color: {{ tag.tag_colour }}; color: {{ wcag_text_color(tag.tag_colour) }}; }
{%- else -%}
{%- set colors = generate_tag_colors(tag.title) -%}
.watch-tag-list.tag-{{ class_name }} {
background-color: {{ colors['light']['bg'] }};
color: {{ colors['light']['color'] }};
}
html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
background-color: {{ colors['dark']['bg'] }};
color: {{ colors['dark']['color'] }};
}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
</style>
<div class="box">
<form class="pure-form" action="{{ url_for('tags.form_tag_add') }}" method="POST" id="new-watch-form">
@@ -48,7 +68,7 @@
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a>
</td>
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td>
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a></td>
<td>
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>
<a href="{{ url_for('ui.form_watch_checknow', tag=uuid) }}" class="pure-button pure-button-primary" >{{ _('Recheck') }}</a>
+6 -1
View File
@@ -320,7 +320,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'using_global_webdriver_wait': not default['webdriver_delay'],
'uuid': uuid,
'watch': watch,
'capabilities': capabilities
'capabilities': capabilities,
'auto_applied_tags': {
tag_uuid: tag
for tag_uuid, tag in datastore.data['settings']['application']['tags'].items()
if tag_uuid not in watch.get('tags', []) and tag.matches_url(watch.get('url', ''))
},
}
included_content = None
+6 -7
View File
@@ -10,7 +10,8 @@ from changedetectionio import html_tools
def construct_blueprint(datastore: ChangeDetectionStore):
preview_blueprint = Blueprint('ui_preview', __name__, template_folder="../ui/templates")
@preview_blueprint.route("/preview/<uuid_str:uuid>", methods=['GET'])
@preview_blueprint.route("/preview/<uuid_str:uuid>", methods=['GET', 'POST'])
@login_optionally_required
def preview_page(uuid):
"""
@@ -59,12 +60,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
versions = []
timestamp = None
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
is_html_webdriver = watch.fetcher_supports_screenshots
triggered_line_numbers = []
ignored_line_numbers = []
@@ -74,7 +71,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
flash(gettext("Preview unavailable - No fetch/check completed or triggers not reached"), "error")
else:
# So prepare the latest preview or not
preferred_version = request.args.get('version')
preferred_version = request.values.get('version') if request.method == 'POST' else request.args.get('version')
versions = list(watch.history.keys())
timestamp = versions[-1]
if preferred_version and preferred_version in versions:
@@ -81,6 +81,14 @@
<div class="pure-control-group">
{{ render_field(form.tags) }}
<span class="pure-form-message-inline">{{ _('Organisational tag/group name used in the main listing page') }}</span>
{% if auto_applied_tags %}
<span class="pure-form-message-inline">
{{ _('Also automatically applied by URL pattern:') }}
{% for tag_uuid, tag in auto_applied_tags.items() %}
<a href="{{ url_for('tags.form_tag_edit', uuid=tag_uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a>
{% endfor %}
</span>
{% endif %}
</div>
<div class="pure-control-group inline-radio">
{{ render_field(form.processor) }}
@@ -17,7 +17,7 @@
<script src="{{ url_for('static_content', group='js', filename='tabs.js') }}" defer></script>
{% if versions|length >= 2 %}
<div id="diff-form" style="text-align: center;">
<form class="pure-form " action="" method="POST">
<form class="pure-form " action="{{url_for('ui.ui_preview.preview_page', uuid=uuid)}}" method="POST">
<fieldset>
<label for="preview-version">{{ _('Select timestamp') }}</label> <select id="preview-version"
name="from_version"
@@ -28,6 +28,7 @@
</option>
{% endfor %}
</select>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pure-button pure-button-primary">{{ _('Go') }}</button>
</fieldset>
@@ -92,6 +92,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
extra_classes='has-queue' if not update_q.empty() else '',
form=form,
generate_tag_colors=processors.generate_processor_badge_colors,
wcag_text_color=processors.wcag_text_color,
guid=datastore.data['app_guid'],
has_proxies=proxy_list,
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
@@ -71,6 +71,13 @@ document.addEventListener('DOMContentLoaded', function() {
{%- for uuid, tag in tags -%}
{%- if tag and tag.title -%}
{%- set class_name = tag.title|sanitize_tag_class -%}
{%- if tag.get('tag_colour') -%}
.button-tag.tag-{{ class_name }},
.watch-tag-list.tag-{{ class_name }} {
background-color: {{ tag.tag_colour }};
color: {{ wcag_text_color(tag.tag_colour) }};
}
{%- else -%}
{%- set colors = generate_tag_colors(tag.title) -%}
.button-tag.tag-{{ class_name }} {
background-color: {{ colors['light']['bg'] }};
@@ -92,6 +99,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
color: {{ colors['dark']['color'] }};
}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
</style>
<div class="box" id="form-quick-watch-add">
@@ -49,6 +49,9 @@ async def capture_full_page_async(page, screenshot_format='JPEG', watch_uuid=Non
if page_height > page.viewport_size['height']:
if page_height < step_size:
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
# Never set viewport taller than our max capture height - otherwise one screenshot chunk
# captures the whole (e.g. 8098px) page even when SCREENSHOT_MAX_HEIGHT=1000
step_size = min(step_size, SCREENSHOT_MAX_TOTAL_HEIGHT)
viewport_start = time.time()
logger.debug(f"{watch_info}Setting bigger viewport to step through large page width W{page.viewport_size['width']}xH{step_size} because page_height > viewport_size")
# Set viewport to a larger size to capture more content at once
@@ -75,6 +75,9 @@ async def capture_full_page(page, screenshot_format='JPEG', watch_uuid=None, loc
if page_height > page.viewport['height']:
if page_height < step_size:
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
# Never set viewport taller than our max capture height - otherwise one screenshot chunk
# captures the whole page even when SCREENSHOT_MAX_HEIGHT is set smaller
step_size = min(step_size, SCREENSHOT_MAX_TOTAL_HEIGHT)
viewport_start = time.time()
await page.setViewport({'width': page.viewport['width'], 'height': step_size})
viewport_time = time.time() - viewport_start
@@ -56,6 +56,10 @@ def stitch_images_worker_raw_bytes(pipe_conn, original_page_height, capture_heig
im.close()
del images
# Clip stitched image to capture_height (chunks may overshoot by up to step_size-1 px)
if total_height > capture_height:
stitched = stitched.crop((0, 0, max_width, capture_height))
# Draw caption only if page was trimmed
if original_page_height > capture_height:
draw = ImageDraw.Draw(stitched)
@@ -104,15 +104,17 @@ class fetcher(Fetcher):
from selenium.webdriver.remote.remote_connection import RemoteConnection
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
from selenium.webdriver.remote.client_config import ClientConfig
from urllib3.util import Timeout
driver = None
try:
# Create the RemoteConnection and set timeout (e.g., 30 seconds)
remote_connection = RemoteConnection(
self.browser_connection_url,
connection_timeout = int(os.getenv("WEBDRIVER_CONNECTION_TIMEOUT", 90))
client_config = ClientConfig(
remote_server_addr=self.browser_connection_url,
timeout=Timeout(connect=connection_timeout, total=connection_timeout)
)
remote_connection.set_timeout(30) # seconds
remote_connection = RemoteConnection(client_config=client_config)
# Now create the driver with the RemoteConnection
driver = RemoteWebDriver(
command_executor=remote_connection,
options=options
+58 -16
View File
@@ -45,8 +45,38 @@ CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
# Compiled regex patterns for performance
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
# Regexes built from the constants above — no brittle hardcoded strings
_EXTRACT_REMOVED_RE = re.compile(
re.escape(REMOVED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(REMOVED_PLACEMARKER_CLOSED)
+ r'|' +
re.escape(CHANGED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_PLACEMARKER_CLOSED)
)
_EXTRACT_ADDED_RE = re.compile(
re.escape(ADDED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(ADDED_PLACEMARKER_CLOSED)
+ r'|' +
re.escape(CHANGED_INTO_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_INTO_PLACEMARKER_CLOSED)
)
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:
def extract_changed_from(raw_diff: str) -> str:
"""Extract only the removed/changed-from fragments from a raw diff string.
Useful for {{diff_changed_from}} gives just the old value (e.g. old price),
not the full surrounding line. Multiple fragments joined with newlines.
"""
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_REMOVED_RE.finditer(raw_diff))
def extract_changed_to(raw_diff: str) -> str:
"""Extract only the added/changed-into fragments from a raw diff string.
Useful for {{diff_changed_to}} gives just the new value (e.g. new price),
not the full surrounding line. Multiple fragments joined with newlines.
"""
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_ADDED_RE.finditer(raw_diff))
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html', include_change_type_prefix: bool = True) -> tuple[str, bool]:
"""
Render word-level differences between two lines inline using diff-match-patch library.
@@ -133,14 +163,20 @@ def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool
if removed_tokens:
removed_full = ''.join(removed_tokens).rstrip()
trailing_removed = ''.join(removed_tokens)[len(removed_full):] if len(''.join(removed_tokens)) > len(removed_full) else ''
result_parts.append(f'{CHANGED_PLACEMARKER_OPEN}{removed_full}{CHANGED_PLACEMARKER_CLOSED}{trailing_removed}')
if include_change_type_prefix:
result_parts.append(f'{CHANGED_PLACEMARKER_OPEN}{removed_full}{CHANGED_PLACEMARKER_CLOSED}{trailing_removed}')
else:
result_parts.append(f'{removed_full}{trailing_removed}')
if added_tokens:
if result_parts: # Add newline between removed and added
result_parts.append('\n')
added_full = ''.join(added_tokens).rstrip()
trailing_added = ''.join(added_tokens)[len(added_full):] if len(''.join(added_tokens)) > len(added_full) else ''
result_parts.append(f'{CHANGED_INTO_PLACEMARKER_OPEN}{added_full}{CHANGED_INTO_PLACEMARKER_CLOSED}{trailing_added}')
if include_change_type_prefix:
result_parts.append(f'{CHANGED_INTO_PLACEMARKER_OPEN}{added_full}{CHANGED_INTO_PLACEMARKER_CLOSED}{trailing_added}')
else:
result_parts.append(f'{added_full}{trailing_added}')
return ''.join(result_parts), has_changes
else:
@@ -150,21 +186,27 @@ def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool
if op == 0: # Equal
result_parts.append(text)
elif op == 1: # Insertion
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{ADDED_PLACEMARKER_OPEN}{content}{ADDED_PLACEMARKER_CLOSED}{trailing}')
if not include_change_type_prefix:
result_parts.append(text)
else:
result_parts.append(trailing)
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{ADDED_PLACEMARKER_OPEN}{content}{ADDED_PLACEMARKER_CLOSED}{trailing}')
else:
result_parts.append(trailing)
elif op == -1: # Deletion
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{REMOVED_PLACEMARKER_OPEN}{content}{REMOVED_PLACEMARKER_CLOSED}{trailing}')
if not include_change_type_prefix:
result_parts.append(text)
else:
result_parts.append(trailing)
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{REMOVED_PLACEMARKER_OPEN}{content}{REMOVED_PLACEMARKER_CLOSED}{trailing}')
else:
result_parts.append(trailing)
return ''.join(result_parts), has_changes
@@ -360,7 +402,7 @@ def customSequenceMatcher(
# Use inline word-level diff for single line replacements when word_diff is enabled
if word_diff and len(before_lines) == 1 and len(after_lines) == 1:
inline_diff, has_changes = render_inline_word_diff(before_lines[0], after_lines[0], ignore_junk=ignore_junk, tokenizer=tokenizer)
inline_diff, has_changes = render_inline_word_diff(before_lines[0], after_lines[0], ignore_junk=ignore_junk, tokenizer=tokenizer, include_change_type_prefix=include_change_type_prefix)
# Check if there are any actual changes (not just whitespace when ignore_junk is enabled)
if ignore_junk and not has_changes:
# No real changes, skip this line
+5
View File
@@ -212,6 +212,11 @@ def _is_safe_valid_url(test_url):
from .validate_url import is_safe_valid_url
return is_safe_valid_url(test_url)
@app.template_global('get_html_head_extras')
def _get_html_head_extras():
from .pluggy_interface import collect_html_head_extras
return collect_html_head_extras()
@app.template_filter('format_number_locale')
def _jinja2_filter_format_number_locale(value: float) -> str:
+6 -3
View File
@@ -667,9 +667,11 @@ class ValidateCSSJSONXPATHInput(object):
# `jq` requires full compilation in windows and so isn't generally available
raise ValidationError("jq not support not found")
from changedetectionio.html_tools import validate_jq_expression
input = line.replace('jq:', '')
try:
validate_jq_expression(input)
jq.compile(input)
except (ValueError) as e:
message = field.gettext('\'%s\' is not a valid jq expression. (%s)')
@@ -723,7 +725,7 @@ class ValidateStartsWithRegex(object):
raise ValidationError(self.message or _l("Invalid value."))
class quickWatchForm(Form):
url = fields.URLField(_l('URL'), validators=[validateURL()])
url = StringField(_l('URL'), validators=[validateURL()])
tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
@@ -777,7 +779,7 @@ class SingleBrowserStep(Form):
class processor_text_json_diff_form(commonSettingsForm):
url = fields.URLField('Web Page URL', validators=[validateURL()])
url = StringField('Web Page URL', validators=[validateURL()])
tags = StringTagUUID('Group Tag', [validators.Optional()], default='')
time_between_check = EnhancedFormField(
@@ -796,6 +798,7 @@ class processor_text_json_diff_form(commonSettingsForm):
subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])
extract_lines_containing = StringListField(_l('Extract lines containing'), [validators.Optional()])
extract_text = StringListField(_l('Extract text'), [ValidateListRegex()])
title = StringField(_l('Title'), default='')
@@ -1005,7 +1008,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
render_kw={"placeholder": "0.1", "style": "width: 8em;"}
)
password = SaltyPasswordField(_l('Password'))
password = SaltyPasswordField(_l('Password'), render_kw={"autocomplete": "new-password"})
pager_size = IntegerField(_l('Pager size'),
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0,
+71 -9
View File
@@ -4,6 +4,7 @@ from loguru import logger
from typing import List
import html
import json
import os
import re
# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis
@@ -13,6 +14,45 @@ PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
META_CS = re.compile(r'<meta[^>]+charset=["\']?\s*([a-z0-9_\-:+.]+)', re.I)
# jq builtins that can leak sensitive data or cause harm when user-supplied expressions are executed.
# env/$ENV reads all process environment variables (passwords, API keys, etc.)
# include/import can read arbitrary files from disk
# input/inputs reads beyond the supplied JSON data
# debug/stderr leaks data to stderr
# halt/halt_error terminates the process (DoS)
_JQ_BLOCKED_PATTERNS = [
(re.compile(r'\benv\b'), 'env (reads environment variables)'),
(re.compile(r'\$ENV\b'), '$ENV (reads environment variables)'),
(re.compile(r'\binclude\b'), 'include (reads files from disk)'),
(re.compile(r'\bimport\b'), 'import (reads files from disk)'),
(re.compile(r'\binputs?\b'), 'input/inputs (reads beyond provided data)'),
(re.compile(r'\bdebug\b'), 'debug (leaks data to stderr)'),
(re.compile(r'\bstderr\b'), 'stderr (leaks data to stderr)'),
(re.compile(r'\bhalt(?:_error)?\b'), 'halt/halt_error (terminates the process)'),
(re.compile(r'\$__loc__\b'), '$__loc__ (leaks file path information)'),
(re.compile(r'\bbuiltins\b'), 'builtins (enumerates available functions)'),
(re.compile(r'\bmodulemeta\b'), 'modulemeta (leaks module information)'),
(re.compile(r'\$JQ_BUILD_CONFIGURATION\b'), '$JQ_BUILD_CONFIGURATION (leaks build information)'),
]
def validate_jq_expression(expression: str) -> None:
"""Raise ValueError if the jq expression uses any dangerous builtin.
User-supplied jq expressions are executed server-side. Without this check,
builtins like `env` expose every process environment variable (SALTED_PASS,
proxy credentials, API keys, etc.) as watch output.
"""
from changedetectionio.strtobool import strtobool
if strtobool(os.getenv('JQ_ALLOW_RISKY_EXPRESSIONS', 'false')):
return
for pattern, description in _JQ_BLOCKED_PATTERNS:
if pattern.search(expression):
msg = f"jq expression uses disallowed builtin: {description}"
logger.critical(f"Security: blocked jq expression containing '{description}' - expression: {expression!r}")
raise ValueError(msg)
META_CT = re.compile(r'<meta[^>]+http-equiv=["\']?content-type["\']?[^>]*content=["\'][^>]*charset=([a-z0-9_\-:+.]+)', re.I)
# 'price' , 'lowPrice', 'highPrice' are usually under here
@@ -30,6 +70,12 @@ _DEFAULT_UNSAFE_XPATH3_FUNCTIONS = [
'unparsed-text-available',
'doc',
'doc-available',
'json-doc',
'json-doc-available',
'collection', # XPath 2.0+: loads XML node collections from arbitrary URIs
'uri-collection', # XPath 3.0+: enumerates URIs from resource collections
'transform', # XPath 3.1: XSLT transformation (currently raises, block proactively)
'load-xquery-module', # XPath 3.1: loads XQuery modules (currently raises, block proactively)
'environment-variable',
'available-environment-variables',
]
@@ -378,12 +424,16 @@ def _parse_json(json_data, json_filter):
raise Exception("jq not support not found")
if json_filter.startswith("jq:"):
jq_expression = jq.compile(json_filter.removeprefix("jq:"))
expr = json_filter.removeprefix("jq:")
validate_jq_expression(expr)
jq_expression = jq.compile(expr)
match = jq_expression.input(json_data).all()
return _get_stripped_text_from_json_match(match)
if json_filter.startswith("jqraw:"):
jq_expression = jq.compile(json_filter.removeprefix("jqraw:"))
expr = json_filter.removeprefix("jqraw:")
validate_jq_expression(expr)
jq_expression = jq.compile(expr)
match = jq_expression.input(json_data).all()
return '\n'.join(str(item) for item in match)
@@ -487,13 +537,25 @@ def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None
except json.JSONDecodeError as e:
logger.warning(f"Error processing JSON {content[:20]}...{str(e)})")
else:
# Probably something else, go fish inside for it
try:
stripped_text_from_html = extract_json_blob_from_html(content=content,
ensure_is_ldjson_info_type=ensure_is_ldjson_info_type,
json_filter=json_filter )
except json.JSONDecodeError as e:
logger.warning(f"Error processing JSON while extracting JSON from HTML blob {content[:20]}...{str(e)})")
# Check for JSONP wrapper: someCallback({...}) or some.namespace({...})
# Server may claim application/json but actually return JSONP
jsonp_match = re.match(r'^\w[\w.]*\s*\((.+)\)\s*;?\s*$', content.lstrip("\ufeff").strip(), re.DOTALL)
if jsonp_match:
try:
inner = jsonp_match.group(1).strip()
logger.warning(f"Content looks like JSONP, attempting to extract inner JSON for filter '{json_filter}'")
stripped_text_from_html = _parse_json(json.loads(inner), json_filter)
except json.JSONDecodeError as e:
logger.warning(f"Error processing JSONP inner content {content[:20]}...{str(e)})")
if not stripped_text_from_html:
# Probably something else, go fish inside for it
try:
stripped_text_from_html = extract_json_blob_from_html(content=content,
ensure_is_ldjson_info_type=ensure_is_ldjson_info_type,
json_filter=json_filter)
except json.JSONDecodeError as e:
logger.warning(f"Error processing JSON while extracting JSON from HTML blob {content[:20]}...{str(e)})")
if not stripped_text_from_html:
# Re 265 - Just return an empty string when filter not found
+14 -11
View File
@@ -28,18 +28,20 @@ def get_timeago_locale(flask_locale):
str: timeago library locale code (e.g., 'en', 'zh_CN', 'pt_PT')
"""
locale_map = {
'zh': 'zh_CN', # Chinese Simplified
'zh': 'zh_CN', # Chinese Simplified
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
'zh_TW': 'zh_TW', # Chinese Traditional (timeago uses zh_TW)
'zh_TW': 'zh_TW', # Chinese Traditional (timeago uses zh_TW)
'zh_Hant_TW': 'zh_TW', # Flask-Babel normalizes zh_TW to zh_Hant_TW, map back to timeago's zh_TW
'pt': 'pt_PT', # Portuguese (Portugal)
'sv': 'sv_SE', # Swedish
'no': 'nb_NO', # Norwegian Bokmål
'hi': 'in_HI', # Hindi
'cs': 'en', # Czech not supported by timeago, fallback to English
'uk': 'uk', # Ukrainian
'en_GB': 'en', # British English - timeago uses 'en'
'en_US': 'en', # American English - timeago uses 'en'
'pt': 'pt_PT', # Portuguese (Portugal)
'pt_BR': 'pt_BR', # Portuguese (Brasil)
'sv': 'sv_SE', # Swedish
'no': 'nb_NO', # Norwegian Bokmål
'hi': 'in_HI', # Hindi
'cs': 'en', # Czech not supported by timeago, fallback to English
'ja': 'ja', # Japanese
'uk': 'uk', # Ukrainian
'en_GB': 'en', # British English - timeago uses 'en'
'en_US': 'en', # American English - timeago uses 'en'
}
return locale_map.get(flask_locale, flask_locale)
@@ -53,7 +55,8 @@ LANGUAGE_DATA = {
'ko': {'flag': 'fi fi-kr fis', 'name': '한국어'},
'cs': {'flag': 'fi fi-cz fis', 'name': 'Čeština'},
'es': {'flag': 'fi fi-es fis', 'name': 'Español'},
'pt': {'flag': 'fi fi-pt fis', 'name': 'Português'},
'pt': {'flag': 'fi fi-pt fis', 'name': 'Português (Portugal)'},
'pt_BR': {'flag': 'fi fi-br fis', 'name': 'Português (Brasil)'},
'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},
'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},
'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},
+15
View File
@@ -46,11 +46,26 @@ class model(EntityPersistenceMixin, watch_base):
super(model, self).__init__(*arg, **kw)
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
self['url_match_pattern'] = kw.get('default', {}).get('url_match_pattern', '')
if kw.get('default'):
self.update(kw['default'])
del kw['default']
def matches_url(self, url: str) -> bool:
"""Return True if this tag should be auto-applied to the given watch URL.
Wildcard patterns (*,?,[ ) use fnmatch; anything else is a case-insensitive
substring match. Returns False if no pattern is configured.
"""
import fnmatch
pattern = self.get('url_match_pattern', '').strip()
if not pattern or not url:
return False
if any(c in pattern for c in ('*', '?', '[')):
return fnmatch.fnmatch(url.lower(), pattern.lower())
return pattern.lower() in url.lower()
# _save_to_disk() method provided by EntityPersistenceMixin
# commit() and _get_commit_data() methods inherited from watch_base
# Tag uses default _get_commit_data() (includes all keys)
+19
View File
@@ -388,6 +388,25 @@ class model(EntityPersistenceMixin, watch_base):
return self.get('fetch_backend')
@property
def fetcher_supports_screenshots(self):
"""Return True if the fetcher configured for this watch supports screenshots.
Resolves 'system' via self._datastore, then checks supports_screenshots on
the actual fetcher class. Works for built-in and plugin fetchers alike.
"""
from changedetectionio import content_fetchers
fetcher_name = self.get_fetch_backend # already handles is_pdf → html_requests
if not fetcher_name or fetcher_name == 'system':
fetcher_name = self._datastore['settings']['application'].get('fetch_backend', 'html_requests')
fetcher_class = getattr(content_fetchers, fetcher_name, None)
if fetcher_class is None:
return False
return bool(getattr(fetcher_class, 'supports_screenshots', False))
@property
def is_pdf(self):
url = str(self.get("url") or "").lower()
+1
View File
@@ -186,6 +186,7 @@ class watch_base(dict):
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
'content-type': None,
'date_created': None,
'extract_lines_containing': [], # Keep only lines containing these substrings (plain text, case-insensitive)
'extract_text': [], # Extract text by regex after filters
'fetch_backend': 'system', # plaintext, playwright etc
'fetch_time': 0.0,
+43 -8
View File
@@ -88,6 +88,28 @@ class FormattableTimestamp(str):
return self._dt.isoformat()
class FormattableExtract(str):
"""
A str subclass that holds only the extracted changed fragments from a diff.
Used for {{diff_changed_from}} and {{diff_changed_to}} tokens.
{{ diff_changed_from }} old value(s) only, e.g. "$99.99"
{{ diff_changed_to }} new value(s) only, e.g. "$109.99"
Multiple changed fragments are joined with newlines.
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
raw = diff_module.render_diff(prev_snapshot, current_snapshot, word_diff=True)
extracted = extract_fn(raw)
else:
extracted = ''
instance = super().__new__(cls, extracted)
return instance
class FormattableDiff(str):
"""
A str subclass representing a rendered diff. As a plain string it renders
@@ -161,6 +183,8 @@ class NotificationContextData(dict):
'diff_patch': FormattableDiff('', '', patch_format=True),
'diff_removed': FormattableDiff('', '', include_added=False),
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_url': None,
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
'notification_timestamp': time.time(),
@@ -244,16 +268,27 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
}
from changedetectionio.diff import extract_changed_from, extract_changed_to
extract_specs = {
'diff_changed_from': extract_changed_from,
'diff_changed_to': extract_changed_to,
}
ret = {}
rendered_count = 0
# Only create FormattableDiff objects for diff keys actually used in the notification text
# Only create FormattableDiff/FormattableExtract objects for diff keys actually used in the notification text
for key in NotificationContextData().keys():
if key.startswith('diff') and key in diff_specs:
# Check if this placeholder is actually used in the notification text
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if re.search(pattern, notification_scan_text, re.IGNORECASE):
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
rendered_count += 1
if not key.startswith('diff'):
continue
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
continue
if key in diff_specs:
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
rendered_count += 1
elif key in extract_specs:
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
rendered_count += 1
if rendered_count:
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")
@@ -461,7 +496,7 @@ Thanks - Your omniscient changedetection.io installation.
n_object = NotificationContextData({
'notification_title': f"Changedetection.io - Alert - Browser step at position {step} could not be run",
'notification_body': body,
'notification_format': self._check_cascading_vars('notification_format', watch),
'notification_format': _check_cascading_vars(self.datastore, 'notification_format', watch),
})
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')
+75 -1
View File
@@ -174,6 +174,64 @@ class ChangeDetectionSpec:
"""
pass
@hookspec
def get_html_head_extras():
"""Return HTML to inject into the <head> of every page via base.html.
Plugins can use this to add <script>, <style>, or <link> tags that should
be present on all pages. Return a raw HTML string or None.
IMPORTANT: Always use Flask's url_for() for any src/href URLs so that
sub-path deployments (nginx reverse proxy with USE_X_SETTINGS / X-Forwarded-Prefix)
work correctly. This hook is called inside a request context so url_for() is
always available.
For small amounts of CSS/JS, return them inline no file-serving needed::
from changedetectionio.pluggy_interface import hookimpl
@hookimpl
def get_html_head_extras(self):
return (
'<style>.my-module-banner { color: red; }</style>\\n'
'<script>console.log("my_module_content loaded");</script>'
)
For larger assets, register your own lightweight Flask routes in the plugin
module and point to them with url_for() so the sub-path prefix is handled
automatically::
from flask import url_for, Response
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.flask_app import app as _app
MY_CSS = ".my-module-example { color: red; }"
MY_JS = "console.log('my_module_content loaded');"
@_app.route('/my_module_content/css')
def my_module_content_css():
return Response(MY_CSS, mimetype='text/css',
headers={'Cache-Control': 'max-age=3600'})
@_app.route('/my_module_content/js')
def my_module_content_js():
return Response(MY_JS, mimetype='application/javascript',
headers={'Cache-Control': 'max-age=3600'})
@hookimpl
def get_html_head_extras(self):
css = url_for('my_module_content_css')
js = url_for('my_module_content_js')
return (
f'<link rel="stylesheet" href="{css}">\\n'
f'<script src="{js}" defer></script>'
)
Returns:
str or None: Raw HTML string to inject inside <head>, or None
"""
pass
# Set up Plugin Manager
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
@@ -606,4 +664,20 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
except Exception as e:
# Don't let plugin errors crash the worker
logger.error(f"Error in update_finalize hook: {e}")
logger.exception(f"update_finalize hook exception details:")
logger.exception(f"update_finalize hook exception details:")
def collect_html_head_extras():
"""Collect and combine HTML head extras from all plugins.
Called from a Flask template global so it always runs inside a request context.
This means url_for() works correctly in plugin implementations, including when the
app is deployed under a sub-path via USE_X_SETTINGS / X-Forwarded-Prefix (ProxyFix
sets SCRIPT_NAME so url_for() automatically prepends the prefix).
Returns:
str: Combined HTML string to inject inside <head>, or empty string
"""
results = plugin_manager.hook.get_html_head_extras()
parts = [r for r in results if r]
return "\n".join(parts) if parts else ""
+12
View File
@@ -341,6 +341,18 @@ def get_processor_descriptions():
return descriptions
def wcag_text_color(hex_bg: str) -> str:
"""Return #000000 or #ffffff for maximum WCAG contrast against hex_bg."""
hex_bg = hex_bg.lstrip('#')
if len(hex_bg) != 6:
return '#000000'
r, g, b = (int(hex_bg[i:i+2], 16) / 255 for i in (0, 2, 4))
def lin(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
return '#000000' if L > 0.179 else '#ffffff'
def generate_processor_badge_colors(processor_name):
"""
Generate consistent colors for a processor badge based on its name.
+1 -4
View File
@@ -42,10 +42,7 @@ def render_form(watch, datastore, request, url_for, render_template, flash, redi
# Get error information for the template
screenshot_url = watch.get_screenshot()
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
is_html_webdriver = watch.fetcher_supports_screenshots
password_enabled_and_share_is_off = False
if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
+7 -1
View File
@@ -100,7 +100,13 @@ class guess_stream_type():
if any(s in http_content_header for s in RSS_XML_CONTENT_TYPES):
self.is_rss = True
elif any(s in http_content_header for s in JSON_CONTENT_TYPES):
self.is_json = True
# JSONP detection: server claims application/json but content is actually JSONP (e.g. cb({...}))
# A JSONP response starts with an identifier followed by '(' - not valid JSON
if re.match(r'^\w[\w.]*\s*\(', test_content):
logger.warning(f"Content-Type header claims JSON but content looks like JSONP (starts with identifier+parenthesis) - treating as plaintext")
self.is_plaintext = True
else:
self.is_json = True
elif 'pdf' in magic_content_header:
self.is_pdf = True
# magic will call a rss document 'xml'
@@ -1,6 +1,7 @@
from babel.numbers import parse_decimal
from changedetectionio.model.Watch import model as BaseWatch
from decimal import Decimal, InvalidOperation
from typing import Union
import re
@@ -10,6 +11,8 @@ supports_browser_steps = True
supports_text_filters_and_triggers = True
supports_text_filters_and_triggers_elements = True
supports_request_type = True
_price_re = re.compile(r"Price:\s*(\d+(?:\.\d+)?)", re.IGNORECASE)
class Restock(dict):
@@ -63,6 +66,17 @@ class Restock(dict):
super().__setitem__(key, value)
def get_price_from_history_str(history_str):
m = _price_re.search(history_str)
if not m:
return None
try:
return str(Decimal(m.group(1)))
except InvalidOperation:
return None
class Watch(BaseWatch):
def __init__(self, *arg, **kw):
super().__init__(*arg, **kw)
@@ -76,13 +90,27 @@ class Watch(BaseWatch):
def extra_notification_token_values(self):
values = super().extra_notification_token_values()
values['restock'] = self.get('restock', {})
values['restock']['previous_price'] = None
if self.history_n >= 2:
history = self.history
if history and len(history) >=2:
"""Unfortunately for now timestamp is stored as string key"""
sorted_keys = sorted(list(history), key=lambda x: int(x))
sorted_keys.reverse()
price_str = self.get_history_snapshot(timestamp=sorted_keys[-1])
if price_str:
values['restock']['previous_price'] = get_price_from_history_str(price_str)
return values
def extra_notification_token_placeholder_info(self):
values = super().extra_notification_token_placeholder_info()
values.append(('restock.price', "Price detected"))
values.append(('restock.in_stock', "In stock status"))
values.append(('restock.original_price', "Original price at first check"))
values.append(('restock.previous_price', "Previous price in history"))
return values
@@ -154,11 +154,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
screenshot_url = watch.get_screenshot()
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
is_html_webdriver = watch.fetcher_supports_screenshots
password_enabled_and_share_is_off = False
if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
@@ -85,6 +85,10 @@ class FilterConfig:
self._subtractive_selectors_cache = [*tag_selectors, *watch_selectors, *global_selectors]
return self._subtractive_selectors_cache
@property
def extract_lines_containing(self):
return self._get_merged_rules('extract_lines_containing')
@property
def extract_text(self):
return self._get_merged_rules('extract_text')
@@ -135,6 +139,17 @@ class ContentTransformer:
text = text.replace("\n\n", "\n")
return '\n'.join(sorted(text.splitlines(), key=lambda x: x.lower()))
@staticmethod
def extract_lines_containing(text, substrings):
"""Keep only lines that contain at least one of the given substrings (case-insensitive)."""
needles = [s.lower() for s in substrings if s.strip()]
if not needles:
return text
return '\n'.join(
line for line in text.splitlines()
if any(needle in line.lower() for needle in needles)
)
@staticmethod
def extract_by_regex(text, regex_patterns):
"""Extract text matching regex patterns."""
@@ -503,6 +518,10 @@ class perform_site_check(difference_detection_processor):
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
# === LINE FILTER (plain-text substring) ===
if filter_config.extract_lines_containing:
stripped_text = transformer.extract_lines_containing(stripped_text, filter_config.extract_lines_containing)
# === REGEX EXTRACTION ===
if filter_config.extract_text:
extracted = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
+2
View File
@@ -29,9 +29,11 @@ def register_watch_operation_handlers(socketio, datastore):
# Perform the operation
if op == 'pause':
watch.toggle_pause()
watch.commit()
logger.info(f"Socket.IO: Toggled pause for watch {uuid}")
elif op == 'mute':
watch.toggle_mute()
watch.commit()
logger.info(f"Socket.IO: Toggled mute for watch {uuid}")
elif op == 'recheck':
# Import here to avoid circular imports
@@ -199,8 +199,31 @@ def handle_watch_update(socketio, **kwargs):
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def _suppress_werkzeug_ws_abrupt_disconnect_noise():
"""Patch BaseWSGIServer.log to suppress the AssertionError traceback that fires when
a browser closes a WebSocket connection mid-handshake (e.g. closing a tab).
The exception is caught inside run_wsgi and routed to self.server.log() it never
propagates out, so wrapping run_wsgi doesn't help. Patching the log method is the
only reliable intercept point. The error is cosmetic: Socket.IO already handles the
disconnect correctly via its own disconnect handler and timeout logic."""
try:
from werkzeug.serving import BaseWSGIServer
_original_log = BaseWSGIServer.log
def _filtered_log(self, type, message, *args):
if type == 'error' and 'write() before start_response' in message:
return
_original_log(self, type, message, *args)
BaseWSGIServer.log = _filtered_log
except Exception:
pass
def init_socketio(app, datastore):
"""Initialize SocketIO with the main Flask app"""
_suppress_werkzeug_ws_abrupt_disconnect_noise()
import platform
import sys
+8
View File
@@ -116,6 +116,14 @@ $(document).ready(function () {
$('#realtime-conn-error').show();
});
// Tell the server we're leaving cleanly so it can release the connection
// immediately rather than waiting for a timeout.
// Note: this only fires for voluntary closes (tab/window close, navigation away).
// Hard kills, crashes and network drops will still timeout normally on the server.
window.addEventListener('beforeunload', function () {
socket.disconnect();
});
socket.on('queue_size', function (data) {
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
if(queueSizePagerInfoText) {
+12 -4
View File
@@ -980,12 +980,20 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
def get_all_tags_for_watch(self, uuid):
"""This should be in Watch model but Watch doesn't have access to datastore, not sure how to solve that yet"""
watch = self.data['watching'].get(uuid)
if not watch:
return {}
# Should return a dict of full tag info linked by UUID
if watch:
return dictfilt(self.__data['settings']['application']['tags'], watch.get('tags', []))
# Start with manually assigned tags
result = dictfilt(self.__data['settings']['application']['tags'], watch.get('tags', []))
return {}
# Additionally include any tag whose url_match_pattern matches this watch's URL
watch_url = watch.get('url', '')
if watch_url:
for tag_uuid, tag in self.__data['settings']['application']['tags'].items():
if tag_uuid not in result and tag.matches_url(watch_url):
result[tag_uuid] = tag
return result
@property
def extra_browsers(self):
@@ -98,6 +98,14 @@
<td><code>{{ '{{diff_patch}}' }}</code></td>
<td>{{ _('The diff output - patch in unified format') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_changed_from}}' }}</code></td>
<td>{{ _('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.') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_changed_to}}' }}</code></td>
<td>{{ _('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.') }}</td>
</tr>
<tr>
<td><code>{{ '{{current_snapshot}}' }}</code></td>
<td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}
+4
View File
@@ -45,6 +45,10 @@
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
{% endif %}
{%- set _html_head_extras = get_html_head_extras() -%}
{%- if _html_head_extras %}
{{ _html_head_extras | safe }}
{%- endif %}
</head>
<body class="{{extra_classes}}">
@@ -49,6 +49,21 @@ Unavailable") }}
</span>
</div>
</fieldset>
<fieldset>
<div class="pure-control-group">
{{ render_field(form.extract_lines_containing, rows=5, placeholder="celsius
temperature
price") }}
<span class="pure-form-message-inline">
<ul>
<li>{{ _('Keep only lines that contain any of these words or phrases (plain text, case-insensitive)') }}</li>
<li>{{ _('One entry per line — any line in the page text that contains a match is kept') }}</li>
<li>{{ _('Simpler alternative to regex — use this when you just want lines about a specific topic') }}</li>
<li>{{ _('Example: enter') }} <code>celsius</code> {{ _('to keep only lines mentioning temperature readings') }}</li>
</ul>
</span>
</div>
</fieldset>
<fieldset>
<div class="pure-control-group">
{{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/
@@ -0,0 +1,83 @@
"""Test that plugins can inject HTML into base.html <head> via get_html_head_extras hookimpl."""
import pytest
from flask import url_for, Response
from changedetectionio.pluggy_interface import hookimpl, plugin_manager
_MY_JS = "console.log('my_module_content loaded');"
_MY_CSS = ".my-module-example { color: red; }"
class _HeadExtrasPlugin:
"""Test plugin that injects tags pointing at its own Flask routes."""
@hookimpl
def get_html_head_extras(self):
css_url = url_for('test_plugin_my_module_content_css')
js_url = url_for('test_plugin_my_module_content_js')
return (
f'<link rel="stylesheet" id="test-head-extra-css" href="{css_url}">\n'
f'<script id="test-head-extra-js" src="{js_url}" defer></script>'
)
@pytest.fixture(scope='module')
def plugin_routes(live_server):
"""Register plugin asset routes once per module (Flask routes can't be added twice)."""
app = live_server.app
@app.route('/test-plugin/my_module_content/css')
def test_plugin_my_module_content_css():
return Response(_MY_CSS, mimetype='text/css',
headers={'Cache-Control': 'max-age=3600'})
@app.route('/test-plugin/my_module_content/js')
def test_plugin_my_module_content_js():
return Response(_MY_JS, mimetype='application/javascript',
headers={'Cache-Control': 'max-age=3600'})
@pytest.fixture
def head_extras_plugin(plugin_routes):
"""Register the hookimpl for one test then unregister it — function-scoped for clean isolation."""
plugin = _HeadExtrasPlugin()
plugin_manager.register(plugin, name="test_head_extras")
yield plugin
plugin_manager.unregister(name="test_head_extras")
def test_plugin_html_injected_into_head(client, live_server, measure_memory_usage, datastore_path, head_extras_plugin):
"""get_html_head_extras output must appear inside <head> in the rendered page."""
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert res.status_code == 200
assert b'id="test-head-extra-css"' in res.data, "Plugin <link> tag missing from rendered page"
assert b'id="test-head-extra-js"' in res.data, "Plugin <script> tag missing from rendered page"
head_end = res.data.find(b'</head>')
assert head_end != -1
for marker in (b'id="test-head-extra-css"', b'id="test-head-extra-js"'):
pos = res.data.find(marker)
assert pos != -1 and pos < head_end, f"{marker} must appear before </head>"
def test_plugin_js_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
"""The plugin-registered JS route must return JS with the right Content-Type."""
res = client.get(url_for('test_plugin_my_module_content_js'))
assert res.status_code == 200
assert 'javascript' in res.content_type
assert _MY_JS.encode() in res.data
def test_plugin_css_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
"""The plugin-registered CSS route must return CSS with the right Content-Type."""
res = client.get(url_for('test_plugin_my_module_content_css'))
assert res.status_code == 200
assert 'css' in res.content_type
assert _MY_CSS.encode() in res.data
def test_no_extras_without_plugin(client, live_server, measure_memory_usage, datastore_path):
"""With no hookimpl registered the markers must not appear (isolation check)."""
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert b'id="test-head-extra-css"' not in res.data
assert b'id="test-head-extra-js"' not in res.data
@@ -11,10 +11,10 @@ from changedetectionio.tests.util import set_original_response, set_modified_res
set_longer_modified_response, delete_all_watches
import logging
import os
# NOTE - RELIES ON mailserver as hostname running, see github build recipes
smtp_test_server = 'mailserver'
smtp_test_server = os.getenv('SMTP_TEST_MAILSERVER', 'mailserver')
ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys())
+14
View File
@@ -170,6 +170,14 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
headers={'x-api-key': api_key},
)
assert b'(changed) Which is across' in res.data
assert b'Some text thats the same' in res.data
# Fetch the difference between two versions (default text format)
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+"?changesOnly=true",
headers={'x-api-key': api_key},
)
assert b'Some text thats the same' not in res.data
# Test htmlcolor format
res = client.get(
@@ -366,6 +374,9 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
watch['last_changed'] = 454444444444
watch['date_created'] = 454444444444
# Exercise the new extract_lines_containing field
watch['extract_lines_containing'] = ['celsius', 'temperature']
# HTTP PUT ( UPDATE an existing watch )
res = client.put(
url_for("watch", uuid=uuid),
@@ -389,6 +400,9 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
assert date_created != 454444444444
assert date_created != "454444444444"
assert res.json.get('extract_lines_containing') == ['celsius', 'temperature'], \
"extract_lines_containing should be persisted and returned via API"
def test_access_denied(client, live_server, measure_memory_usage, datastore_path):
# `config_api_token_enabled` Should be On by default
+26 -5
View File
@@ -178,23 +178,44 @@ def test_api_tags_listing(client, live_server, measure_memory_usage, datastore_p
def test_api_tag_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):
"""
Test that a tag/group can be updated with processor_config_restock_diff via the API.
Test that a tag/group can be created and updated with processor_config_restock_diff via the API.
Since Tag extends WatchBase, processor config fields injected into WatchBase are also valid for tags.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
# Create a tag
# Create a tag with processor_config_restock_diff in a single POST (issue #3966)
res = client.post(
url_for("tag"),
data=json.dumps({"title": "Restock Group"}),
data=json.dumps({
"title": "Restock Group",
"overrides_watch": True,
"processor_config_restock_diff": {
"in_stock_processing": "in_stock_only",
"follow_price_changes": True,
"price_change_min": 7777777
}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key}
)
assert res.status_code == 201
assert res.status_code == 201, f"POST tag with restock config failed: {res.data}"
tag_uuid = res.json.get('uuid')
# Update tag with valid processor_config_restock_diff
# Verify processor config was saved during creation (the bug: these were discarded)
res = client.get(
url_for("tag", uuid=tag_uuid),
headers={'x-api-key': api_key}
)
assert res.status_code == 200
tag_data = res.json
assert tag_data.get('overrides_watch') == True, "overrides_watch should be saved on POST"
assert tag_data.get('processor_config_restock_diff', {}).get('in_stock_processing') == 'in_stock_only', \
"processor_config_restock_diff should be saved on POST"
assert tag_data.get('processor_config_restock_diff', {}).get('price_change_min') == 7777777, \
"price_change_min should be saved on POST"
# Update tag with valid processor_config_restock_diff via PUT
res = client.put(
url_for("tag", uuid=tag_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
+34
View File
@@ -48,6 +48,15 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
# Check this class does not appear (that we didnt see the actual source)
assert b'foobar-detection' not in res.data
# Check POST preview
res = client.post(
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
# Check this class does not appear (that we didnt see the actual source)
assert b'foobar-detection' not in res.data
# Make a change
set_modified_response(datastore_path=datastore_path)
@@ -413,3 +422,28 @@ def test_plaintext_even_if_xml_content_and_can_apply_filters(client, live_server
assert b'&lt;foobar' not in res.data
res = delete_all_watches(client)
def test_last_error_cleared_on_same_checksum(client, live_server, datastore_path):
"""last_error should be cleared even when content is unchanged (checksumFromPreviousCheckWasTheSame path)"""
set_original_response(datastore_path=datastore_path)
uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))
# First check - establishes baseline checksum
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# Inject a stale last_error directly (simulates a prior failed check)
datastore = client.application.config.get('DATASTORE')
datastore.update_watch(uuid=uuid, update_obj={'last_error': 'Some previous error'})
assert datastore.data['watching'][uuid].get('last_error') == 'Some previous error'
# Second check - same content, so checksumFromPreviousCheckWasTheSame will fire
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# last_error must be cleared even though no change was detected
assert datastore.data['watching'][uuid].get('last_error') == False
delete_all_watches(client)
@@ -220,3 +220,344 @@ def test_regex_error_handling(client, live_server, measure_memory_usage, datasto
assert b'is not a valid regular expression.' in res.data
delete_all_watches(client)
def test_extract_lines_containing(client, live_server, measure_memory_usage, datastore_path):
"""Test the 'extract_lines_containing' filter keeps only lines with matching substrings."""
test_return_data = """<html>
<body>
<p>Current temperature: 21 celsius</p>
<p>Humidity: 55%</p>
<p>Wind speed: 10 km/h</p>
<p>Feels like: 19 celsius</p>
<p>UV index: 3</p>
</body>
</html>
"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(test_return_data)
test_url = url_for('test_endpoint', _external=True)
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)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
'extract_lines_containing': 'celsius',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid), follow_redirects=True)
# Lines containing 'celsius' should be present
assert b'celsius' in res.data
# Lines without 'celsius' should be excluded
assert b'Humidity' not in res.data
assert b'Wind speed' not in res.data
assert b'UV index' not in res.data
delete_all_watches(client)
def test_extract_lines_containing_case_insensitive(client, live_server, measure_memory_usage, datastore_path):
"""Test that extract_lines_containing is case-insensitive."""
test_return_data = """<html>
<body>
<p>PRICE: $99.99</p>
<p>Price drops to $79.99</p>
<p>Stock: Available</p>
<p>price history shows decline</p>
</body>
</html>
"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(test_return_data)
test_url = url_for('test_endpoint', _external=True)
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)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
'extract_lines_containing': 'price',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid), follow_redirects=True)
# All three price lines (different cases) should match
assert b'$99.99' in res.data
assert b'$79.99' in res.data
assert b'price history' in res.data
# Non-price line should be excluded
assert b'Stock' not in res.data
delete_all_watches(client)
def test_extract_lines_containing_multiple_terms(client, live_server, measure_memory_usage, datastore_path):
"""Test that multiple extract_lines_containing entries act as OR (keep line if any term matches)."""
test_return_data = """<html>
<body>
<p>Temperature: 21 celsius</p>
<p>Humidity: 55%</p>
<p>Wind speed: 10 km/h</p>
<p>Rain chance: 20%</p>
</body>
</html>
"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(test_return_data)
test_url = url_for('test_endpoint', _external=True)
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)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
'extract_lines_containing': 'celsius\r\nhumidity',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid), follow_redirects=True)
assert b'celsius' in res.data
assert b'Humidity' in res.data
# Wind and Rain lines should be excluded
assert b'Wind speed' not in res.data
assert b'Rain chance' not in res.data
delete_all_watches(client)
def test_extract_lines_containing_with_ignore_text(client, live_server, measure_memory_usage, datastore_path):
"""
extract_lines_containing narrows to matching lines; ignore_text then suppresses specific
lines from triggering change detection (they remain visible but don't affect the checksum).
Filters are set BEFORE the first check so the filtered+ignored checksum is the baseline
from the very start no race between a forced-recheck and the next content write.
"""
initial_data = """<html><body>
<p>Temperature: 21 celsius</p>
<p>Feels like: 19 celsius</p>
<p>Humidity: 55%</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(initial_data)
test_url = url_for('test_endpoint', _external=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),
data={
'extract_lines_containing': 'celsius',
'ignore_text': 'Feels like',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
},
follow_redirects=True
)
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)
assert b'celsius' in res.data
assert b'Humidity' not in res.data
# Change ONLY the ignored "Feels like" line — should NOT trigger a change
changed_data = """<html><body>
<p>Temperature: 21 celsius</p>
<p>Feels like: 17 celsius</p>
<p>Humidity: 55%</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(changed_data)
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, \
"Changing an ignored line should not trigger a change notification"
client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
# Change the non-ignored celsius line — SHOULD trigger
triggered_data = """<html><body>
<p>Temperature: 30 celsius</p>
<p>Feels like: 17 celsius</p>
<p>Humidity: 55%</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(triggered_data)
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' in res.data, \
"Changing a non-ignored line should trigger a change notification"
delete_all_watches(client)
def test_extract_lines_containing_with_extract_text_regex(client, live_server, measure_memory_usage, datastore_path):
"""
extract_lines_containing first narrows to relevant lines, then extract_text regex
pulls specific tokens from those lines verifying correct pipeline ordering.
"""
test_return_data = """<html><body>
<p>Widget price: $49.99 each</p>
<p>Gadget price: $129.00 each</p>
<p>Latest news: price index up 2%</p>
<p>Stock count: 150 units</p>
<p>Shipping cost: $5.99</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(test_return_data)
test_url = url_for('test_endpoint', _external=True)
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)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
# Step 1: keep lines containing "price" (excludes Stock count and Shipping cost)
'extract_lines_containing': 'price',
# Step 2: from those lines extract only dollar amounts
'extract_text': r'/\$[\d.]+/',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid), follow_redirects=True)
# Dollar amounts from price lines should be extracted
assert b'$49.99' in res.data
assert b'$129.00' in res.data
# "price index up 2%" has no dollar amount — nothing extracted from that line
# "Shipping cost" line was excluded by extract_lines_containing before regex ran
assert b'$5.99' not in res.data
# Raw line text should not appear — regex replaced it with just the match
assert b'Widget' not in res.data
assert b'Stock count' not in res.data
delete_all_watches(client)
def test_extract_lines_containing_with_include_filters_css(client, live_server, measure_memory_usage, datastore_path):
"""
CSS include_filters narrows the HTML first; extract_lines_containing then filters
within that already-reduced text verifying correct pipeline ordering.
"""
test_return_data = """<html><body>
<div class="weather">
<p>Temperature: 21 celsius</p>
<p>Humidity: 60%</p>
<p>Wind: 15 km/h</p>
</div>
<div class="news">
<p>Local forecast: warm celsius weather ahead</p>
<p>Markets closed early</p>
</div>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(test_return_data)
test_url = url_for('test_endpoint', _external=True)
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)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
# CSS filter: only look inside the weather div
'include_filters': 'div.weather',
# Then keep only celsius lines from that section
'extract_lines_containing': 'celsius',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid), follow_redirects=True)
# Only the celsius line from the weather div should survive both filters
assert b'celsius' in res.data
# Other weather lines excluded by extract_lines_containing
assert b'Humidity' not in res.data
assert b'Wind' not in res.data
# News div content excluded entirely by CSS filter (even though it contains "celsius")
assert b'Markets' not in res.data
assert b'forecast' not in res.data
delete_all_watches(client)
+82
View File
@@ -214,3 +214,85 @@ def test_import_watchete_xlsx(client, live_server, measure_memory_usage, datasto
assert watch.get('fetch_backend') == 'system' # uses default if blank
delete_all_watches(client)
def test_import_wachete_xlsx_row_counter(client, live_server, measure_memory_usage, datastore_path):
"""Row counter in Wachete XLSX import must advance even after a failed row.
Regression: row_id was only incremented in the try/else (on success), so
after any failure the counter froze and all subsequent errors cited the
stale number. With the enumerate() fix, row 5 must say "row 5", not "row 3".
"""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
# Header row (row 1)
ws.append(['Name', 'Id', 'Url', 'Interval (min)', 'XPath', 'Dynamic Wachet', 'Portal Wachet', 'Folder'])
# Row 2: valid
ws.append(['Site A', '001', 'https://example.com/a', 60, None, None, None, None])
# Row 3: bad URL — must report row 3
ws.append(['Site B', '002', 'not-a-valid-url', 60, None, None, None, None])
# Row 4: valid
ws.append(['Site C', '003', 'https://example.com/c', 60, None, None, None, None])
# Row 5: bad URL — must report row 5, not "row 3" (the pre-fix stale value)
ws.append(['Site D', '004', 'also-not-valid', 60, None, None, None, None])
xlsx_bytes = io.BytesIO()
wb.save(xlsx_bytes)
xlsx_bytes.seek(0)
res = client.post(
url_for("imports.import_page"),
data={'file_mapping': 'wachete', 'xlsx_file': (xlsx_bytes, 'test.xlsx')},
follow_redirects=True,
)
assert b'2 imported from Wachete .xlsx' in res.data
assert b'Error processing row number 3' in res.data
assert b'Error processing row number 5' in res.data
delete_all_watches(client)
def test_import_custom_xlsx_row_counter(client, live_server, measure_memory_usage, datastore_path):
"""Row counter in custom XLSX import must reflect the actual row, not always row 1.
Regression: row_i was incremented in the else clause of the *outer* try/except
(which only fired once, after the whole loop), so every URL-validation error
inside the loop reported "row 1". With enumerate() the third row must say
"row 3", not "row 1".
"""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
# Row 1: bad URL — must report row 1
ws.append(['not-valid-url-row1'])
# Row 2: valid
ws.append(['https://example.com/b'])
# Row 3: bad URL — must report row 3, not "row 1" (the pre-fix value)
ws.append(['not-valid-url-row3'])
# Row 4: valid
ws.append(['https://example.com/d'])
xlsx_bytes = io.BytesIO()
wb.save(xlsx_bytes)
xlsx_bytes.seek(0)
res = client.post(
url_for("imports.import_page"),
data={
'file_mapping': 'custom',
'custom_xlsx[col_0]': '1',
'custom_xlsx[col_type_0]': 'url',
'xlsx_file': (xlsx_bytes, 'test.xlsx'),
},
follow_redirects=True,
)
assert b'2 imported from custom .xlsx' in res.data
assert b'Error processing row number 1' in res.data
assert b'Error processing row number 3' in res.data
delete_all_watches(client)
@@ -16,6 +16,51 @@ except ModuleNotFoundError:
def test_jsonp_treated_as_plaintext():
from ..processors.magic import guess_stream_type
# JSONP content (server wrongly claims application/json) should be detected as plaintext
# Callback names are arbitrary identifiers, not always 'cb'
jsonp_content = 'jQuery123456({ "version": "8.0.41", "url": "https://example.com/app.apk" })'
result = guess_stream_type(http_content_header="application/json", content=jsonp_content)
assert result.is_json is False
assert result.is_plaintext is True
# Variation with dotted callback name e.g. jQuery.cb(...)
jsonp_dotted = 'some.callback({ "version": "1.0" })'
result = guess_stream_type(http_content_header="application/json", content=jsonp_dotted)
assert result.is_json is False
assert result.is_plaintext is True
# Real JSON should still be detected as JSON
json_content = '{ "version": "8.0.41", "url": "https://example.com/app.apk" }'
result = guess_stream_type(http_content_header="application/json", content=json_content)
assert result.is_json is True
assert result.is_plaintext is False
def test_jsonp_json_filter_extraction():
from .. import html_tools
# Tough case: dotted namespace callback, trailing semicolon, deeply nested content with arrays
jsonp_content = 'weixin.update.callback({"platforms": {"android": {"variants": [{"arch": "arm64", "versionName": "8.0.68", "url": "https://example.com/app-arm64.apk"}, {"arch": "arm32", "versionName": "8.0.41", "url": "https://example.com/app-arm32.apk"}]}}});'
# Deep nested jsonpath filter into array element
text = html_tools.extract_json_as_string(jsonp_content, "json:$.platforms.android.variants[0].versionName")
assert text == '"8.0.68"'
# Filter that selects the second array element
text = html_tools.extract_json_as_string(jsonp_content, "json:$.platforms.android.variants[1].arch")
assert text == '"arm32"'
if jq_support:
text = html_tools.extract_json_as_string(jsonp_content, "jq:.platforms.android.variants[0].versionName")
assert text == '"8.0.68"'
text = html_tools.extract_json_as_string(jsonp_content, "jqraw:.platforms.android.variants[1].url")
assert text == "https://example.com/app-arm32.apk"
def test_unittest_inline_html_extract():
# So lets pretend that the JSON we want is inside some HTML
content="""
@@ -350,6 +350,7 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
res = client.get(url_for("settings.settings_page"))
assert b'{{restock.original_price}}' in res.data
assert b'{{restock.previous_price}}' in res.data
assert b'Original price at first check' in res.data
#####################
@@ -358,7 +359,7 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "title new price {{restock.price}}",
"application-notification_body": "new price {{restock.price}}",
"application-notification_body": "new price {{restock.price}} previous price {{restock.previous_price}} instock {{restock.in_stock}}",
"application-notification_format": default_notification_format,
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
@@ -372,8 +373,6 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
assert b"Settings updated." in res.data
set_original_response(props_markup=instock_props[0], price='960.45', datastore_path=datastore_path)
# A change in price, should trigger a change by default
set_original_response(props_markup=instock_props[0], price='1950.45', datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"))
@@ -384,6 +383,7 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
notification = f.read()
assert "new price 1950.45" in notification
assert "title new price 1950.45" in notification
assert "previous price 960.45" in notification
## Now test the "SEND TEST NOTIFICATION" is working
os.unlink(os.path.join(datastore_path, "notification.txt"))
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Integration tests for auto-applying tags to watches by URL pattern matching.
Verifies:
- A tag with url_match_pattern shows on the watch overview list (via get_all_tags_for_watch)
- The auto-applied tag appears on the watch edit page
- A watch whose URL does NOT match the pattern does not get the tag
"""
import json
from flask import url_for
from .util import set_original_response, live_server_setup
def test_tag_url_pattern_shows_in_overview(client, live_server, measure_memory_usage, datastore_path):
"""Tag with a matching url_match_pattern must appear in the watch overview row."""
set_original_response(datastore_path=datastore_path)
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Create a tag with a URL match pattern
res = client.post(
url_for("tag"),
data=json.dumps({"title": "Auto GitHub", "url_match_pattern": "*github.com*"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
tag_uuid = res.json['uuid']
# Add a watch that matches the pattern
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "https://github.com/someuser/repo"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
matching_watch_uuid = res.json['uuid']
# Add a watch that does NOT match
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "https://example.com/page"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
non_matching_watch_uuid = res.json['uuid']
# Watch overview — the tag label must appear in the matching watch's row
res = client.get(url_for("watchlist.index"))
assert res.status_code == 200
html = res.get_data(as_text=True)
# The tag title should appear somewhere on the page (it's rendered per-watch via get_all_tags_for_watch)
assert "Auto GitHub" in html, "Auto-matched tag title must appear in watch overview"
# Verify via the datastore directly that get_all_tags_for_watch returns the pattern-matched tag
datastore = live_server.app.config['DATASTORE']
matching_tags = datastore.get_all_tags_for_watch(matching_watch_uuid)
assert tag_uuid in matching_tags, "Pattern-matched tag must be returned for matching watch"
non_matching_tags = datastore.get_all_tags_for_watch(non_matching_watch_uuid)
assert tag_uuid not in non_matching_tags, "Pattern-matched tag must NOT appear for non-matching watch"
def test_auto_applied_tag_shows_on_watch_edit(client, live_server, measure_memory_usage, datastore_path):
"""The watch edit page must show auto-applied tags (from URL pattern) separately."""
set_original_response(datastore_path=datastore_path)
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
res = client.post(
url_for("tag"),
data=json.dumps({"title": "Auto Docs", "url_match_pattern": "*docs.example.com*"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "https://docs.example.com/guide"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
watch_uuid = res.json['uuid']
# Watch edit page must mention the auto-applied tag
res = client.get(url_for("ui.ui_edit.edit_page", uuid=watch_uuid))
assert res.status_code == 200
html = res.get_data(as_text=True)
assert "Auto Docs" in html, "Auto-applied tag name must appear on watch edit page"
assert "automatically applied" in html.lower() or "auto" in html.lower(), \
"Watch edit page must indicate the tag is auto-applied by pattern"
def test_multiple_pattern_tags_all_applied(client, live_server, measure_memory_usage, datastore_path):
"""A watch matching multiple tag patterns must receive all of them, not just the first."""
set_original_response(datastore_path=datastore_path)
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Two tags with different patterns that both match the same URL
res = client.post(
url_for("tag"),
data=json.dumps({"title": "Org Docs", "url_match_pattern": "*docs.*"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
tag_docs_uuid = res.json['uuid']
res = client.post(
url_for("tag"),
data=json.dumps({"title": "Org Python", "url_match_pattern": "*python*"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
tag_python_uuid = res.json['uuid']
# A third tag whose pattern does NOT match
res = client.post(
url_for("tag"),
data=json.dumps({"title": "Org Rust", "url_match_pattern": "*rust-lang*"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
tag_rust_uuid = res.json['uuid']
# Watch URL matches both "docs" and "python" patterns but not "rust"
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "https://docs.python.org/3/library/fnmatch.html"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201, res.data
watch_uuid = res.json['uuid']
datastore = live_server.app.config['DATASTORE']
resolved = datastore.get_all_tags_for_watch(watch_uuid)
assert tag_docs_uuid in resolved, "First matching tag must be included"
assert tag_python_uuid in resolved, "Second matching tag must be included"
assert tag_rust_uuid not in resolved, "Non-matching tag must NOT be included"
@@ -610,6 +610,11 @@ def test_xpath_blocked_functions_unit():
"unparsed-text-available('file:///etc/passwd')",
"doc('file:///etc/passwd')",
"doc-available('file:///etc/passwd')",
"json-doc('file:///datastore/changedetection.json')",
"collection('file:///datastore/')",
"uri-collection('file:///datastore/')",
"transform(map{})",
"load-xquery-module('foo')",
"environment-variable('PATH')",
"available-environment-variables()",
]
@@ -0,0 +1,85 @@
"""
Static analysis test: verify @login_optionally_required is always applied
AFTER (inner to) @blueprint.route(), not before it.
In Flask, @route() must be the outermost decorator because it registers
whatever function it receives. If @login_optionally_required is placed
above @route(), the raw unprotected function gets registered and auth is
silently bypassed (GHSA-jmrh-xmgh-x9j4).
Correct order (route outermost, auth inner):
@blueprint.route('/path')
@login_optionally_required
def view(): ...
Wrong order (auth never called):
@login_optionally_required registered by route, then discarded
@blueprint.route('/path')
def view(): ...
"""
import ast
import pathlib
import pytest
REPO_ROOT = pathlib.Path(__file__).parents[3] # …/changedetection.io/
SOURCE_ROOT = REPO_ROOT / "changedetectionio"
def _is_route_decorator(node: ast.expr) -> bool:
"""Return True if the decorator looks like @something.route(...)."""
return (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "route"
)
def _is_auth_decorator(node: ast.expr) -> bool:
"""Return True if the decorator is @login_optionally_required."""
return isinstance(node, ast.Name) and node.id == "login_optionally_required"
def collect_violations() -> list[str]:
violations = []
for path in SOURCE_ROOT.rglob("*.py"):
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
decorators = node.decorator_list
auth_indices = [i for i, d in enumerate(decorators) if _is_auth_decorator(d)]
route_indices = [i for i, d in enumerate(decorators) if _is_route_decorator(d)]
# Bad order: auth decorator appears at a lower index (higher up) than a route decorator
for auth_idx in auth_indices:
for route_idx in route_indices:
if auth_idx < route_idx:
rel = path.relative_to(REPO_ROOT)
violations.append(
f"{rel}:{node.lineno} — `{node.name}`: "
f"@login_optionally_required (line {decorators[auth_idx].lineno}) "
f"is above @route (line {decorators[route_idx].lineno}); "
f"auth wrapper will never be called"
)
return violations
def test_auth_decorator_order():
violations = collect_violations()
if violations:
msg = (
"\n\nFound routes where @login_optionally_required is placed ABOVE @blueprint.route().\n"
"This silently disables authentication — @route() registers the raw function\n"
"and the auth wrapper is never called.\n\n"
"Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n\n"
+ "\n".join(f"{v}" for v in violations)
)
pytest.fail(msg)
@@ -64,7 +64,7 @@ class TestTriggerConditions(unittest.TestCase):
"conditions": [
{"operator": ">=", "field": "extracted_number", "value": "10"},
{"operator": "<=", "field": "extracted_number", "value": "5000"},
{"operator": "in", "field": "page_text", "value": "rock"},
{"operator": "in", "field": "page_filtered_text", "value": "rock"},
#{"operator": "starts_with", "field": "page_text", "value": "I saw"},
]
}
@@ -15,7 +15,9 @@ from changedetectionio.diff import (
CHANGED_PLACEMARKER_OPEN,
CHANGED_PLACEMARKER_CLOSED,
CHANGED_INTO_PLACEMARKER_OPEN,
CHANGED_INTO_PLACEMARKER_CLOSED
CHANGED_INTO_PLACEMARKER_CLOSED,
extract_changed_from,
extract_changed_to,
)
@@ -381,5 +383,140 @@ Line 3 with tabs and spaces"""
self.assertNotIn('[-Line 2-]', output)
self.assertNotIn('[+Line 2+]', output)
def test_diff_changed_from_to_word_level(self):
"""Primary use case: extract just the old/new value from a changed line (e.g. price monitoring)"""
before = "Widget costs $99.99 per month"
after = "Widget costs $109.99 per month"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99.99")
self.assertEqual(extract_changed_to(raw), "$109.99")
def test_diff_changed_from_to_multiple_changes(self):
"""Multiple changed fragments on different lines are joined with newline.
An unchanged line between the two changes ensures each is a 1-to-1 replace,
so word_diff fires per line rather than falling back to multi-line block mode."""
before = "Price $99\nunchanged\nTax $5"
after = "Price $149\nunchanged\nTax $12"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99\n$5")
self.assertEqual(extract_changed_to(raw), "$149\n$12")
def test_diff_changed_from_to_pure_insert_delete(self):
"""Pure line additions/deletions (no inline word diff) are also captured"""
before = "old line"
after = "new line"
# word_diff=False forces line-level CHANGED markers
raw = diff.render_diff(before, after, word_diff=False)
self.assertEqual(extract_changed_from(raw), "old line")
self.assertEqual(extract_changed_to(raw), "new line")
def test_diff_changed_from_to_similar_numbers(self):
"""$90.00 → $9.00 must not produce a partial match like '0.00'.
The tokenizer splits on whitespace only, so '$90.00' and '$9.00' are
each a single atomic token diff never sees their internal characters."""
before = "for sale $90.00"
after = "for sale $9.00"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$90.00")
self.assertEqual(extract_changed_to(raw), "$9.00")
def test_diff_changed_from_to_whole_line_replaced(self):
"""When every token on the line changed (no common tokens), render_inline_word_diff
takes the whole_line_replaced path using CHANGED/CHANGED_INTO markers instead of
REMOVED/ADDED. Extraction must still work via the alternation in the regex."""
before = "$99"
after = "$109"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99")
self.assertEqual(extract_changed_to(raw), "$109")
def test_diff_changed_from_to_multiple_words_same_line(self):
"""When multiple words change on the same line all fragments are joined with newline.
'quick brown fox jumps' -> 'slow brown fox hops' gives 'quick\njumps' / 'slow\nhops'.
These tokens work best when a single value changes per line."""
before = "quick brown fox jumps"
after = "slow brown fox hops"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "quick\njumps")
self.assertEqual(extract_changed_to(raw), "slow\nhops")
def test_diff_changed_from_to_no_change(self):
"""No changes → empty string"""
content = "nothing changed here"
raw = diff.render_diff(content, content, word_diff=True)
self.assertEqual(extract_changed_from(raw), "")
self.assertEqual(extract_changed_to(raw), "")
def test_word_diff_no_prefix_whole_line_replaced(self):
"""When include_change_type_prefix=False, word-level diffs for whole-line
replacements must not include placemarkers (issue #3816)."""
before = "73"
after = "100"
raw = diff.render_diff(before, after, word_diff=True, include_change_type_prefix=False)
self.assertNotIn('PLACEMARKER', raw)
# Should contain just the raw values separated by newline
self.assertIn('73', raw)
self.assertIn('100', raw)
def test_word_diff_no_prefix_inline_changes(self):
"""When include_change_type_prefix=False, inline word-level diffs
must not include placemarkers (issue #3816)."""
before = "the price is 50 dollars"
after = "the price is 75 dollars"
raw = diff.render_diff(before, after, word_diff=True, include_change_type_prefix=False)
self.assertNotIn('PLACEMARKER', raw)
self.assertIn('50', raw)
self.assertIn('75', raw)
def test_word_diff_with_prefix_still_wraps(self):
"""Default include_change_type_prefix=True must still wrap tokens."""
before = "73"
after = "100"
raw = diff.render_diff(before, after, word_diff=True, include_change_type_prefix=True)
self.assertIn('PLACEMARKER', raw)
def test_word_diff_no_prefix_exact_output(self):
"""Pin exact output for include_change_type_prefix=False to catch regressions.
Whole-line replacement: old and new values separated by newline, no markers.
Inline partial replacement: equal tokens kept, changed tokens (both old and new)
appended without markers this means old+new are concatenated in place.
"""
# Whole-line replaced: both values on separate lines, clean
raw = diff.render_diff('73', '100', word_diff=True, include_change_type_prefix=False)
self.assertEqual(raw, '73\n100')
# Inline word replacement: equal context preserved, old+new token concatenated in-place
raw = diff.render_diff('the price is 50 dollars', 'the price is 75 dollars',
word_diff=True, include_change_type_prefix=False)
self.assertEqual(raw, 'the price is 5075 dollars')
# Sanity: with prefix the whole-line case is fully wrapped
raw = diff.render_diff('73', '100', word_diff=True, include_change_type_prefix=True)
self.assertEqual(raw, '@changed_PLACEMARKER_OPEN73@changed_PLACEMARKER_CLOSED\n'
'@changed_into_PLACEMARKER_OPEN100@changed_into_PLACEMARKER_CLOSED')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,68 @@
"""
Unit test for send_step_failure_notification regression.
Before the fix, line 499 called self._check_cascading_vars('notification_format', watch)
which raises AttributeError because _check_cascading_vars is a module-level function,
not a method of NotificationService.
"""
import queue
from unittest.mock import MagicMock
def _make_datastore(watch_uuid, notification_url):
"""Minimal datastore mock that NotificationService and _check_cascading_vars need."""
watch = MagicMock()
watch.get = lambda key, default=None: {
'uuid': watch_uuid,
'url': 'https://example.com',
'notification_urls': [notification_url],
'notification_format': '',
'notification_muted': False,
}.get(key, default)
watch.__getitem__ = lambda self, key: watch.get(key)
datastore = MagicMock()
datastore.data = {
'watching': {watch_uuid: watch},
'settings': {
'application': {
'notification_urls': [],
'notification_format': 'text',
'filter_failure_notification_threshold_attempts': 3,
}
}
}
datastore.get_all_tags_for_watch.return_value = {}
return datastore, watch
def test_send_step_failure_notification_does_not_raise():
"""send_step_failure_notification must not raise AttributeError (wrong self. prefix on module-level function)."""
from changedetectionio.notification_service import NotificationService
watch_uuid = 'test-uuid-1234'
notification_q = queue.Queue()
datastore, _ = _make_datastore(watch_uuid, 'post://localhost/test')
service = NotificationService(datastore=datastore, notification_q=notification_q)
# Before the fix this raised:
# AttributeError: 'NotificationService' object has no attribute '_check_cascading_vars'
service.send_step_failure_notification(watch_uuid=watch_uuid, step_n=0)
def test_send_step_failure_notification_queues_item():
"""A notification object should be placed on the queue when URLs are configured."""
from changedetectionio.notification_service import NotificationService
watch_uuid = 'test-uuid-5678'
notification_q = queue.Queue()
datastore, _ = _make_datastore(watch_uuid, 'post://localhost/test')
service = NotificationService(datastore=datastore, notification_q=notification_q)
service.send_step_failure_notification(watch_uuid=watch_uuid, step_n=1)
assert not notification_q.empty(), "Expected a notification to be queued"
item = notification_q.get_nowait()
assert 'notification_title' in item
assert 'position 2' in item['notification_title']
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# run from dir above changedetectionio/ dir
# python3 -m unittest changedetectionio.tests.unit.test_tag_url_match
import unittest
from changedetectionio.model.Tag import model as TagModel
def make_tag(pattern):
"""Minimal Tag instance for testing matches_url — skips datastore wiring."""
tag = TagModel.__new__(TagModel)
dict.__init__(tag)
tag['url_match_pattern'] = pattern
return tag
class TestTagUrlMatch(unittest.TestCase):
def test_wildcard_matches(self):
tag = make_tag('*example.com*')
self.assertTrue(tag.matches_url('https://example.com/page'))
self.assertTrue(tag.matches_url('https://www.example.com/shop/item'))
self.assertFalse(tag.matches_url('https://other.com/page'))
def test_wildcard_case_insensitive(self):
tag = make_tag('*EXAMPLE.COM*')
self.assertTrue(tag.matches_url('https://example.com/page'))
def test_substring_match(self):
tag = make_tag('github.com/myorg')
self.assertTrue(tag.matches_url('https://github.com/myorg/repo'))
self.assertFalse(tag.matches_url('https://github.com/otherorg/repo'))
def test_substring_case_insensitive(self):
tag = make_tag('GitHub.com/MyOrg')
self.assertTrue(tag.matches_url('https://github.com/myorg/repo'))
def test_empty_pattern_never_matches(self):
tag = make_tag('')
self.assertFalse(tag.matches_url('https://example.com'))
def test_empty_url_never_matches(self):
tag = make_tag('*example.com*')
self.assertFalse(tag.matches_url(''))
def test_question_mark_wildcard(self):
tag = make_tag('https://example.com/item-?')
self.assertTrue(tag.matches_url('https://example.com/item-1'))
self.assertFalse(tag.matches_url('https://example.com/item-12'))
def test_substring_is_broad(self):
"""Plain substring matching is intentionally broad — 'evil.com' matches anywhere
in the URL string, including 'notevil.com'. Users who need precise domain matching
should use a wildcard pattern like '*://evil.com/*' instead."""
tag = make_tag('evil.com')
self.assertTrue(tag.matches_url('https://evil.com/page'))
self.assertTrue(tag.matches_url('https://notevil.com')) # substring match — expected
def test_precise_domain_match_with_wildcard(self):
"""Use wildcard pattern for precise domain matching to avoid substring surprises."""
tag = make_tag('*://evil.com/*')
self.assertTrue(tag.matches_url('https://evil.com/page'))
self.assertFalse(tag.matches_url('https://notevil.com/page'))
if __name__ == '__main__':
unittest.main()
+2
View File
@@ -76,7 +76,9 @@ These commands read settings from `../../setup.cfg` automatically.
- `en_US` - English (US)
- `fr` - French (Français)
- `it` - Italian (Italiano)
- `ja` - Japanese (日本語)
- `ko` - Korean (한국어)
- `pt_BR` - Portuguese (Brasil)
- `zh` - Chinese Simplified (中文简体)
- `zh_Hant_TW` - Chinese Traditional (繁體中文)
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-14 03:57+0100\n"
"Last-Translator: \n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -130,6 +135,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -204,6 +214,10 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX & Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -561,15 +575,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr "Tipp:"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Verbinden Sie sich über Bright Data und Oxylabs Proxies. Weitere Informationen finden Sie hier."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -763,7 +777,7 @@ msgid "Tip"
msgstr "Tipp"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -833,6 +847,28 @@ msgstr "Aktualisiert"
msgid "Filters & Triggers"
msgstr "Filter und Trigger"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr "Diese Einstellungen sind"
@@ -1031,6 +1067,10 @@ msgstr "Überwachung nicht gefunden"
msgid "Cleared snapshot history for watch {}"
msgstr "Snapshot-Verlauf für Beobachtung {} gelöscht"
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "löschen"
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -1153,10 +1193,6 @@ msgstr "Bestätigungstext"
msgid "Type in the word"
msgstr "Geben Sie das Wort ein"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "löschen"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr "um zu bestätigen, dass Sie es verstanden haben."
@@ -1349,6 +1385,10 @@ msgstr "Hilfe und Beispiele finden Sie hier"
msgid "Organisational tag/group name used in the main listing page"
msgstr "Gruppen-/Label-NameGruppen-/Label-NameOrganisations-Tag/Gruppenname, der auf der Haupteintragsseite verwendet wird"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr ""
@@ -1381,6 +1421,10 @@ msgstr ""
"Die Methode erfordert eine Netzwerkverbindung zu einem laufenden WebDriver+Chrome-Server, der durch die "
"Umgebungsvariable „WEBDRIVER_URL“ festgelegt wird."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Verbinden Sie sich über Bright Data und Oxylabs Proxies. Weitere Informationen finden Sie hier."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr "Überprüfen Sie alles noch einmal"
@@ -1617,7 +1661,7 @@ msgstr "Bereich zeichnen"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Clear selection"
msgstr "Klare Auswahl"
msgstr "Auswahl löschen"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "One moment, fetching screenshot and element information.."
@@ -2229,6 +2273,10 @@ msgstr "CSS/xPath-Filter"
msgid "Remove elements"
msgstr "Elemente entfernen"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "Daten extrahieren"
@@ -2729,6 +2777,11 @@ msgstr "Die Überwachungsgruppe / Tag"
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2737,6 +2790,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2773,6 +2834,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3121,6 +3194,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
@@ -3385,3 +3478,6 @@ msgstr "Haupteinstellungen"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr ""
@@ -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-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-12 16:33+0100\n"
"Last-Translator: British English Translation Team\n"
"Language: en_GB\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -128,6 +133,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -202,6 +212,10 @@ msgstr ""
msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -547,15 +561,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -749,7 +763,7 @@ msgid "Tip"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -819,6 +833,28 @@ msgstr ""
msgid "Filters & Triggers"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr ""
@@ -1011,6 +1047,10 @@ msgstr ""
msgid "Cleared snapshot history for watch {}"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -1131,10 +1171,6 @@ msgstr ""
msgid "Type in the word"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr ""
@@ -1327,6 +1363,10 @@ msgstr ""
msgid "Organisational tag/group name used in the main listing page"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr ""
@@ -1353,6 +1393,10 @@ msgstr ""
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr ""
@@ -2181,6 +2225,10 @@ msgstr ""
msgid "Remove elements"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr ""
@@ -2678,6 +2726,11 @@ msgstr ""
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2686,6 +2739,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2722,6 +2783,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3068,6 +3141,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
@@ -3215,3 +3308,6 @@ msgstr ""
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr ""
@@ -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-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-12 16:37+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en_US\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -128,6 +133,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -202,6 +212,10 @@ msgstr ""
msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -547,15 +561,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -749,7 +763,7 @@ msgid "Tip"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -819,6 +833,28 @@ msgstr ""
msgid "Filters & Triggers"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr ""
@@ -1011,6 +1047,10 @@ msgstr ""
msgid "Cleared snapshot history for watch {}"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -1131,10 +1171,6 @@ msgstr ""
msgid "Type in the word"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr ""
@@ -1327,6 +1363,10 @@ msgstr ""
msgid "Organisational tag/group name used in the main listing page"
msgstr "organizational tag/group name used in the main listing page"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr ""
@@ -1353,6 +1393,10 @@ msgstr ""
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr ""
@@ -2181,6 +2225,10 @@ msgstr ""
msgid "Remove elements"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr ""
@@ -2678,6 +2726,11 @@ msgstr ""
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2686,6 +2739,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2722,6 +2783,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3068,6 +3141,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
@@ -3215,3 +3308,6 @@ msgstr ""
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr ""
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -128,6 +133,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -204,6 +214,10 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX et Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -551,15 +565,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr "Conseil:"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Connectez-vous à l'aide des proxys Bright Data et Oxylabs, découvrez-en plus ici."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -753,7 +767,7 @@ msgid "Tip"
msgstr "Astuce"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -823,6 +837,28 @@ msgstr "Muet"
msgid "Filters & Triggers"
msgstr "Filtres et déclencheurs"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr "PARAMÈTRES"
@@ -1015,6 +1051,10 @@ msgstr "Surveillance non trouvée"
msgid "Cleared snapshot history for watch {}"
msgstr "Historique effacé pour le moniteur {}"
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clair"
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -1135,10 +1175,6 @@ msgstr "Texte de confirmation"
msgid "Type in the word"
msgstr "Tapez le mot"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clair"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr "pour confirmer que vous comprenez."
@@ -1333,6 +1369,10 @@ msgstr ""
"Nom du groupe/étiquetteNom du groupe/étiquetteBalise organisationnelle/nom de groupe utilisé dans la page de liste "
"principale"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr ""
@@ -1359,6 +1399,10 @@ msgstr ""
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Connectez-vous à l'aide des proxys Bright Data et Oxylabs, découvrez-en plus ici."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr "Revérifiez tout"
@@ -2189,6 +2233,10 @@ msgstr "Filtre CSS/JSONPath/JQ/XPath"
msgid "Remove elements"
msgstr "Supprimer par élément"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "Extraire des données"
@@ -2686,6 +2734,11 @@ msgstr "Le groupe / tag du moniteur"
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2694,6 +2747,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2730,6 +2791,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3078,6 +3151,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
@@ -3276,3 +3369,6 @@ msgstr "Paramètres principaux"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr ""
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: it\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -128,6 +133,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -204,6 +214,10 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX & Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -549,15 +563,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -751,7 +765,7 @@ msgid "Tip"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -821,6 +835,28 @@ msgstr "Aggiornato"
msgid "Filters & Triggers"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr ""
@@ -1013,6 +1049,10 @@ msgstr "Monitoraggio non trovato"
msgid "Cleared snapshot history for watch {}"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -1133,10 +1173,6 @@ msgstr "Testo di conferma"
msgid "Type in the word"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr ""
@@ -1329,6 +1365,10 @@ msgstr ""
msgid "Organisational tag/group name used in the main listing page"
msgstr "Nome gruppo/etichetta"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr ""
@@ -1355,6 +1395,10 @@ msgstr ""
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr ""
@@ -2183,6 +2227,10 @@ msgstr "Filtri CSS/JSONPath/JQ/XPath"
msgid "Remove elements"
msgstr "Rimuovi elementi"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "Estrai testo"
@@ -2680,6 +2728,11 @@ msgstr "Gruppo / Etichetta"
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2688,6 +2741,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2724,6 +2785,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3070,6 +3143,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
@@ -3250,3 +3343,6 @@ msgstr "Impostazioni principali"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr ""
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ko\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -128,6 +133,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -202,6 +212,10 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX 및 와체테"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -547,15 +561,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr "팁:"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Bright Data 및 Oxylabs 프록시를 사용하여 연결하세요. 여기에서 자세한 내용을 알아보세요."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -749,7 +763,7 @@ msgid "Tip"
msgstr "팁"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -819,6 +833,28 @@ msgstr "업데이트됨"
msgid "Filters & Triggers"
msgstr "필터 및 트리거"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr "설정"
@@ -1011,6 +1047,10 @@ msgstr "모니터를 찾을 수 없음"
msgid "Cleared snapshot history for watch {}"
msgstr "모니터 {} 스냅샷 기록 삭제됨"
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "분명한"
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -1131,10 +1171,6 @@ msgstr "확인 텍스트"
msgid "Type in the word"
msgstr "단어를 입력하세요"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "분명한"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr "당신이 이해했는지 확인하기 위해."
@@ -1327,6 +1363,10 @@ msgstr "여기에 도움말과 예시가 있습니다"
msgid "Organisational tag/group name used in the main listing page"
msgstr "그룹/태그 이름"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr ""
@@ -1353,6 +1393,10 @@ msgstr ""
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Bright Data 및 Oxylabs 프록시를 사용하여 연결하세요. 여기에서 자세한 내용을 알아보세요."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr "모두 다시 확인하세요"
@@ -2181,6 +2225,10 @@ msgstr "CSS/JSONPath/JQ/XPath 필터"
msgid "Remove elements"
msgstr "요소 제거"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "텍스트 추출"
@@ -2678,6 +2726,11 @@ msgstr "모니터 그룹 / 태그"
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2686,6 +2739,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2722,6 +2783,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3068,6 +3141,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
@@ -3371,3 +3464,6 @@ msgstr "기본 설정"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr ""
+141 -12
View File
@@ -6,16 +6,16 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.53.6\n"
"Project-Id-Version: changedetection.io 0.54.8\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\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"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -73,6 +73,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -127,6 +132,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -148,6 +158,7 @@ msgid "Importing 5,000 of the first URLs from your list, the rest can be importe
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from list in {:.2f}s, {} Skipped."
msgstr ""
@@ -160,6 +171,7 @@ msgid "JSON structure looks invalid, was it broken?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
msgstr ""
@@ -168,18 +180,22 @@ msgid "Unable to read export XLSX file, something wrong with the file?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, URL value was incorrect, row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from Wachete .xlsx in {:.2f}s"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from custom .xlsx in {:.2f}s"
msgstr ""
@@ -195,6 +211,10 @@ msgstr ""
msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -302,10 +322,12 @@ msgid "Password protection removed."
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Worker count adjusted: {}"
msgstr ""
@@ -314,6 +336,7 @@ msgid "Dynamic worker adjustment not supported for sync workers"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Error adjusting workers: {}"
msgstr ""
@@ -537,15 +560,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -739,7 +762,7 @@ msgid "Tip"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -777,6 +800,7 @@ msgid "Clear Snapshot History"
msgstr ""
#: changedetectionio/blueprint/tags/__init__.py
#, python-brace-format
msgid "The tag \"{}\" already exists"
msgstr ""
@@ -808,6 +832,28 @@ msgstr ""
msgid "Filters & Triggers"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr ""
@@ -937,46 +983,57 @@ msgid "RSS Feed for this watch"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches deleted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches paused"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches unpaused"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches updated"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches muted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches un-muted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches queued for rechecking"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches errors cleared"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches cleared/reset."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches set to use default notification settings"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches were tagged"
msgstr ""
@@ -985,9 +1042,14 @@ msgid "Watch not found"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Cleared snapshot history for watch {}"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -997,6 +1059,7 @@ msgid "Incorrect confirmation text."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
msgstr ""
@@ -1017,10 +1080,12 @@ msgid "Queued 1 watch for rechecking."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Queued {} watches for rechecking ({} already queued or running)."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Queued {} watches for rechecking."
msgstr ""
@@ -1029,6 +1094,7 @@ msgid "Queueing watches for rechecking in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Could not share, something went wrong while communicating with the share server - {}"
msgstr ""
@@ -1049,18 +1115,22 @@ msgid "No watches to edit"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "No watch with the UUID {} found."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Switched to mode - {}."
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 ""
@@ -1100,10 +1170,6 @@ msgstr ""
msgid "Type in the word"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr ""
@@ -1296,6 +1362,10 @@ msgstr ""
msgid "Organisational tag/group name used in the main listing page"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr ""
@@ -1322,6 +1392,10 @@ msgstr ""
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr ""
@@ -1645,6 +1719,7 @@ msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc
msgstr ""
#: changedetectionio/blueprint/ui/views.py
#, python-brace-format
msgid "Warning, URL {} already exists"
msgstr ""
@@ -1657,6 +1732,7 @@ msgid "Watch added."
msgstr ""
#: changedetectionio/blueprint/watchlist/__init__.py
#, python-brace-format
msgid "displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>"
msgstr ""
@@ -2148,6 +2224,10 @@ msgstr ""
msgid "Remove elements"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr ""
@@ -2457,10 +2537,12 @@ msgid "Not enough history to compare. Need at least 2 snapshots."
msgstr ""
#: changedetectionio/processors/image_ssim_diff/difference.py
#, python-brace-format
msgid "Failed to load screenshots: {}"
msgstr ""
#: changedetectionio/processors/image_ssim_diff/difference.py
#, python-brace-format
msgid "Failed to calculate diff: {}"
msgstr ""
@@ -2586,6 +2668,7 @@ msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
@@ -2594,6 +2677,7 @@ 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 ""
@@ -2641,6 +2725,11 @@ msgstr ""
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2649,6 +2738,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2685,6 +2782,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3031,6 +3140,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,9 +4,9 @@
#
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io\n"
"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-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-02-19 12:30+0100\n"
"Last-Translator: \n"
"Language: uk\n"
@@ -15,7 +15,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -33,34 +33,126 @@ msgstr "Резервна копія створюється у фоновому
msgid "Backups were deleted."
msgstr "Резервні копії було видалено."
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Резервні копії"
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "A backup is running!"
msgstr "Виконується резервне копіювання!"
#: changedetectionio/blueprint/backups/templates/overview.html
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr "Тут ви можете завантажити або створити нову резервну копію. Коли створення завершиться, вона з'явиться у списку нижче."
#: changedetectionio/blueprint/backups/templates/overview.html
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "Mb"
msgstr "Мб"
#: changedetectionio/blueprint/backups/templates/overview.html
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "No backups found."
msgstr "Резервних копій не знайдено."
#: changedetectionio/blueprint/backups/templates/overview.html
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "Create backup"
msgstr "Створити резервну копію"
#: changedetectionio/blueprint/backups/templates/overview.html
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "Remove backups"
msgstr "Видалити резервні копії"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr "Імпортуються перші 5000 URL з вашого списку, решту можна імпортувати повторно."
@@ -119,6 +211,18 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX та Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr "Введіть по одному URL у рядок, опціонально додайте теги для кожного URL через пробіл, розділяючи їх комами (,):"
@@ -203,6 +307,16 @@ msgstr "Час перевірки (хвилини)"
msgid "Import"
msgstr "Імпорт"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr "Захист паролем вимкнено."
@@ -288,6 +402,10 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Резервні копії"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "Час і Дата"
@@ -304,10 +422,6 @@ msgstr "Інфо"
msgid "Default recheck time for all watches, current system minimum is"
msgstr "Час перевірки за замовчуванням для всіх завдань (поточний системний мінімум:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "секунд)"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr "детальніше"
@@ -396,8 +510,7 @@ msgstr "Chrome/Javascript"
msgid ""
"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time"
" here."
msgstr ""
"Якщо сторінка не встигає повністю відобразитися (відсутній текст тощо), спробуйте збільшити час очікування."
msgstr "Якщо сторінка не встигає повністю відобразитися (відсутній текст тощо), спробуйте збільшити час очікування."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "This will wait"
@@ -447,15 +560,15 @@ msgstr "Примітка: Проста зміна User-Agent часто не д
msgid "all of the ways that the browser is detected"
msgstr "усі способи виявлення браузера"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr "Порада:"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Підключення через проксі Bright Data та Oxylabs, дізнайтеся більше тут."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr "Ігнорувати пробіли, табуляцію та переноси рядків під час виявлення змін."
@@ -506,7 +619,9 @@ msgstr "у текстовому знімку (ви будете його бач
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr "Кожен рядок обробляється окремо; будь-який рядок, що збігається, буде проігноровано (видалено перед створенням контрольної суми)"
msgstr ""
"Кожен рядок обробляється окремо; будь-який рядок, що збігається, буде проігноровано (видалено перед створенням "
"контрольної суми)"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Regular Expression support, wrap the entire line in forward slash"
@@ -518,7 +633,9 @@ msgstr "Зміна цього параметра вплине на контро
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove any text that appears in the \"Ignore text\" from the output (otherwise its just ignored for change-detection)"
msgstr "Видалити будь-який текст, вказаний у «Ігнорувати текст», із виводу (інакше він просто ігнорується під час перевірки змін)"
msgstr ""
"Видалити будь-який текст, вказаний у «Ігнорувати текст», із виводу (інакше він просто ігнорується під час перевірки "
"змін)"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "API Access"
@@ -606,7 +723,9 @@ msgstr "Максимальна кількість знімків історії
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection."
msgstr "Для відстеження інших RSS-каналів — при відстеженні RSS/Atom перетворювати їх на чистий текст для кращого виявлення змін."
msgstr ""
"Для відстеження інших RSS-каналів — при відстеженні RSS/Atom перетворювати їх на чистий текст для кращого виявлення "
"змін."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Does your reader support HTML? Set it here"
@@ -649,8 +768,8 @@ msgid "Tip"
msgstr "Порада"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgstr "Проксі типу «Резидентні» та «Мобільні» можуть бути ефективнішими, ніж «Дата-центр», для заблокованих сайтів."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Name\" will be used for selecting the proxy in the Watch Edit settings"
@@ -664,6 +783,10 @@ msgstr ""
"SOCKS5 проксі з аутентифікацією підтримуються лише завантажувачем 'звичайні запити', для інших завантажувачів "
"необхідно додати IP до білого списку"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Версія Python:"
@@ -717,6 +840,28 @@ msgstr "Оновлено"
msgid "Filters & Triggers"
msgstr "Фільтри та Тригери"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr "Ці налаштування"
@@ -911,6 +1056,10 @@ msgstr "Завдання не знайдено"
msgid "Cleared snapshot history for watch {}"
msgstr "Очищено історію знімків для завдання {}"
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clear"
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr "Очищення історії запущено у фоновому режимі"
@@ -919,10 +1068,6 @@ msgstr "Очищення історії запущено у фоновому р
msgid "Incorrect confirmation text."
msgstr "Невірний текст підтвердження."
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr "Позначення завдань як переглянутих у фоновому режимі..."
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1035,10 +1180,6 @@ msgstr "Текст підтвердження"
msgid "Type in the word"
msgstr "Введіть слово"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clear"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr ", щоб підтвердити розуміння наслідків."
@@ -1231,6 +1372,10 @@ msgstr "довідка та приклади тут"
msgid "Organisational tag/group name used in the main listing page"
msgstr "Ім'я організаційного тегу/групи, що використовується на головній сторінці"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr "Автоматично використовує заголовок сторінки, якщо знайдено. Ви також можете вказати тут свою назву/опис."
@@ -1244,8 +1389,8 @@ msgid ""
"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."
msgstr ""
"Надсилає сповіщення, коли фільтр більше не видно на сторінці. Корисно, щоб дізнатися, що сторінка змінилася і "
"ваш фільтр більше не працює."
"Надсилає сповіщення, коли фільтр більше не видно на сторінці. Корисно, щоб дізнатися, що сторінка змінилася і ваш "
"фільтр більше не працює."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
@@ -1259,6 +1404,10 @@ msgstr "метод (за замовчуванням), якщо сайту не
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr "метод потребує підключення до сервера WebDriver+Chrome, заданого змінною 'WEBDRIVER_URL'."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "Підключення через проксі Bright Data та Oxylabs, дізнайтеся більше тут."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr "Перевірити/Сканувати всі"
@@ -1336,7 +1485,8 @@ msgid ""
"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based "
"fetchers)"
msgstr ""
"Вибачте, ця функція працює лише із завантажувачами, що підтримують інтерактивний Javascript (наразі лише на базі Playwright)"
"Вибачте, ця функція працює лише із завантажувачами, що підтримують інтерактивний Javascript (наразі лише на базі "
"Playwright)"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "to one that supports interactive Javascript."
@@ -1423,8 +1573,8 @@ msgid ""
"Good for websites that just move the content around, and you want to know when NEW content is added, compares new "
"lines against all history for this watch."
msgstr ""
"Корисно для сайтів, які просто переміщують контент, коли ви хочете знати лише про НОВИЙ контент. Порівнює "
"нові рядки з усією історією цього завдання."
"Корисно для сайтів, які просто переміщують контент, коли ви хочете знати лише про НОВИЙ контент. Порівнює нові рядки "
"з усією історією цього завдання."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
@@ -1534,6 +1684,10 @@ msgstr "Відповідь типу сервера"
msgid "Download latest HTML snapshot"
msgstr "Завантажити останній знімок HTML"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr "Видалити завдання?"
@@ -1791,6 +1945,66 @@ msgstr "в '%(title)s'"
msgid "Not yet"
msgstr "Ще ні"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "секунд)"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr "Вже авторизовані"
@@ -2028,6 +2242,10 @@ msgstr "Фільтри CSS/JSONPath/JQ/XPath"
msgid "Remove elements"
msgstr "Видалити елементи"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "Вилучити текст"
@@ -2525,6 +2743,11 @@ msgstr "Група / тег завдання"
msgid "The URL of the preview page generated by changedetection.io."
msgstr "URL сторінки попереднього перегляду, створеної changedetection.io."
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr "URL виводу різниці (diff) для завдання."
@@ -2533,6 +2756,14 @@ msgstr "URL виводу різниці (diff) для завдання."
msgid "The diff output - only changes, additions, and removals"
msgstr "Вивід diff - тільки зміни, додавання та видалення"
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr "Вивід diff - тільки зміни, додавання та видалення —"
@@ -2569,6 +2800,18 @@ msgstr "Вивід diff - повний вивід різниці —"
msgid "The diff output - patch in unified format"
msgstr "Вивід diff - патч у форматі unified"
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr "Текстовий вміст поточного знімка, корисно при використанні JSON або CSS фільтрів"
@@ -2875,7 +3118,9 @@ msgstr "Текст для очікування перед спрацьовува
#: changedetectionio/templates/edit/text-options.html
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr "Тригерний текст обробляється з результуючого тексту, отриманого після застосування CSS/JSON фільтрів для цього завдання"
msgstr ""
"Тригерний текст обробляється з результуючого тексту, отриманого після застосування CSS/JSON фільтрів для цього "
"завдання"
#: changedetectionio/templates/edit/text-options.html
msgid "Each line is processed separately (think of each line as \"OR\")"
@@ -2885,6 +3130,18 @@ msgstr "Кожен рядок обробляється окремо (сприй
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr "Примітка: Обгорніть у скісну риску / для використання regex, приклад:"
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr "Текст, що збігається, буде проігноровано у текстовому знімку (ви його побачите, але він не викличе сповіщення)"
@@ -2894,8 +3151,8 @@ msgid ""
"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for "
"waiting for when a product is available again"
msgstr ""
"Блокувати виявлення змін, поки цей текст є на сторінці. Весь текст і regex без урахування регістру. Корисно "
"для очікування, коли товар знову з'явиться в наявності"
"Блокувати виявлення змін, поки цей текст є на сторінці. Весь текст і regex без урахування регістру. Корисно для "
"очікування, коли товар знову з'явиться в наявності"
#: changedetectionio/templates/edit/text-options.html
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
@@ -2905,6 +3162,26 @@ msgstr "Блокуючий текст обробляється з результ
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr "Усі рядки тут не повинні існувати (кожен рядок як \"АБО\")"
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr "Вилучає текст у фінальний вивід (по-рядково) після інших фільтрів, використовуючи регулярні вирази або збіг рядків:"
@@ -3023,4 +3300,11 @@ msgstr "Ні"
#: changedetectionio/widgets/ternary_boolean.py
msgid "Main settings"
msgstr "Головні налаштування"
msgstr "Головні налаштування"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr "Позначення завдань як переглянутих у фоновому режимі..."
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr "Проксі типу «Резидентні» та «Мобільні» можуть бути ефективнішими, ніж «Дата-центр», для заблокованих сайтів."
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-18 21:31+0800\n"
"Last-Translator: 吾爱分享 <admin@wuaishare.cn>\n"
"Language: zh\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -128,6 +133,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -202,6 +212,10 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX 与 Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -547,15 +561,15 @@ msgstr "注意:仅更换 User-Agent 往往无法绕过反爬虫技术,务必
msgid "all of the ways that the browser is detected"
msgstr "浏览器被识别的各种方式"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr "提示:"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "使用 Bright Data 和 Oxylabs 代理连接,更多信息见此处。"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr "判断是否变更时忽略空格、制表符和换行。"
@@ -749,8 +763,8 @@ msgid "Tip"
msgstr "提示"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgstr "对于被封锁的网站,“住宅”和“移动”代理类型可能比“数据中心”更有效。"
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Name\" will be used for selecting the proxy in the Watch Edit settings"
@@ -819,6 +833,28 @@ msgstr "已更新"
msgid "Filters & Triggers"
msgstr "过滤器与触发器"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr "这些设置会"
@@ -1011,6 +1047,10 @@ msgstr "未找到监控项"
msgid "Cleared snapshot history for watch {}"
msgstr "已清除监控项 {} 的快照历史"
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clear"
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr "历史清理已在后台开始"
@@ -1131,10 +1171,6 @@ msgstr "确认文本"
msgid "Type in the word"
msgstr "请输入单词"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clear"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr "以确认你已理解。"
@@ -1327,6 +1363,10 @@ msgstr "帮助与示例在此"
msgid "Organisational tag/group name used in the main listing page"
msgstr "分组/标签名称"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr "若检测到页面标题将自动使用,你也可以在此自定义标题/描述"
@@ -1353,6 +1393,10 @@ msgstr "方式(默认),适用于无需 JavaScript 渲染的网站。"
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr "方式需要连接正在运行的 WebDriver+Chrome 服务器,通过环境变量 'WEBDRIVER_URL' 设置。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "使用 Bright Data 和 Oxylabs 代理连接,更多信息见此处。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr "检查/扫描全部"
@@ -2181,6 +2225,10 @@ msgstr "CSS/JSONPath/JQ/XPath 过滤器"
msgid "Remove elements"
msgstr "移除元素"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "提取文本"
@@ -2678,6 +2726,11 @@ msgstr "监视器组/标签"
msgid "The URL of the preview page generated by changedetection.io."
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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr "该监控项的差异输出 URL。"
@@ -2686,6 +2739,14 @@ msgstr "该监控项的差异输出 URL。"
msgid "The diff output - only changes, additions, and removals"
msgstr "差异输出 - 仅包含更改、新增与删除"
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr "差异输出 - 仅包含更改、新增与删除 —"
@@ -2722,6 +2783,18 @@ msgstr "差异输出 - 完整差异内容 —"
msgid "The diff output - patch in unified format"
msgstr "差异输出 - 统一格式补丁"
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr "当前快照的文本内容值,与 JSON 或 CSS 过滤器结合使用时很有用"
@@ -3068,6 +3141,26 @@ msgstr "阻止文本来自该监控项的 CSS/JSON 过滤结果"
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr "此处所有行必须不存在(每行视为“或”)"
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr "在其他过滤器之后,按行从最终输出中提取文本(使用正则或字符串匹配):"
@@ -3200,3 +3293,6 @@ msgstr "主设置"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr "正在后台将监控项标记为已读..."
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr "对于被封锁的网站,“住宅”和“移动”代理类型可能比“数据中心”更有效。"
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-15 12:00+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh_Hant_TW\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.18.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -74,6 +74,11 @@ msgstr ""
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
@@ -128,6 +133,11 @@ msgstr ""
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
@@ -202,6 +212,10 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX 和 Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
@@ -547,15 +561,15 @@ msgstr ""
msgid "all of the ways that the browser is detected"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
msgid "Tip:"
msgstr "提示:"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "使用 Bright Data 和 Oxylabs 代理連接,在此處了解更多資訊。"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
@@ -749,7 +763,7 @@ msgid "Tip"
msgstr "提示"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
msgid "\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
@@ -819,6 +833,28 @@ msgstr "已更新"
msgid "Filters & Triggers"
msgstr "過濾器與觸發器"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
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 ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "These settings are"
msgstr "這些設定會"
@@ -1011,6 +1047,10 @@ msgstr "找不到監測任務"
msgid "Cleared snapshot history for watch {}"
msgstr "已清除監測任務 {} 的快照歷史記錄"
#: changedetectionio/blueprint/ui/__init__.py changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clear"
#: changedetectionio/blueprint/ui/__init__.py
msgid "History clearing started in background"
msgstr ""
@@ -1131,10 +1171,6 @@ msgstr "確認文字"
msgid "Type in the word"
msgstr "輸入單字"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "clear"
msgstr "clear"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "to confirm that you understand."
msgstr "以確認您已了解。"
@@ -1327,6 +1363,10 @@ msgstr "幫助與範例請見此處"
msgid "Organisational tag/group name used in the main listing page"
msgstr "群組/標籤名稱"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Also automatically applied by URL pattern:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgstr "如果找到頁面標題將自動使用,您也可以在此使用您自己的標題 / 描述"
@@ -1353,6 +1393,10 @@ msgstr "方法(預設),適用於您監測的網站不需要 Javascript 渲
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgstr "方法需要連線到執行中的 WebDriver + Chrome 伺服器,由環境變數 'WEBDRIVER_URL' 設定。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Connect using Bright Data and Oxylabs Proxies, find out more here."
msgstr "使用 Bright Data 和 Oxylabs 代理連接,在此處了解更多資訊。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Check/Scan all"
msgstr "檢查 / 掃描全部"
@@ -2181,6 +2225,10 @@ msgstr "CSS / JSONPath / JQ / XPath 過濾器"
msgid "Remove elements"
msgstr "移除元素"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "提取文字"
@@ -2678,6 +2726,11 @@ msgstr "群組 / 標籤"
msgid "The URL of the preview page generated by changedetection.io."
msgstr ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The URL of the diff output for the watch."
msgstr ""
@@ -2686,6 +2739,14 @@ msgstr ""
msgid "The diff output - only changes, additions, and removals"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "All diff variants accept"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "args, e.g."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "The diff output - only changes, additions, and removals —"
msgstr ""
@@ -2722,6 +2783,18 @@ msgstr ""
msgid "The diff output - patch in unified format"
msgstr ""
#: changedetectionio/templates/_common_fields.html
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 ""
#: 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 ""
#: changedetectionio/templates/_common_fields.html
msgid "The current snapshot text contents value, useful when combined with JSON or CSS filters"
msgstr ""
@@ -3068,6 +3141,26 @@ msgstr ""
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Keep only lines that contain any of these words or phrases (plain text, case-insensitive)"
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 ""
#: changedetectionio/templates/edit/text-options.html
msgid "Simpler alternative to regex — use this when you just want lines about a specific topic"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Example: enter"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "to keep only lines mentioning temperature readings"
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:"
msgstr ""
@@ -3329,3 +3422,6 @@ msgstr "主設定"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""
#~ msgid "\"Residential\" and \"Mobile\" proxy type can be more successfull than \"Data Center\" for blocked websites."
#~ msgstr ""
+3
View File
@@ -284,6 +284,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.debug(f'[{uuid}] - checksumFromPreviousCheckWasTheSame - Checksum from previous check was the same, nothing todo here.')
# Reset the edited flag since we successfully completed the check
watch.reset_watch_edited_flag()
# Page was fetched successfully - clear any previous error state
datastore.update_watch(uuid=uuid, update_obj={'last_error': False})
cleanup_error_artifacts(uuid, datastore)
except content_fetchers_exceptions.BrowserConnectError as e:
datastore.update_watch(uuid=uuid,
+1 -1
View File
@@ -28,7 +28,7 @@ services:
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
#
#
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well)
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well, Can't handle custom headers etc)
# - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub
#
# WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,
+14
View File
@@ -460,6 +460,13 @@ components:
maxLength: 5000
maxItems: 100
description: Text that should NOT be present (triggers alert if found)
extract_lines_containing:
type: array
items:
type: string
maxLength: 5000
maxItems: 100
description: Keep only lines containing these substrings (plain text, case-insensitive) — simpler alternative to regex
extract_text:
type: array
items:
@@ -725,6 +732,13 @@ components:
- true: Tag settings override watch settings
- false: Tag settings do not override (watches use their own settings)
- null: Not decided yet / inherit default behavior
url_match_pattern:
type: string
description: |
Automatically apply this tag to any watch whose URL matches this pattern.
Supports fnmatch wildcards (* and ?): e.g. *://example.com/* or github.com/myorg.
Plain strings are matched as case-insensitive substrings.
Leave empty to disable auto-matching.
# Future: Aggregated statistics from all watches with this tag
# check_count:
# type: integer

Some files were not shown because too many files have changed in this diff Show More