Showing preview only (4,913K chars total). Download the full file or copy to clipboard to get everything.
Repository: dgtlmoon/changedetection.io
Branch: master
Commit: 5f9fa15a6ad4
Files: 427
Total size: 4.6 MB
Directory structure:
gitextract_1122jxp9/
├── .dockerignore
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── actions/
│ │ └── extract-memory-report/
│ │ └── action.yml
│ ├── dependabot.yml
│ ├── nginx-reverse-proxy-test.conf
│ ├── test/
│ │ └── Dockerfile-alpine
│ └── workflows/
│ ├── codeql-analysis.yml
│ ├── containers.yml
│ ├── pypi-release.yml
│ ├── test-container-build.yml
│ ├── test-only.yml
│ └── test-stack-reusable-workflow.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .ruff.toml
├── COMMERCIAL_LICENCE.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── MANIFEST.in
├── README-pip.md
├── README.md
├── babel.cfg
├── changedetection.py
├── changedetectionio/
│ ├── .gitignore
│ ├── PLUGIN_README.md
│ ├── __init__.py
│ ├── api/
│ │ ├── Import.py
│ │ ├── Notifications.py
│ │ ├── Search.py
│ │ ├── Spec.py
│ │ ├── SystemInfo.py
│ │ ├── Tags.py
│ │ ├── Watch.py
│ │ ├── __init__.py
│ │ └── auth.py
│ ├── auth_decorator.py
│ ├── blueprint/
│ │ ├── __init__.py
│ │ ├── backups/
│ │ │ ├── __init__.py
│ │ │ ├── restore.py
│ │ │ └── templates/
│ │ │ ├── backup_create.html
│ │ │ └── backup_restore.html
│ │ ├── browser_steps/
│ │ │ ├── TODO.txt
│ │ │ └── __init__.py
│ │ ├── check_proxies/
│ │ │ └── __init__.py
│ │ ├── imports/
│ │ │ ├── __init__.py
│ │ │ ├── importer.py
│ │ │ └── templates/
│ │ │ └── import.html
│ │ ├── price_data_follower/
│ │ │ └── __init__.py
│ │ ├── rss/
│ │ │ ├── __init__.py
│ │ │ ├── _util.py
│ │ │ ├── blueprint.py
│ │ │ ├── main_feed.py
│ │ │ ├── single_watch.py
│ │ │ └── tag.py
│ │ ├── settings/
│ │ │ ├── __init__.py
│ │ │ └── templates/
│ │ │ ├── notification-log.html
│ │ │ └── settings.html
│ │ ├── tags/
│ │ │ ├── README.md
│ │ │ ├── __init__.py
│ │ │ ├── form.py
│ │ │ └── templates/
│ │ │ ├── edit-tag.html
│ │ │ └── groups-overview.html
│ │ ├── ui/
│ │ │ ├── __init__.py
│ │ │ ├── diff.py
│ │ │ ├── edit.py
│ │ │ ├── notification.py
│ │ │ ├── preview.py
│ │ │ ├── templates/
│ │ │ │ ├── clear_all_history.html
│ │ │ │ ├── diff-offscreen-options.html
│ │ │ │ ├── diff.html
│ │ │ │ ├── edit.html
│ │ │ │ └── preview.html
│ │ │ └── views.py
│ │ └── watchlist/
│ │ ├── __init__.py
│ │ └── templates/
│ │ └── watch-overview.html
│ ├── browser_steps/
│ │ ├── __init__.py
│ │ └── browser_steps.py
│ ├── conditions/
│ │ ├── __init__.py
│ │ ├── blueprint.py
│ │ ├── default_plugin.py
│ │ ├── exceptions.py
│ │ ├── form.py
│ │ ├── pluggy_interface.py
│ │ └── plugins/
│ │ ├── __init__.py
│ │ ├── levenshtein_plugin.py
│ │ └── wordcount_plugin.py
│ ├── content_fetchers/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── exceptions/
│ │ │ └── __init__.py
│ │ ├── playwright.py
│ │ ├── puppeteer.py
│ │ ├── requests.py
│ │ ├── res/
│ │ │ ├── __init__.py
│ │ │ ├── favicon-fetcher.js
│ │ │ ├── lock-elements-sizing.js
│ │ │ ├── stock-not-in-stock.js
│ │ │ ├── unlock-elements-sizing.js
│ │ │ └── xpath_element_scraper.js
│ │ ├── screenshot_handler.py
│ │ └── webdriver_selenium.py
│ ├── custom_queue.py
│ ├── diff/
│ │ ├── __init__.py
│ │ └── tokenizers/
│ │ ├── __init__.py
│ │ ├── natural_text.py
│ │ └── words_and_html.py
│ ├── favicon_utils.py
│ ├── flask_app.py
│ ├── forms.py
│ ├── gc_cleanup.py
│ ├── html_tools.py
│ ├── is_safe_url.py
│ ├── jinja2_custom/
│ │ ├── __init__.py
│ │ ├── extensions/
│ │ │ ├── TimeExtension.py
│ │ │ └── __init__.py
│ │ ├── plugins/
│ │ │ ├── __init__.py
│ │ │ └── regex.py
│ │ └── safe_jinja.py
│ ├── languages.py
│ ├── model/
│ │ ├── App.py
│ │ ├── Tag.py
│ │ ├── Tags.py
│ │ ├── Watch.py
│ │ ├── __init__.py
│ │ ├── persistence.py
│ │ └── schema_utils.py
│ ├── notification/
│ │ ├── __init__.py
│ │ ├── apprise_plugin/
│ │ │ ├── __init__.py
│ │ │ ├── assets.py
│ │ │ ├── custom_handlers.py
│ │ │ └── discord.py
│ │ ├── email_helpers.py
│ │ └── handler.py
│ ├── notification_service.py
│ ├── pluggy_interface.py
│ ├── processors/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── exceptions.py
│ │ ├── extract.py
│ │ ├── image_ssim_diff/
│ │ │ ├── README.md
│ │ │ ├── __init__.py
│ │ │ ├── difference.py
│ │ │ ├── edit_hook.py
│ │ │ ├── forms.py
│ │ │ ├── image_handler/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── isolated_libvips.py
│ │ │ │ ├── isolated_opencv.py
│ │ │ │ └── libvips_handler.py
│ │ │ ├── preview.py
│ │ │ ├── processor.py
│ │ │ ├── templates/
│ │ │ │ └── image_ssim_diff/
│ │ │ │ ├── diff.html
│ │ │ │ └── preview.html
│ │ │ └── util.py
│ │ ├── magic.py
│ │ ├── restock_diff/
│ │ │ ├── __init__.py
│ │ │ ├── api.yaml
│ │ │ ├── forms.py
│ │ │ ├── processor.py
│ │ │ └── pure_python_extractor.py
│ │ ├── templates/
│ │ │ └── extract.html
│ │ └── text_json_diff/
│ │ ├── __init__.py
│ │ ├── difference.py
│ │ └── processor.py
│ ├── pytest.ini
│ ├── queue_handlers.py
│ ├── queuedWatchMetaData.py
│ ├── realtime/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── events.py
│ │ └── socket_server.py
│ ├── rss_tools.py
│ ├── run_basic_tests.sh
│ ├── run_custom_browser_url_tests.sh
│ ├── run_proxy_tests.sh
│ ├── run_socks_proxy_tests.sh
│ ├── static/
│ │ ├── favicons/
│ │ │ ├── browserconfig.xml
│ │ │ └── site.webmanifest
│ │ ├── js/
│ │ │ ├── browser-steps.js
│ │ │ ├── comparison-slider.js
│ │ │ ├── conditions.js
│ │ │ ├── csrf.js
│ │ │ ├── diff-overview.js
│ │ │ ├── diff-render.js
│ │ │ ├── flask-toast-bridge.js
│ │ │ ├── global-settings.js
│ │ │ ├── hamburger-menu.js
│ │ │ ├── language-selector.js
│ │ │ ├── modal.js
│ │ │ ├── notifications.js
│ │ │ ├── plugins.js
│ │ │ ├── preview.js
│ │ │ ├── realtime.js
│ │ │ ├── recheck-proxy.js
│ │ │ ├── scheduler.js
│ │ │ ├── search-modal.js
│ │ │ ├── snippet-to-image.js
│ │ │ ├── stepper.js
│ │ │ ├── tabs.js
│ │ │ ├── toast.js
│ │ │ ├── toggle-theme.js
│ │ │ ├── vis.js
│ │ │ ├── visual-selector.js
│ │ │ ├── watch-overview.js
│ │ │ └── watch-settings.js
│ │ └── styles/
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── diff-image.css
│ │ ├── diff.css
│ │ ├── package.json
│ │ ├── pure-min.css
│ │ ├── scss/
│ │ │ ├── _settings.scss
│ │ │ ├── diff-image.scss
│ │ │ ├── diff.scss
│ │ │ ├── parts/
│ │ │ │ ├── _action_sidebar.scss
│ │ │ │ ├── _arrows.scss
│ │ │ │ ├── _browser-steps.scss
│ │ │ │ ├── _conditions_table.scss
│ │ │ │ ├── _darkmode.scss
│ │ │ │ ├── _diff_image.scss
│ │ │ │ ├── _edit.scss
│ │ │ │ ├── _extra_browsers.scss
│ │ │ │ ├── _extra_proxies.scss
│ │ │ │ ├── _hamburger_menu.scss
│ │ │ │ ├── _language.scss
│ │ │ │ ├── _lister_extra.scss
│ │ │ │ ├── _login_form.scss
│ │ │ │ ├── _love.scss
│ │ │ │ ├── _menu.scss
│ │ │ │ ├── _minitabs.scss
│ │ │ │ ├── _modal.scss
│ │ │ │ ├── _notification_bubble.scss
│ │ │ │ ├── _pagination.scss
│ │ │ │ ├── _preview_text_filter.scss
│ │ │ │ ├── _search_modal.scss
│ │ │ │ ├── _socket.scss
│ │ │ │ ├── _spinners.scss
│ │ │ │ ├── _tabs.scss
│ │ │ │ ├── _toast.scss
│ │ │ │ ├── _variables.scss
│ │ │ │ ├── _visualselector.scss
│ │ │ │ ├── _watch_table-mobile.scss
│ │ │ │ ├── _watch_table.scss
│ │ │ │ └── _widgets.scss
│ │ │ └── styles.scss
│ │ └── styles.css
│ ├── store/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── file_saving_datastore.py
│ │ └── updates.py
│ ├── strtobool.py
│ ├── templates/
│ │ ├── IMPORTANT.md
│ │ ├── _common_fields.html
│ │ ├── _helpers.html
│ │ ├── base.html
│ │ ├── edit/
│ │ │ ├── include_subtract.html
│ │ │ └── text-options.html
│ │ ├── login.html
│ │ └── menu.html
│ ├── test_cli_opts.sh
│ ├── tests/
│ │ ├── __init__.py
│ │ ├── apprise/
│ │ │ ├── test_apprise_asset.py
│ │ │ └── test_apprise_custom_api_call.py
│ │ ├── conftest.py
│ │ ├── custom_browser_url/
│ │ │ ├── __init__.py
│ │ │ └── test_custom_browser_url.py
│ │ ├── fetchers/
│ │ │ ├── __init__.py
│ │ │ ├── conftest.py
│ │ │ ├── test_content.py
│ │ │ └── test_custom_js_before_content.py
│ │ ├── import/
│ │ │ └── spreadsheet.xlsx
│ │ ├── itemprop_test_examples/
│ │ │ ├── README.md
│ │ │ └── a.txt
│ │ ├── plugins/
│ │ │ └── test_processor.py
│ │ ├── proxy_list/
│ │ │ ├── __init__.py
│ │ │ ├── conftest.py
│ │ │ ├── proxies.json-example
│ │ │ ├── squid-auth.conf
│ │ │ ├── squid-passwords.txt
│ │ │ ├── squid.conf
│ │ │ ├── test_multiple_proxy.py
│ │ │ ├── test_noproxy.py
│ │ │ ├── test_proxy.py
│ │ │ ├── test_proxy_noconnect.py
│ │ │ └── test_select_custom_proxy.py
│ │ ├── proxy_socks5/
│ │ │ ├── proxies.json-example
│ │ │ ├── proxies.json-example-noauth
│ │ │ ├── test_socks5_proxy.py
│ │ │ └── test_socks5_proxy_sources.py
│ │ ├── restock/
│ │ │ ├── __init__.py
│ │ │ ├── conftest.py
│ │ │ └── test_restock.py
│ │ ├── smtp/
│ │ │ ├── smtp-test-server.py
│ │ │ └── test_notification_smtp.py
│ │ ├── test_access_control.py
│ │ ├── test_add_replace_remove_filter.py
│ │ ├── test_api.py
│ │ ├── test_api_notification_urls_validation.py
│ │ ├── test_api_notifications.py
│ │ ├── test_api_openapi.py
│ │ ├── test_api_search.py
│ │ ├── test_api_security.py
│ │ ├── test_api_tags.py
│ │ ├── test_auth.py
│ │ ├── test_automatic_follow_ldjson_price.py
│ │ ├── test_backend.py
│ │ ├── test_backup.py
│ │ ├── test_basic_socketio.py
│ │ ├── test_block_while_text_present.py
│ │ ├── test_clone.py
│ │ ├── test_commit_persistence.py
│ │ ├── test_conditions.py
│ │ ├── test_css_selector.py
│ │ ├── test_datastore_isolation.py
│ │ ├── test_element_removal.py
│ │ ├── test_encoding.py
│ │ ├── test_errorhandling.py
│ │ ├── test_extract_csv.py
│ │ ├── test_extract_regex.py
│ │ ├── test_filter_exist_changes.py
│ │ ├── test_filter_failure_notification.py
│ │ ├── test_group.py
│ │ ├── test_history_consistency.py
│ │ ├── test_html_to_text.py
│ │ ├── test_i18n.py
│ │ ├── test_ignore.py
│ │ ├── test_ignore_regex_text.py
│ │ ├── test_ignore_text.py
│ │ ├── test_ignorehyperlinks.py
│ │ ├── test_ignorestatuscode.py
│ │ ├── test_ignorewhitespace.py
│ │ ├── test_import.py
│ │ ├── test_jinja2.py
│ │ ├── test_jsonpath_jq_selector.py
│ │ ├── test_live_preview.py
│ │ ├── test_nonrenderable_pages.py
│ │ ├── test_notification.py
│ │ ├── test_notification_errors.py
│ │ ├── test_obfuscations.py
│ │ ├── test_pdf.py
│ │ ├── test_preview_endpoints.py
│ │ ├── test_queue_handler.py
│ │ ├── test_request.py
│ │ ├── test_restock_itemprop.py
│ │ ├── test_rss.py
│ │ ├── test_rss_group.py
│ │ ├── test_rss_reader_mode.py
│ │ ├── test_rss_single_watch.py
│ │ ├── test_scheduler.py
│ │ ├── test_search.py
│ │ ├── test_security.py
│ │ ├── test_settings_tag_force_reprocess.py
│ │ ├── test_share_watch.py
│ │ ├── test_source.py
│ │ ├── test_trigger.py
│ │ ├── test_trigger_regex.py
│ │ ├── test_trigger_regex_with_filter.py
│ │ ├── test_ui.py
│ │ ├── test_unique_lines.py
│ │ ├── test_watch_edited_flag.py
│ │ ├── test_watch_fields_storage.py
│ │ ├── test_xpath_default_namespace.py
│ │ ├── test_xpath_selector.py
│ │ ├── test_xpath_selector_unit.py
│ │ ├── unit/
│ │ │ ├── __init__.py
│ │ │ ├── test-content/
│ │ │ │ ├── README.md
│ │ │ │ ├── after-2.txt
│ │ │ │ ├── after.txt
│ │ │ │ └── before.txt
│ │ │ ├── test_conditions.py
│ │ │ ├── test_html_to_text.py
│ │ │ ├── test_jinja2_security.py
│ │ │ ├── test_notification_diff.py
│ │ │ ├── test_restock_logic.py
│ │ │ ├── test_scheduler.py
│ │ │ ├── test_semver.py
│ │ │ ├── test_time_extension.py
│ │ │ ├── test_time_handler.py
│ │ │ └── test_watch_model.py
│ │ ├── util.py
│ │ └── visualselector/
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ └── test_fetch_data.py
│ ├── time_handler.py
│ ├── translations/
│ │ ├── README.md
│ │ ├── cs/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── de/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── en_GB/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── en_US/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── es/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── fr/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── it/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── ko/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── messages.pot
│ │ ├── uk/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── zh/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ └── zh_Hant_TW/
│ │ └── LC_MESSAGES/
│ │ ├── messages.mo
│ │ └── messages.po
│ ├── validate_url.py
│ ├── widgets/
│ │ ├── __init__.py
│ │ ├── ternary_boolean.py
│ │ └── test_custom_text.py
│ ├── worker.py
│ └── worker_pool.py
├── docker-compose.yml
├── docker-entrypoint.sh
├── docs/
│ ├── .gitignore
│ ├── README.md
│ ├── api-spec.yaml
│ ├── api_v1/
│ │ └── index.html
│ └── package.json
├── requirements.txt
├── runtime.txt
├── setup.cfg
└── setup.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
# Git
.git/
.gitignore
# GitHub
.github/
# Byte-compiled / optimized / DLL files
**/__pycache__
**/*.py[cod]
# Caches
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Distribution / packaging
build/
dist/
*.egg-info*
# Virtual environment
.env
.venv/
venv/
# IntelliJ IDEA
.idea/
# Visual Studio
.vscode/
# Test and development files
test-datastore/
tests/
*.md
!README.md
# Temporary and log files
*.log
*.tmp
tmp/
temp/
# Training data and large files
train-data/
works-data/
# Container files
Dockerfile*
docker-compose*.yml
.dockerignore
# Development certificates and keys
*.pem
*.key
*.crt
profile_output.prof
# Large binary files that shouldn't be in container
*.pdf
chrome.json
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: dgtlmoon
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a bug report, if you don't follow this template, your report will be DELETED
title: ''
labels: 'triage'
assignees: 'dgtlmoon'
---
**DO NOT USE THIS FORM TO REPORT THAT A PARTICULAR WEBSITE IS NOT SCRAPING/WATCHING AS EXPECTED**
This form is only for direct bugs and feature requests todo directly with the software.
Please report watched websites (full URL and _any_ settings) that do not work with changedetection.io as expected [**IN THE DISCUSSION FORUMS**](https://github.com/dgtlmoon/changedetection.io/discussions) or your report will be deleted
CONSIDER TAKING OUT A SUBSCRIPTION FOR A SMALL PRICE PER MONTH, YOU GET THE BENEFIT OF USING OUR PAID PROXIES AND FURTHERING THE DEVELOPMENT OF CHANGEDETECTION.IO
THANK YOU
**Describe the bug**
A clear and concise description of what the bug is.
**Version**
*Exact version* in the top right area: 0....
**How did you install?**
Docker, Pip, from source directly etc
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
! ALWAYS INCLUDE AN EXAMPLE URL WHERE IT IS POSSIBLE TO RE-CREATE THE ISSUE - USE THE 'SHARE WATCH' FEATURE AND PASTE IN THE SHARE-LINK!
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: '[feature]'
labels: 'enhancement'
assignees: ''
---
**Version and OS**
For example, 0.123 on linux/docker
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe the use-case and give concrete real-world examples**
Attach any HTML/JSON, give links to sites, screenshots etc, we are not mind readers
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/actions/extract-memory-report/action.yml
================================================
name: 'Extract Memory Test Report'
description: 'Extracts and displays memory test report from a container'
inputs:
container-name:
description: 'Name of the container to extract logs from'
required: true
python-version:
description: 'Python version for artifact naming'
required: true
output-dir:
description: 'Directory to store output logs'
required: false
default: 'output-logs'
runs:
using: "composite"
steps:
- name: Create output directory
shell: bash
run: |
mkdir -p ${{ inputs.output-dir }}
- name: Dump container log
shell: bash
run: |
echo "Disabled for now"
# return
# docker logs ${{ inputs.container-name }} > ${{ inputs.output-dir }}/${{ inputs.container-name }}-stdout-${{ inputs.python-version }}.txt 2>&1 || echo "Could not get stdout"
# docker logs ${{ inputs.container-name }} 2> ${{ inputs.output-dir }}/${{ inputs.container-name }}-stderr-${{ inputs.python-version }}.txt || echo "Could not get stderr"
- name: Extract and display memory test report
shell: bash
run: |
echo "Disabled for now"
# echo "Extracting test-memory.log from container..."
# docker cp ${{ inputs.container-name }}:/app/changedetectionio/test-memory.log ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log || echo "test-memory.log not found in container"
#
# echo "=== Top 10 Highest Peak Memory Tests ==="
# if [ -f ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log ]; then
# grep "Peak memory:" ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log | \
# sed 's/.*Peak memory: //' | \
# paste -d'|' - <(grep "Peak memory:" ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log) | \
# sort -t'|' -k1 -nr | \
# cut -d'|' -f2 | \
# head -10
# echo ""
# echo "=== Full Memory Test Report ==="
# cat ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log
# else
# echo "No memory log available"
# fi
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: "weekly"
groups:
all:
patterns:
- "*"
- package-ecosystem: pip
directory: /
schedule:
interval: "weekly"
================================================
FILE: .github/nginx-reverse-proxy-test.conf
================================================
server {
listen 80;
server_name localhost;
# Test basic reverse proxy to changedetection.io
location / {
proxy_pass http://changedet-app:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Test subpath deployment with X-Forwarded-Prefix
location /changedet-sub/ {
proxy_pass http://changedet-app:5000/;
proxy_set_header X-Forwarded-Prefix /changedet-sub;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
================================================
FILE: .github/test/Dockerfile-alpine
================================================
# Taken from https://github.com/linuxserver/docker-changedetection.io/blob/main/Dockerfile
# Test that we can still build on Alpine (musl modified libc https://musl.libc.org/)
# Some packages wont install via pypi because they dont have a wheel available under this architecture.
FROM ghcr.io/linuxserver/baseimage-alpine:3.22
ENV PYTHONUNBUFFERED=1
COPY requirements.txt /requirements.txt
ARG TARGETPLATFORM
RUN \
apk add --update --no-cache --virtual=build-dependencies \
build-base \
cargo \
git \
jpeg-dev \
libc-dev \
libffi-dev \
libxslt-dev \
openssl-dev \
python3-dev \
file \
zip \
zlib-dev && \
apk add --update --no-cache \
libjpeg \
libxslt \
file \
nodejs \
poppler-utils \
python3 \
glib \
libsm \
libxext \
libxrender && \
case "$TARGETPLATFORM" in \
linux/arm/v7|linux/arm/v8) \
echo "INFO: Skipping py3-opencv on $TARGETPLATFORM (using pixelmatch fallback)" \
;; \
*) \
apk add --update --no-cache py3-opencv || echo "WARN: py3-opencv install failed, using pixelmatch fallback" \
;; \
esac && \
echo "**** pip3 install test of changedetection.io ****" && \
python3 -m venv /lsiopy && \
pip install -U pip wheel setuptools && \
pip install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.22/ -r /requirements.txt && \
apk del --purge \
build-dependencies
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
schedule:
- cron: '27 9 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
================================================
FILE: .github/workflows/containers.yml
================================================
name: Build and push containers
on:
# Automatically triggered by a testing workflow passing, but this is only checked when it lands in the `master`/default branch
# workflow_run:
# workflows: ["ChangeDetection.io Test"]
# branches: [master]
# tags: ['0.*']
# types: [completed]
# Or a new tagged release
release:
types: [published, edited]
push:
branches:
- master
jobs:
metadata:
runs-on: ubuntu-latest
steps:
- name: Show metadata
run: |
echo SHA ${{ github.sha }}
echo github.ref: ${{ github.ref }}
echo github_ref: $GITHUB_REF
echo Event name: ${{ github.event_name }}
echo Ref ${{ github.ref }}
echo c: ${{ github.event.workflow_run.conclusion }}
echo r: ${{ github.event.workflow_run }}
echo tname: "${{ github.event.release.tag_name }}"
echo headbranch: -${{ github.event.workflow_run.head_branch }}-
set
build-push-containers:
runs-on: ubuntu-latest
# If the testing workflow has a success, then we build to :latest
# Or if we are in a tagged release scenario.
if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != ''
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: 3.11
- name: Cache pip packages
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Create release metadata
run: |
# COPY'ed by Dockerfile into changedetectionio/ of the image, then read by the server in store.py
echo ${{ github.sha }} > changedetectionio/source.txt
echo ${{ github.ref }} > changedetectionio/tag.txt
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub Container Registry
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
with:
install: true
version: latest
driver-opts: image=moby/buildkit:master
# master branch -> :dev container tag
- name: Docker meta :dev
if: ${{ github.ref == 'refs/heads/master' && github.event_name != 'release' }}
uses: docker/metadata-action@v6
id: meta_dev
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io
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
if: ${{ github.ref == 'refs/heads/master' && github.event_name != 'release' }}
uses: docker/build-push-action@v7
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ steps.meta_dev.outputs.tags }}
labels: ${{ steps.meta_dev.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
cache-from: type=gha
cache-to: type=gha,mode=max
# Looks like this was disabled
# provenance: false
# A new tagged release is required, which builds :tag and :latest
- name: Debug release info
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
run: |
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
id: meta
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io
ghcr.io/dgtlmoon/changedetection.io
tags: |
type=semver,pattern={{version}},value=${{ github.event.release.tag_name }}
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
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
uses: docker/build-push-action@v7
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
cache-from: type=gha
cache-to: type=gha,mode=max
# Looks like this was disabled
# provenance: false
- name: Image digest
run: echo step SHA ${{ steps.vars.outputs.sha_short }} tag ${{steps.vars.outputs.tag}} branch ${{steps.vars.outputs.branch}} digest ${{ steps.docker_build.outputs.digest }}
================================================
FILE: .github/workflows/pypi-release.yml
================================================
name: Publish Python 🐍distribution 📦 to PyPI and TestPyPI
on: push
jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install pypa/build
run: >-
python3 -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v7
with:
name: python-package-distributions
path: dist/
test-pypi-package:
name: Test the built package works basically.
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download all the dists
uses: actions/download-artifact@v8
with:
name: python-package-distributions
path: dist/
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Test that the basic pip built package runs without error
run: |
set -ex
ls -alR
# Install the first wheel found in dist/
WHEEL=$(find dist -type f -name "*.whl" -print -quit)
echo Installing $WHEEL
python3 -m pip install --upgrade pip
python3 -m pip install "$WHEEL"
changedetection.io -d /tmp -p 10000 &
sleep 3
curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null
curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null
# --- API test ---
# This also means that the docs/api-spec.yml was shipped and could be read
test -f /tmp/changedetection.json
API_KEY=$(jq -r '.. | .api_access_token? // empty' /tmp/changedetection.json)
echo Test API KEY is $API_KEY
curl -X POST "http://127.0.0.1:10000/api/v1/watch" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
--show-error --fail \
--retry 6 --retry-delay 1 --retry-connrefused \
-d '{
"url": "https://example.com",
"title": "Example Site Monitor",
"time_between_check": { "hours": 1 }
}'
killall changedetection.io
publish-to-pypi:
name: >-
Publish Python 🐍 distribution 📦 to PyPI
if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
needs:
- test-pypi-package
runs-on: ubuntu-latest
environment:
name: release
url: https://pypi.org/p/changedetection.io
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing
steps:
- name: Download all the dists
uses: actions/download-artifact@v8
with:
name: python-package-distributions
path: dist/
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
================================================
FILE: .github/workflows/test-container-build.yml
================================================
name: ChangeDetection.io Container Build Test
# Triggers the workflow on push or pull request events
# This line doesnt work, even tho it is the documented one
#on: [push, pull_request]
on:
push:
paths:
- requirements.txt
- Dockerfile
- .github/workflows/*
- .github/test/Dockerfile*
pull_request:
paths:
- requirements.txt
- Dockerfile
- .github/workflows/*
- .github/test/Dockerfile*
# Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing
# @todo: some kind of path filter for requirements.txt and Dockerfile
jobs:
builder:
name: Build ${{ matrix.platform }} (${{ matrix.dockerfile == './Dockerfile' && 'main' || 'alpine' }})
runs-on: ubuntu-latest
strategy:
matrix:
include:
# Main Dockerfile platforms
- platform: linux/amd64
dockerfile: ./Dockerfile
- platform: linux/arm64
dockerfile: ./Dockerfile
- platform: linux/arm/v7
dockerfile: ./Dockerfile
- platform: linux/arm/v8
dockerfile: ./Dockerfile
# Alpine Dockerfile platforms (musl via alpine check)
- platform: linux/amd64
dockerfile: ./.github/test/Dockerfile-alpine
- platform: linux/arm64
dockerfile: ./.github/test/Dockerfile-alpine
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: 3.11
- name: Cache pip packages
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
# Just test that the build works, some libraries won't compile on ARM/rPi etc
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
with:
install: true
version: latest
driver-opts: image=moby/buildkit:master
- name: Test that the docker containers can build (${{ matrix.platform }} - ${{ matrix.dockerfile }})
id: docker_build
uses: docker/build-push-action@v7
# https://github.com/docker/build-push-action#customizing
with:
context: ./
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
cache-from: type=gha
cache-to: type=gha,mode=max
================================================
FILE: .github/workflows/test-only.yml
================================================
name: ChangeDetection.io App Test
# Triggers the workflow on push or pull request events
on: [push, pull_request]
jobs:
lint-code:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Lint with Ruff
run: |
pip install ruff
# Check for syntax errors and undefined names
ruff check . --select E9,F63,F7,F82
# Complete check with errors treated as warnings
ruff check . --exit-zero
- name: Validate OpenAPI spec
run: |
pip install openapi-spec-validator
python3 -c "from openapi_spec_validator import validate_spec; import yaml; validate_spec(yaml.safe_load(open('docs/api-spec.yaml')))"
test-application-3-10:
# Only run on push to master (including PR merges)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: lint-code
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.10'
test-application-3-11:
# Always run
needs: lint-code
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.11'
test-application-3-12:
# Only run on push to master (including PR merges)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: lint-code
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.12'
skip-pypuppeteer: true
test-application-3-13:
# Only run on push to master (including PR merges)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: lint-code
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.13'
skip-pypuppeteer: true
test-application-3-14:
#if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: lint-code
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.14'
skip-pypuppeteer: false
================================================
FILE: .github/workflows/test-stack-reusable-workflow.yml
================================================
name: ChangeDetection.io App Test
on:
workflow_call:
inputs:
python-version:
description: 'Python version to use'
required: true
type: string
default: '3.11'
skip-pypuppeteer:
description: 'Skip PyPuppeteer (not supported in 3.11/3.12)'
required: false
type: boolean
default: false
jobs:
# Build the Docker image once and share it with all test jobs
build:
runs-on: ubuntu-latest
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Cache pip packages
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-py${{ env.PYTHON_VERSION }}-
${{ runner.os }}-pip-
- name: Get current date for cache key
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build changedetection.io container for testing under Python ${{ env.PYTHON_VERSION }}
uses: docker/build-push-action@v7
with:
context: ./
file: ./Dockerfile
build-args: |
PYTHON_VERSION=${{ env.PYTHON_VERSION }}
LOGGER_LEVEL=TRACE
tags: test-changedetectionio
load: true
cache-from: type=gha,scope=build-${{ github.ref_name }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt', 'Dockerfile') }}-${{ steps.date.outputs.date }}
cache-to: type=gha,mode=max,scope=build-${{ github.ref_name }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt', 'Dockerfile') }}-${{ steps.date.outputs.date }}
- name: Verify build
run: |
echo "---- Built for Python ${{ env.PYTHON_VERSION }} -----"
docker run test-changedetectionio bash -c 'pip list'
- name: We should be Python ${{ env.PYTHON_VERSION }} ...
run: |
docker run test-changedetectionio bash -c 'python3 --version'
- name: Save Docker image
run: |
docker save test-changedetectionio -o /tmp/test-changedetectionio.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp/test-changedetectionio.tar
retention-days: 1
# Unit tests (lightweight, no ancillary services needed)
unit-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- 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'
# Basic pytest tests with ancillary services
basic-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 25
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Test built container with Pytest
run: |
docker network inspect changedet-network >/dev/null 2>&1 || docker network create changedet-network
docker run --name test-cdio-basic-tests --network changedet-network test-changedetectionio bash -c 'cd changedetectionio && ./run_basic_tests.sh'
- name: Test CLI options
run: |
docker network inspect changedet-network >/dev/null 2>&1 || docker network create changedet-network
docker run --name test-cdio-cli-opts --network changedet-network test-changedetectionio bash -c 'changedetectionio/test_cli_opts.sh' &> cli-opts-output.txt
echo "=== CLI Options Test Output ==="
cat cli-opts-output.txt
- name: CLI Memory Test
run: |
echo "=== Checking CLI batch mode memory usage ==="
# Extract RSS memory value from output
RSS_MB=$(grep -oP "Memory consumption before worker shutdown: RSS=\K[\d.]+" cli-opts-output.txt | head -1 || echo "0")
echo "RSS Memory: ${RSS_MB} MB"
# Check if RSS is less than 100MB
if [ -n "$RSS_MB" ]; then
if (( $(echo "$RSS_MB < 100" | bc -l) )); then
echo "✓ Memory usage is acceptable: ${RSS_MB} MB < 100 MB"
else
echo "✗ Memory usage too high: ${RSS_MB} MB >= 100 MB"
exit 1
fi
else
echo "⚠ Could not extract memory usage, skipping check"
fi
- name: Extract memory report and logs
if: always()
uses: ./.github/actions/extract-memory-report
with:
container-name: test-cdio-basic-tests
python-version: ${{ env.PYTHON_VERSION }}
- name: Store test artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
path: output-logs
- name: Store CLI test output
if: always()
uses: actions/upload-artifact@v7
with:
name: test-cdio-cli-opts-output-py${{ env.PYTHON_VERSION }}
path: cli-opts-output.txt
# Playwright tests
playwright-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Spin up ancillary services
run: |
docker network create changedet-network
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest
- name: Playwright - Specific tests in built container
run: |
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
- name: Playwright - Headers and requests
run: |
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'find .; cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py; pwd;find .'
- name: Playwright - Restock detection
run: |
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
# Pyppeteer tests
pyppeteer-tests:
runs-on: ubuntu-latest
needs: build
if: ${{ inputs.skip-pypuppeteer == false }}
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Spin up ancillary services
run: |
docker network create changedet-network
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest
- name: Pyppeteer - Specific tests in built container
run: |
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
- name: Pyppeteer - Headers and requests checks
run: |
docker run --name "changedet" --hostname changedet --rm -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
- name: Pyppeteer - Restock detection
run: |
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
# Selenium tests
selenium-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Spin up ancillary services
run: |
docker network create changedet-network
docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome:4
sleep 3
- name: Specific tests for headers and requests checks with Selenium
run: |
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
- name: Specific tests in built container for Selenium
run: |
docker run --rm -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py'
# SMTP tests
smtp-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Spin up SMTP test server
run: |
docker network create changedet-network
docker run --network changedet-network -d -p 11025:11025 -p 11080:11080 --hostname mailserver test-changedetectionio bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py'
- name: Test SMTP notification mime types
run: |
docker run --rm --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py'
nginx-reverse-proxy:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Spin up services
run: |
docker network create changedet-network
# Start changedetection.io container with X-Forwarded headers support
docker run --name changedet-app --hostname changedet-app --network changedet-network \
-e USE_X_SETTINGS=true \
-d test-changedetectionio
sleep 3
- name: Start nginx reverse proxy
run: |
# Start nginx with our test configuration
docker run --name nginx-proxy --network changedet-network -d -p 8080:80 --rm \
-v ${{ github.workspace }}/.github/nginx-reverse-proxy-test.conf:/etc/nginx/conf.d/default.conf:ro \
nginx:alpine
sleep 2
- name: Test reverse proxy - root path
run: |
echo "=== Testing nginx reverse proxy at root path ==="
curl --retry-connrefused --retry 6 -s http://localhost:8080/ > /tmp/nginx-test-root.html
# Check for changedetection.io UI elements
if grep -q "checkbox-uuid" /tmp/nginx-test-root.html; then
echo "✓ Found checkbox-uuid in response"
else
echo "ERROR: checkbox-uuid not found in response"
cat /tmp/nginx-test-root.html
exit 1
fi
# Check for watchlist content
if grep -q -i "watch" /tmp/nginx-test-root.html; then
echo "✓ Found watch/watchlist content in response"
else
echo "ERROR: watchlist content not found"
cat /tmp/nginx-test-root.html
exit 1
fi
echo "✓ Root path reverse proxy working correctly"
- name: Test reverse proxy - subpath with X-Forwarded-Prefix
run: |
echo "=== Testing nginx reverse proxy at subpath /changedet-sub/ ==="
curl --retry-connrefused --retry 6 -s http://localhost:8080/changedet-sub/ > /tmp/nginx-test-subpath.html
# Check for changedetection.io UI elements
if grep -q "checkbox-uuid" /tmp/nginx-test-subpath.html; then
echo "✓ Found checkbox-uuid in subpath response"
else
echo "ERROR: checkbox-uuid not found in subpath response"
cat /tmp/nginx-test-subpath.html
exit 1
fi
echo "✓ Subpath reverse proxy working correctly"
- name: Test API through reverse proxy subpath
run: |
echo "=== Testing API endpoints through nginx subpath /changedet-sub/ ==="
# Extract API key from the changedetection.io datastore
API_KEY=$(docker exec changedet-app cat /datastore/changedetection.json | grep -o '"api_access_token": *"[^"]*"' | cut -d'"' -f4)
if [ -z "$API_KEY" ]; then
echo "ERROR: Could not extract API key from datastore"
docker exec changedet-app cat /datastore/changedetection.json
exit 1
fi
echo "✓ Extracted API key: ${API_KEY:0:8}..."
# Create a watch via API through nginx proxy subpath
echo "Creating watch via POST to /changedet-sub/api/v1/watch"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "http://localhost:8080/changedet-sub/api/v1/watch" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/test-nginx-proxy",
"tag": "nginx-test"
}')
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | head -n-1)
if [ "$HTTP_CODE" != "201" ]; then
echo "ERROR: Expected HTTP 201, got $HTTP_CODE"
echo "Response: $BODY"
exit 1
fi
echo "✓ Watch created successfully (HTTP 201)"
# Extract the watch UUID from response
WATCH_UUID=$(echo "$BODY" | grep -o '"uuid": *"[^"]*"' | cut -d'"' -f4)
echo "✓ Watch UUID: $WATCH_UUID"
# Update the watch via PUT through nginx proxy subpath
echo "Updating watch via PUT to /changedet-sub/api/v1/watch/${WATCH_UUID}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT "http://localhost:8080/changedet-sub/api/v1/watch/${WATCH_UUID}" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"paused": true
}')
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | head -n-1)
if [ "$HTTP_CODE" != "200" ]; then
echo "ERROR: Expected HTTP 200, got $HTTP_CODE"
echo "Response: $BODY"
exit 1
fi
if echo "$BODY" | grep -q 'OK'; then
echo "✓ Watch updated successfully (HTTP 200, response: OK)"
else
echo "ERROR: Expected response 'OK', got: $BODY"
echo "Response: $BODY"
exit 1
fi
# Verify the watch is paused via GET
echo "Verifying watch is paused via GET"
RESPONSE=$(curl -s "http://localhost:8080/changedet-sub/api/v1/watch/${WATCH_UUID}" \
-H "x-api-key: ${API_KEY}")
if echo "$RESPONSE" | grep -q '"paused": *true'; then
echo "✓ Watch is paused as expected"
else
echo "ERROR: Watch paused state not confirmed"
echo "Response: $RESPONSE"
exit 1
fi
echo "✓ API tests through nginx subpath completed successfully"
- name: Cleanup nginx test
if: always()
run: |
docker logs nginx-proxy || true
docker logs changedet-app || true
docker stop nginx-proxy changedet-app || true
docker rm nginx-proxy changedet-app || true
# Proxy tests
proxy-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Spin up services
run: |
docker network create changedet-network
docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome:4
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest
- name: Test proxy Squid style interaction
run: |
cd changedetectionio
./run_proxy_tests.sh
docker ps
cd ..
- name: Test proxy SOCKS5 style interaction
run: |
cd changedetectionio
./run_socks_proxy_tests.sh
cd ..
# Custom browser URL tests
custom-browser-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Spin up ancillary services
run: |
docker network create changedet-network
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest
docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest
- name: Test custom browser URL
run: |
cd changedetectionio
./run_custom_browser_url_tests.sh
processor-plugin-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 20
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Basic processor plugin registration and checks
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'
# Container startup tests
container-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Test container starts+runs basically without error
run: |
docker run --name test-changedetectionio -p 5556:5000 -d test-changedetectionio
sleep 3
curl --retry-connrefused --retry 6 -s http://localhost:5556 |grep -q checkbox-uuid
curl --retry-connrefused --retry 6 -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid
docker logs test-changedetectionio 2>/dev/null | grep 'TRACE log is enabled' || exit 1
docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1
docker kill test-changedetectionio
- name: Test HTTPS SSL mode
run: |
openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
docker run --name test-changedetectionio-ssl --rm -e SSL_CERT_FILE=cert.pem -e SSL_PRIVKEY_FILE=privkey.pem -p 5000:5000 -v ./cert.pem:/app/cert.pem -v ./privkey.pem:/app/privkey.pem -d test-changedetectionio
sleep 3
curl --retry-connrefused --retry 6 -k https://localhost:5000 -v|grep -q checkbox-uuid
docker kill test-changedetectionio-ssl
- name: Test IPv6 Mode
run: |
docker run --name test-changedetectionio-ipv6 --rm -p 5000:5000 -e LISTEN_HOST=:: -d test-changedetectionio
sleep 3
curl --retry-connrefused --retry 6 http://[::1]:5000 -v|grep -q checkbox-uuid
docker kill test-changedetectionio-ipv6
# Signal tests
signal-tests:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
- name: Load Docker image
run: |
docker load -i /tmp/test-changedetectionio.tar
- name: Test SIGTERM and SIGINT signal shutdown
run: |
echo SIGINT Shutdown request test
docker run --name sig-test -d test-changedetectionio
sleep 3
echo ">>> Sending SIGINT to sig-test container"
docker kill --signal=SIGINT sig-test
sleep 3
docker ps
docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGINT' || exit 1
test -z "`docker ps|grep sig-test`"
if [ $? -ne 0 ]; then
echo "Looks like container was running when it shouldnt be"
docker ps
exit 1
fi
docker rm sig-test
echo SIGTERM Shutdown request test
docker run --name sig-test -d test-changedetectionio
sleep 3
echo ">>> Sending SIGTERM to sig-test container"
docker kill --signal=SIGTERM sig-test
sleep 3
docker ps
docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGTERM' || exit 1
test -z "`docker ps|grep sig-test`"
if [ $? -ne 0 ]; then
echo "Looks like container was running when it shouldnt be"
docker ps
exit 1
fi
docker rm sig-test
# Upgrade path test
upgrade-path-test:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 25
env:
PYTHON_VERSION: ${{ inputs.python-version }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Fetch all history and tags for upgrade testing
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Check upgrade works without error
run: |
echo "=== Testing upgrade path from 0.49.1 to ${{ github.ref_name }} (${{ github.sha }}) ==="
sudo apt-get update && sudo apt-get install -y --no-install-recommends \
g++ \
gcc \
libc-dev \
libffi-dev \
libjpeg-dev \
libssl-dev \
libxslt-dev \
make \
patch \
pkg-config \
zlib1g-dev
# Checkout old version and create datastore
git checkout 0.49.1
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install 'pyOpenSSL>=23.2.0'
echo "=== Running version 0.49.1 to create datastore ==="
ALLOW_IANA_RESTRICTED_ADDRESSES=true python3 ./changedetection.py -C -d /tmp/data &
APP_PID=$!
# Wait for app to be ready
echo "Waiting for 0.49.1 to be ready..."
sleep 6
# Extract API key from datastore (0.49.1 uses url-watches.json)
API_KEY=$(jq -r '.settings.application.api_access_token // empty' /tmp/data/url-watches.json)
echo "API Key: ${API_KEY:0:8}..."
# Create a watch with tag "github-group-test" via API
echo "Creating test watch with tag via API..."
curl -X POST "http://127.0.0.1:5000/api/v1/watch" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
--show-error --fail \
--retry 6 --retry-delay 1 --retry-connrefused \
-d '{
"url": "https://example.com/upgrade-test",
"tag": "github-group-test"
}'
echo "✓ Created watch with tag 'github-group-test'"
# Create a specific test URL watch
echo "Creating test URL watch via API..."
curl -X POST "http://127.0.0.1:5000/api/v1/watch" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
--show-error --fail \
-d '{
"url": "http://localhost/test.txt"
}'
echo "✓ Created watch for 'http://localhost/test.txt' in version 0.49.1"
# Stop the old version gracefully
kill $APP_PID
wait $APP_PID || true
echo "✓ Version 0.49.1 stopped"
# Upgrade to current version (use commit SHA since we're in detached HEAD)
echo "Upgrading to commit ${{ github.sha }}"
git checkout ${{ github.sha }}
pip install -r requirements.txt
echo "=== Running current version (commit ${{ github.sha }}) with old datastore (testing mode) ==="
ALLOW_IANA_RESTRICTED_ADDRESSES=true TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD=1 python3 ./changedetection.py -d /tmp/data > /tmp/upgrade-test.log 2>&1
echo "=== Upgrade test output ==="
cat /tmp/upgrade-test.log
echo "✓ Datastore upgraded successfully"
# Now start the current version normally to verify the tag survived
echo "=== Starting current version to verify tag exists after upgrade ==="
ALLOW_IANA_RESTRICTED_ADDRESSES=true timeout 20 python3 ./changedetection.py -d /tmp/data > /tmp/ui-test.log 2>&1 &
APP_PID=$!
# Wait for app to be ready and fetch UI
echo "Waiting for current version to be ready..."
sleep 5
curl --retry 6 --retry-delay 1 --retry-connrefused --silent http://127.0.0.1:5000 > /tmp/ui-output.html
# Verify tag exists in UI
if grep -q "github-group-test" /tmp/ui-output.html; then
echo "✓ Tag 'github-group-test' found in UI after upgrade"
else
echo "ERROR: Tag 'github-group-test' not found in UI after upgrade"
echo "=== UI Output ==="
cat /tmp/ui-output.html
echo "=== App Log ==="
cat /tmp/ui-test.log
kill $APP_PID || true
exit 1
fi
# Verify test URL exists in UI
if grep -q "http://localhost/test.txt" /tmp/ui-output.html; then
echo "✓ Watch URL 'http://localhost/test.txt' found in UI after upgrade"
else
echo "ERROR: Watch URL 'http://localhost/test.txt' not found in UI after upgrade"
echo "=== UI Output ==="
cat /tmp/ui-output.html
echo "=== App Log ==="
cat /tmp/ui-test.log
kill $APP_PID || true
exit 1
fi
# Cleanup
kill $APP_PID || true
wait $APP_PID || true
echo ""
echo "✓✓✓ Upgrade test passed: 0.49.1 → ${{ github.ref_name }} ✓✓✓"
echo " - Commit: ${{ github.sha }}"
echo " - Datastore migrated successfully"
echo " - Tag 'github-group-test' survived upgrade"
echo " - Watch URL 'http://localhost/test.txt' survived upgrade"
echo "✓ Upgrade test passed: 0.49.1 → ${{ github.ref_name }}"
- name: Upload upgrade test logs
if: always()
uses: actions/upload-artifact@v7
with:
name: upgrade-test-logs-py${{ env.PYTHON_VERSION }}
path: /tmp/upgrade-test.log
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
**/__pycache__
**/*.py[cod]
# Caches
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Distribution / packaging
build/
dist/
*.egg-info*
# Virtual environment
.env
.venv/
venv/
.python-version
# IDEs
.idea
.vscode/settings.json
*~
# Datastore files
datastore/
test-datastore/
# Memory consumption log
test-memory.log
tests/logs/
================================================
FILE: .pre-commit-config.yaml
================================================
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.2
hooks:
# Lint (and apply safe fixes)
- id: ruff
args: [--fix]
# Fomrat
- id: ruff-format
================================================
FILE: .ruff.toml
================================================
# Minimum supported version
target-version = "py310"
# Formatting options
line-length = 100
indent-width = 4
exclude = [
"__pycache__",
".eggs",
".git",
".tox",
".venv",
"*.egg-info",
"*.pyc",
]
[lint]
# https://docs.astral.sh/ruff/rules/
select = [
"B", # flake8-bugbear
"B9",
"C",
"E", # pycodestyle
"F", # Pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"W", # pycodestyle
]
ignore = [
"B007", # unused-loop-control-variable
"B909", # loop-iterator-mutation
"E203", # whitespace-before-punctuation
"E266", # multiple-leading-hashes-for-block-comment
"E501", # redundant-backslash
"F403", # undefined-local-with-import-star
"N802", # invalid-function-name
"N806", # non-lowercase-variable-in-function
"N815", # mixed-case-variable-in-class-scope
]
[lint.mccabe]
max-complexity = 12
[format]
indent-style = "space"
quote-style = "preserve"
================================================
FILE: COMMERCIAL_LICENCE.md
================================================
# Generally
In any commercial activity involving 'Hosting' (as defined herein), whether in part or in full, this license must be executed and adhered to.
# Commercial License Agreement
This Commercial License Agreement ("Agreement") is entered into by and between Web Technologies s.r.o. here-in ("Licensor") and (your company or personal name) _____________ ("Licensee"). This Agreement sets forth the terms and conditions under which Licensor provides its software ("Software") and services to Licensee for the purpose of reselling the software either in part or full, as part of any commercial activity where the activity involves a third party.
### Definition of Hosting
For the purposes of this Agreement, "hosting" means making the functionality of the Program or modified version available to third parties as a service. This includes, without limitation:
- Enabling third parties to interact with the functionality of the Program or modified version remotely through a computer network.
- Offering a service the value of which entirely or primarily derives from the value of the Program or modified version.
- Offering a service that accomplishes for users the primary purpose of the Program or modified version.
## 1. Grant of License
Subject to the terms and conditions of this Agreement, Licensor grants Licensee a non-exclusive, non-transferable license to install, use, and resell the Software. Licensee may:
- Resell the Software as part of a service offering or as a standalone product.
- Host the Software on a server and provide it as a hosted service (e.g., Software as a Service - SaaS).
- Integrate the Software into a larger product or service that is then sold or provided for commercial purposes, where the software is used either in part or full.
## 2. License Fees
Licensee agrees to pay Licensor the license fees specified in the ordering document. License fees are due and payable as specified in the ordering document. The fees may include initial licensing costs and recurring fees based on the number of end users, instances of the Software resold, or revenue generated from the resale activities.
## 3. Resale Conditions
Licensee must comply with the following conditions when reselling the Software, whether the software is resold in part or full:
- Provide end users with access to the source code under the same open-source license conditions as provided by Licensor.
- Clearly state in all marketing and sales materials that the Software is provided under a commercial license from Licensor, and provide a link back to https://changedetection.io.
- Ensure end users are aware of and agree to the terms of the commercial license prior to resale.
- Do not sublicense or transfer the Software to third parties except as part of an authorized resale activity.
## 4. Hosting and Provision of Services
Licensee may host the Software (either in part or full) on its servers and provide it as a hosted service to end users. The following conditions apply:
- Licensee must ensure that all hosted versions of the Software comply with the terms of this Agreement.
- Licensee must provide Licensor with regular reports detailing the number of end users and instances of the hosted service.
- Any modifications to the Software made by Licensee for hosting purposes must be made available to end users under the same open-source license conditions, unless agreed otherwise.
## 5. Services
Licensor will provide support and maintenance services as described in the support policy referenced in the ordering document should such an agreement be signed by all parties. Additional fees may apply for support services provided to end users resold by Licensee.
## 6. Reporting and Audits
Licensee agrees to provide Licensor with regular reports detailing the number of instances, end users, and revenue generated from the resale of the Software. Licensor reserves the right to audit Licensee’s records to ensure compliance with this Agreement.
## 7. Term and Termination
This Agreement shall commence on the effective date and continue for the period set forth in the ordering document unless terminated earlier in accordance with this Agreement. Either party may terminate this Agreement if the other party breaches any material term and fails to cure such breach within thirty (30) days after receipt of written notice.
## 8. Limitation of Liability and Disclaimer of Warranty
Executing this commercial license does not waive the Limitation of Liability or Disclaimer of Warranty as stated in the open-source LICENSE provided with the Software. The Software is provided "as is," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.
## 9. Governing Law
This Agreement shall be governed by and construed in accordance with the laws of the Czech Republic.
## Contact Information
For commercial licensing inquiries, please contact contact@changedetection.io and dgtlmoon@gmail.com.
================================================
FILE: CONTRIBUTING.md
================================================
Contributing is always welcome!
I am no professional flask developer, if you know a better way that something can be done, please let me know!
Otherwise, it's always best to PR into the `master` branch.
Please be sure that all new functionality has a matching test!
Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notification.py` for example
================================================
FILE: Dockerfile
================================================
# pip dependencies install stage
ARG PYTHON_VERSION=3.11
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
# See `cryptography` pin comment in requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
gcc \
libc-dev \
libffi-dev \
libjpeg-dev \
libssl-dev \
libxslt-dev \
make \
patch \
pkg-config \
zlib1g-dev
RUN mkdir /install
WORKDIR /install
COPY requirements.txt /requirements.txt
# Use cache mounts and multiple wheel sources for faster ARM builds
ENV PIP_CACHE_DIR=/tmp/pip-cache
# Help Rust find OpenSSL for cryptography package compilation on ARM
ENV PKG_CONFIG_PATH="/usr/lib/pkgconfig:/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/lib/aarch64-linux-gnu/pkgconfig"
ENV PKG_CONFIG_ALLOW_SYSTEM_CFLAGS=1
ENV OPENSSL_DIR="/usr"
ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
ENV OPENSSL_INCLUDE_DIR="/usr/include/openssl"
# Additional environment variables for cryptography Rust build
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \
pip install \
--prefer-binary \
--extra-index-url https://www.piwheels.org/simple \
--extra-index-url https://pypi.anaconda.org/ARM-software/simple \
--cache-dir=/tmp/pip-cache \
--target=/dependencies \
-r /requirements.txt
# Playwright is an alternative to Selenium
# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing
# https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported)
RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \
pip install \
--prefer-binary \
--cache-dir=/tmp/pip-cache \
--target=/dependencies \
playwright~=1.56.0 \
|| echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
# OpenCV is optional for fast image comparison (pixelmatch is the fallback)
# Skip on arm/v7 and arm/v8 where builds take weeks - excluded from requirements.txt
ARG TARGETPLATFORM
RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \
case "$TARGETPLATFORM" in \
linux/arm/v7|linux/arm/v8) \
echo "INFO: Skipping OpenCV on $TARGETPLATFORM (build takes too long), using pixelmatch fallback" \
;; \
*) \
pip install \
--prefer-binary \
--extra-index-url https://www.piwheels.org/simple \
--cache-dir=/tmp/pip-cache \
--target=/dependencies \
opencv-python-headless>=4.8.0.76 \
|| echo "WARN: OpenCV install failed, will use pixelmatch fallback" \
;; \
esac
# Final image stage
FROM python:${PYTHON_VERSION}-slim-bookworm
LABEL org.opencontainers.image.source="https://github.com/dgtlmoon/changedetection.io"
LABEL org.opencontainers.image.url="https://changedetection.io"
LABEL org.opencontainers.image.documentation="https://changedetection.io/tutorials"
LABEL org.opencontainers.image.title="changedetection.io"
LABEL org.opencontainers.image.description="Self-hosted web page change monitoring and notification service"
LABEL org.opencontainers.image.licenses="Apache-2.0"
LABEL org.opencontainers.image.vendor="changedetection.io"
RUN apt-get update && apt-get install -y --no-install-recommends \
libxslt1.1 \
# For presenting price amounts correctly in the restock/price detection overview
locales \
# For pdftohtml
poppler-utils \
# favicon type detection and other uses
file \
zlib1g \
# OpenCV dependencies for image processing
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops
ENV PYTHONUNBUFFERED=1
RUN [ ! -d "/datastore" ] && mkdir /datastore
# Re #80, sets SECLEVEL=1 in openssl.conf to allow monitoring sites with weak/old cipher suites
RUN sed -i 's/^CipherString = .*/CipherString = DEFAULT@SECLEVEL=1/' /etc/ssl/openssl.cnf
# Copy modules over to the final image and add their dir to PYTHONPATH
COPY --from=builder /dependencies /usr/local
ENV PYTHONPATH=/usr/local
EXPOSE 5000
# The actual flask app module
COPY changedetectionio /app/changedetectionio
# Compile translation files for i18n support
RUN pybabel compile -d /app/changedetectionio/translations
# Also for OpenAPI validation wrapper - needs the YML
RUN [ ! -d "/app/docs" ] && mkdir /app/docs
COPY docs/api-spec.yaml /app/docs/api-spec.yaml
# Starting wrapper
COPY changedetection.py /app/changedetection.py
# Github Action test purpose(test-only.yml).
# On production, it is effectively LOGGER_LEVEL=''.
ARG LOGGER_LEVEL=''
ENV LOGGER_LEVEL="$LOGGER_LEVEL"
# Default
ENV LC_ALL=en_US.UTF-8
WORKDIR /app
# Copy and set up entrypoint script for installing extra packages
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Set entrypoint to handle EXTRA_PACKAGES env var
ENTRYPOINT ["/docker-entrypoint.sh"]
# Default command (can be overridden in docker-compose.yml)
CMD ["python", "./changedetection.py", "-d", "/datastore"]
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Web Technologies s.r.o.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: MANIFEST.in
================================================
recursive-include changedetectionio/api *
include docs/api-spec.yaml
recursive-include changedetectionio/blueprint *
recursive-include changedetectionio/conditions *
recursive-include changedetectionio/content_fetchers *
recursive-include changedetectionio/jinja2_custom *
recursive-include changedetectionio/model *
recursive-include changedetectionio/notification *
recursive-include changedetectionio/processors *
recursive-include changedetectionio/realtime *
recursive-include changedetectionio/static *
recursive-include changedetectionio/store *
recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests *
recursive-include changedetectionio/translations *
recursive-include changedetectionio/widgets *
prune changedetectionio/static/package-lock.json
prune changedetectionio/static/styles/node_modules
prune changedetectionio/static/styles/package-lock.json
include changedetectionio/favicon_utils.py
include changedetection.py
include requirements.txt
include README-pip.md
global-exclude *.pyc
global-exclude node_modules
global-exclude venv
global-exclude test-datastore
global-exclude changedetection.io*dist-info
global-exclude changedetectionio/tests/proxy_socks5/test-datastore
================================================
FILE: README-pip.md
================================================
# Monitor website changes
Detect WebPage Changes Automatically — Monitor Web Page Changes in Real Time
Monitor websites for updates — get notified via Discord, Email, Slack, Telegram, Webhook and many more.
Detect web page content changes and get instant alerts.
[Changedetection.io is the best tool to monitor web-pages for changes](https://changedetection.io) Track website content changes and receive notifications via Discord, Email, Slack, Telegram and 90+ more
Ideal for monitoring price changes, content edits, conditional changes and more.
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring, list of websites with changes" title="Self-hosted web page change monitoring, list of websites with changes" />](https://changedetection.io)
[**Don't have time? Try our extremely affordable subscription use our proxies and support!**](https://changedetection.io)
### Target specific parts of the webpage using the Visual Selector tool.
Available when connected to a <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher">playwright content fetcher</a> (included as part of our subscription service)
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/visualselector-anim.gif" style="max-width:100%;" alt="Select parts and elements of a web page to monitor for changes" title="Select parts and elements of a web page to monitor for changes" />](https://changedetection.io?src=pip)
### Easily see what changed, examine by word, line, or individual character.
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference " title="Self-hosted web page change monitoring context difference " />](https://changedetection.io?src=pip)
### Perform interactive browser steps
Fill in text boxes, click buttons and more, setup your changedetection scenario.
Using the **Browser Steps** configuration, add basic steps before performing change detection, such as logging into websites, adding a product to a cart, accept cookie logins, entering dates and refining searches.
[<img src="docs/browsersteps-anim.gif" style="max-width:100%;" alt="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more" title="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more" />](https://changedetection.io?src=pip)
After **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in.
Requires Playwright to be enabled.
### Example use cases
- Products and services have a change in pricing
- _Out of stock notification_ and _Back In stock notification_
- Monitor and track PDF file changes, know when a PDF file has text changes.
- Governmental department updates (changes are often only on their websites)
- New software releases, security advisories when you're not on their mailing list.
- Festivals with changes
- Discogs restock alerts and monitoring
- Realestate listing changes
- Know when your favourite whiskey is on sale, or other special deals are announced before anyone else
- COVID related news from government websites
- University/organisation news from their website
- Detect and monitor changes in JSON API responses
- JSON API monitoring and alerting
- Changes in legal and other documents
- Trigger API calls via notifications when text appears on a website
- Glue together APIs using the JSON filter and JSON notifications
- Create RSS feeds based on changes in web content
- Monitor HTML source code for unexpected changes, strengthen your PCI compliance
- You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product)
- Get notified when certain keywords appear in Twitter search results
- Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords.
- Get alerts when new job positions are open on Bamboo HR and other job platforms
- Website defacement monitoring
- Pokémon Card Restock Tracker / Pokémon TCG Tracker
- RegTech - stay ahead of regulatory changes, regulatory compliance
_Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_
#### Key Features
- Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions!
- Target elements with xPath(1.0) and CSS Selectors, Easily monitor complex JSON with JSONPath or jq
- Switch between fast non-JS and Chrome JS based "fetchers"
- Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums)
- Easily specify how often a site should be checked
- Execute JS before extracting text (Good for logging in, see examples in the UI!)
- Override Request Headers, Specify `POST` or `GET` and other methods
- Use the "Visual Selector" to help target specific elements
- Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration)
- Send a screenshot with the notification when a change is detected in the web page
We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $100 using our signup link.
[Oxylabs](https://oxylabs.go2cloud.org/SH2d) is also an excellent proxy provider and well worth using, they offer Residential, ISP, Rotating and many other proxy types to suit your project.
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
```bash
$ pip3 install changedetection.io
```
Specify a target for the *datastore path* with `-d` (required) and a *listening port* with `-p` (defaults to `5000`)
```bash
$ changedetection.io -d /path/to/empty/data/dir -p 5000
```
Then visit http://127.0.0.1:5000 , You should now be able to access the UI.
See https://changedetection.io for more information.
================================================
FILE: README.md
================================================
# Detect Website Changes Automatically — Monitor Web Page Changes in Real Time
Monitor websites for updates — get notified via Discord, Email, Slack, Telegram, Webhook and many more.
**Detect web page content changes and get instant alerts.**
Ideal for monitoring price changes, content edits, conditional changes and more.
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Web site page change monitoring" title="Web site page change monitoring" />](https://changedetection.io?src=github)
[![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md)

[**Get started with website page change monitoring straight away. Don't have time? Try our $8.99/month subscription, use our proxies and support!**](https://changedetection.io) , _half the price of other website change monitoring services!_
- Chrome browser included.
- Nothing to install, access via browser login after signup.
- Super fast, no registration needed setup.
- Get started watching and receiving website change notifications straight away.
- See our [tutorials and how-to page for more inspiration](https://changedetection.io/tutorials)
### Target specific parts of the webpage using the Visual Selector tool.
Available when connected to a <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher">playwright content fetcher</a> (included as part of our subscription service)
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/visualselector-anim.gif" style="max-width:100%;" alt="Select parts and elements of a web page to monitor for changes" title="Select parts and elements of a web page to monitor for changes" />](https://changedetection.io?src=github)
### Easily see what changed, examine by word, line, or individual character.
[<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference " title="Self-hosted web page change monitoring context difference " />](https://changedetection.io?src=github)
### Perform interactive browser steps
Fill in text boxes, click buttons and more, setup your changedetection scenario.
Using the **Browser Steps** configuration, add basic steps before performing change detection, such as logging into websites, adding a product to a cart, accept cookie logins, entering dates and refining searches.
[<img src="docs/browsersteps-anim.gif" style="max-width:100%;" alt="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more" title="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more" />](https://changedetection.io?src=github)
After **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in.
Requires Playwright to be enabled.
### Awesome restock and price change notifications
Enable the _"Re-stock & Price detection for single product pages"_ option to activate the best way to monitor product pricing, this will extract any meta-data in the HTML page and give you many options to follow the pricing of the product.
Easily organise and monitor prices for products from the dashboard, get alerts and notifications when the price of a product changes or comes back in stock again!
[<img src="docs/restock-overview.png" style="max-width:100%;" alt="Easily keep an eye on product price changes directly from the UI" title="Easily keep an eye on product price changes directly from the UI" />](https://changedetection.io?src=github)
Set price change notification parameters, upper and lower price, price change percentage and more.
Always know when a product for sale drops in price.
[<img src="docs/restock-settings.png" style="max-width:100%;" alt="Set upper lower and percentage price change notification values" title="Set upper lower and percentage price change notification values" />](https://changedetection.io?src=github)
### Example use cases
- Products and services have a change in pricing
- _Out of stock notification_ and _Back In stock notification_
- Monitor and track PDF file changes, know when a PDF file has text changes.
- Governmental department updates (changes are often only on their websites)
- New software releases, security advisories when you're not on their mailing list.
- Festivals with changes
- Discogs restock alerts and monitoring
- Realestate listing changes
- Know when your favourite whiskey is on sale, or other special deals are announced before anyone else
- COVID related news from government websites
- University/organisation news from their website
- Detect and monitor changes in JSON API responses
- JSON API monitoring and alerting
- Changes in legal and other documents
- Trigger API calls via notifications when text appears on a website
- Glue together APIs using the JSON filter and JSON notifications
- Create RSS feeds based on changes in web content
- Monitor HTML source code for unexpected changes, strengthen your PCI compliance
- You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product)
- Get notified when certain keywords appear in Twitter search results
- Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords.
- Get alerts when new job positions are open on Bamboo HR and other job platforms
- Website defacement monitoring
- Pokémon Card Restock Tracker / Pokémon TCG Tracker
- RegTech - stay ahead of regulatory changes, regulatory compliance
_Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_
#### Key Features
- Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions!
- Target elements with xPath 1 and xPath 2, CSS Selectors, Easily monitor complex JSON with JSONPath or jq
- Switch between fast non-JS and Chrome JS based "fetchers"
- Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums)
- Easily specify how often a site should be checked
- Execute JS before extracting text (Good for logging in, see examples in the UI!)
- Override Request Headers, Specify `POST` or `GET` and other methods
- Use the "Visual Selector" to help target specific elements
- Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration)
- Send a screenshot with the notification when a change is detected in the web page
We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $150 using our signup link.
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
### Conditional web page changes
Easily [configure conditional actions](https://changedetection.io/tutorial/conditional-actions-web-page-changes), for example, only trigger when a price is above or below a preset amount, or [when a web page includes (or does not include) a keyword](https://changedetection.io/tutorial/how-monitor-keywords-any-website)
<img src="./docs/web-page-change-conditions.png" style="max-width:80%;" alt="Conditional web page changes" title="Conditional web page changes" />
### Schedule web page watches in any timezone, limit by day of week and time.
Easily set a re-check schedule, for example you could limit the web page change detection to only operate during business hours.
Or perhaps based on a foreign timezone (for example, you want to check for the latest news-headlines in a foreign country at 0900 AM),
<img src="./docs/scheduler.png" style="max-width:80%;" alt="How to monitor web page changes according to a schedule" title="How to monitor web page changes according to a schedule" />
Includes quick short-cut buttons to setup a schedule for **business hours only**, or **weekends**.
### We have a Chrome extension!
Easily add the current web page to your changedetection.io tool, simply install the extension and click "Sync" to connect it to your existing changedetection.io install.
[<img src="./docs/chrome-extension-screenshot.png" style="max-width:80%;" alt="Chrome Extension to easily add the current web-page to detect a change." title="Chrome Extension to easily add the current web-page to detect a change." />](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop)
[Goto the Chrome Webstore to download the extension.](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) ( Or check out the [GitHub repo](https://github.com/dgtlmoon/changedetection.io-browser-extension) )
## Installation
### Docker
With Docker composer, just clone this repository and..
```bash
$ docker compose up -d
```
Docker standalone
```bash
$ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io
```
`:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch.
Alternative docker repository over at ghcr - [ghcr.io/dgtlmoon/changedetection.io](https://ghcr.io/dgtlmoon/changedetection.io)
### Windows
See the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows
### Python Pip
Check out our pypi page https://pypi.org/project/changedetection.io/
```bash
$ pip3 install changedetection.io
$ changedetection.io -d /path/to/empty/data/dir -p 5000
```
Then visit http://127.0.0.1:5000 , You should now be able to access the UI.
_Now with per-site configurable support for using a fast built in HTTP fetcher or use a Chrome based fetcher for monitoring of JavaScript websites!_
## Updating changedetection.io
### Docker
```
docker pull dgtlmoon/changedetection.io
docker kill $(docker ps -a -f name=changedetection.io -q)
docker rm $(docker ps -a -f name=changedetection.io -q)
docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io
```
### docker compose
```bash
docker compose pull && docker compose up -d
```
See the wiki for more information https://github.com/dgtlmoon/changedetection.io/wiki
## Different browser viewport sizes (mobile, desktop etc)
If you are using the recommended `sockpuppetbrowser` (which is in the docker-compose.yml as a setting to be uncommented) you can easily set different viewport sizes for your web page change detection, [see more information here about setting up different viewport sizes](https://github.com/dgtlmoon/sockpuppetbrowser?tab=readme-ov-file#setting-viewport-size).
## Filters
XPath(1.0), JSONPath, jq, and CSS support comes baked in! You can be as specific as you need, use XPath exported from various XPath element query creation tools.
(We support LXML `re:test`, `re:match` and `re:replace`.)
## Notifications
ChangeDetection.io supports a massive amount of notifications (including email, office365, custom APIs, etc) when a web-page has a change detected thanks to the <a href="https://github.com/caronc/apprise">apprise</a> library.
Simply set one or more notification URL's in the _[edit]_ tab of that watch.
Just some examples
discord://webhook_id/webhook_token
flock://app_token/g:channel_id
gitter://token/room
gchat://workspace/key/token
msteams://TokenA/TokenB/TokenC/
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
rocket://user:password@hostname/#Channel
mailto://user:pass@example.com?to=receivingAddress@example.com
json://someserver.com/custom-api
syslog://
<a href="https://github.com/caronc/apprise#popular-notification-services">And everything else in this list!</a>
<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-notifications.png" style="max-width:100%;" alt="Self-hosted web page change monitoring notifications" title="Self-hosted web page change monitoring notifications" />
Now you can also customise your notification content and use <a target="_new" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2 templating</a> for their title and body!
## JSON API Monitoring
Detect changes and monitor data in JSON API's by using either JSONPath or jq to filter, parse, and restructure JSON as needed.

This will re-parse the JSON and apply formatting to the text, making it super easy to monitor and detect changes in JSON API results

### JSONPath or jq?
For more complex parsing, filtering, and modifying of JSON data, jq is recommended due to the built-in operators and functions. Refer to the [documentation](https://stedolan.github.io/jq/manual/) for more specific information on jq.
One big advantage of `jq` is that you can use logic in your JSON filter, such as filters to only show items that have a value greater than/less than etc.
See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/JSON-Selector-Filter-help for more information and examples
### Parse JSON embedded in HTML!
When you enable a `json:` or `jq:` filter, you can even automatically extract and parse embedded JSON inside a HTML page! Amazingly handy for sites that build content based on JSON, such as many e-commerce websites.
```
<html>
...
<script type="application/ld+json">
{
"@context":"http://schema.org/",
"@type":"Product",
"offers":{
"@type":"Offer",
"availability":"http://schema.org/InStock",
"price":"3949.99",
"priceCurrency":"USD",
"url":"https://www.newegg.com/p/3D5-000D-001T1"
},
"description":"Cobratype King Cobra Hero Desktop Gaming PC",
"name":"Cobratype King Cobra Hero Desktop Gaming PC",
"sku":"3D5-000D-001T1",
"itemCondition":"NewCondition"
}
</script>
```
`json:$..price` or `jq:..price` would give `3949.99`, or you can extract the whole structure (use a JSONpath test website to validate with)
The application also supports notifying you that it can follow this information automatically
## Proxy Configuration
See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration , we also support using [Bright Data proxy services where possible](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support) and [Oxylabs](https://oxylabs.go2cloud.org/SH2d) proxy services.
## Raspberry Pi support?
Raspberry Pi and linux/arm/v6 linux/arm/v7 arm64 devices are supported! See the wiki for [details](https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver)
## Import support
Easily [import your list of websites to watch for changes in Excel .xslx file format](https://changedetection.io/tutorial/how-import-your-website-change-detection-lists-excel), or paste in lists of website URLs as plaintext.
Excel import is recommended - that way you can better organise tags/groups of websites and other features.
## API Support
Full REST API for programmatic management of watches, tags, notifications and more.
- **[Interactive API Documentation](https://changedetection.io/docs/api_v1/index.html)** - Complete API reference with live testing
- **[OpenAPI Specification](docs/api-spec.yaml)** - Generate SDKs for any programming language
## Support us
Do you use changedetection.io to make money? does it save you time or money? Does it make your life easier? less stressful? Remember, we write this software when we should be doing actual paid work, we have to buy food and pay rent just like you.
Consider taking out an officially supported [website change detection subscription](https://changedetection.io?src=github) , even if you don't use it, you still get the warm fuzzy feeling of helping out the project. (And who knows, you might just use it!)
## Commercial Support
I offer commercial support, this software is depended on by network security, aerospace , data-science and data-journalist professionals just to name a few, please reach out at dgtlmoon@gmail.com for any enquiries, I am more than glad to work with your organisation to further the possibilities of what can be done with changedetection.io
[release-shield]: https://img.shields.io:/github/v/release/dgtlmoon/changedetection.io?style=for-the-badge
[docker-pulls]: https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io?style=for-the-badge
[test-shield]: https://github.com/dgtlmoon/changedetection.io/actions/workflows/test-only.yml/badge.svg?branch=master
[license-shield]: https://img.shields.io/github/license/dgtlmoon/changedetection.io.svg?style=for-the-badge
[release-link]: https://github.com/dgtlmoon/changedetection.io/releases
[docker-link]: https://hub.docker.com/r/dgtlmoon/changedetection.io
## Commercial Licencing
If you are reselling this software either in part or full as part of any commercial arrangement, you must abide by our COMMERCIAL_LICENCE.md found in our code repository, please contact dgtlmoon@gmail.com and contact@changedetection.io .
## Third-party licenses
changedetectionio.html_tools.elementpath_tostring: Copyright (c), 2018-2021, SISSA (Scuola Internazionale Superiore di Studi Avanzati), Licensed under [MIT license](https://github.com/sissaschool/elementpath/blob/master/LICENSE)
## Contributors
Recognition of fantastic contributors to the project
- Constantin Hong https://github.com/Constantin1489
================================================
FILE: babel.cfg
================================================
[python: **.py]
keywords = _:1,_l:1,gettext:1
[jinja2: **/templates/**.html]
encoding = utf-8
================================================
FILE: changedetection.py
================================================
#!/usr/bin/env python3
# Only exists for direct CLI usage
import changedetectionio
if __name__ == '__main__':
changedetectionio.main()
================================================
FILE: changedetectionio/.gitignore
================================================
test-datastore
package-lock.json
================================================
FILE: changedetectionio/PLUGIN_README.md
================================================
# Creating Plugins for changedetection.io
This document describes how to create plugins for changedetection.io. Plugins can be used to extend the functionality of the application in various ways.
## Plugin Types
### UI Stats Tab Plugins
These plugins can add content to the Stats tab in the Edit page. This is useful for adding custom statistics or visualizations about a watch.
#### Creating a UI Stats Tab Plugin
1. Create a Python file in a directory that will be loaded by the plugin system.
2. Use the `global_hookimpl` decorator to implement the `ui_edit_stats_extras` hook:
```python
import pluggy
from loguru import logger
global_hookimpl = pluggy.HookimplMarker("changedetectionio")
@global_hookimpl
def ui_edit_stats_extras(watch):
"""Add custom content to the stats tab"""
# Calculate or retrieve your stats
my_stat = calculate_something(watch)
# Return HTML content as a string
html = f"""
<div class="my-plugin-stats">
<h4>My Plugin Statistics</h4>
<p>My statistic: {my_stat}</p>
</div>
"""
return html
```
3. The HTML you return will be included in the Stats tab.
## Plugin Loading
Plugins can be loaded from:
1. Built-in plugin directories in the codebase
2. External packages using setuptools entry points
To add a new plugin directory, modify the `plugin_dirs` dictionary in `pluggy_interface.py`.
## Example Plugin
Here's a simple example of a plugin that adds a word count statistic to the Stats tab:
```python
import pluggy
from loguru import logger
global_hookimpl = pluggy.HookimplMarker("changedetectionio")
def count_words_in_history(watch):
"""Count words in the latest snapshot"""
try:
if not watch.history.keys():
return 0
latest_key = list(watch.history.keys())[-1]
latest_content = watch.get_history_snapshot(timestamp=latest_key)
return len(latest_content.split())
except Exception as e:
logger.error(f"Error counting words: {str(e)}")
return 0
@global_hookimpl
def ui_edit_stats_extras(watch):
"""Add word count to the Stats tab"""
word_count = count_words_in_history(watch)
html = f"""
<div class="word-count-stats">
<h4>Content Analysis</h4>
<table class="pure-table">
<tbody>
<tr>
<td>Word count (latest snapshot)</td>
<td>{word_count}</td>
</tr>
</tbody>
</table>
</div>
"""
return html
```
## Testing Your Plugin
1. Place your plugin in one of the directories scanned by the plugin system
2. Restart changedetection.io
3. Go to the Edit page of a watch and check the Stats tab to see your content
================================================
FILE: changedetectionio/__init__.py
================================================
#!/usr/bin/env python3
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.54.6'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
from loguru import logger
import getopt
import logging
import os
import platform
import signal
import threading
import time
# Eventlet completely removed - using threading mode for SocketIO
# This provides better Python 3.12+ compatibility and eliminates eventlet/asyncio conflicts
# Note: store and changedetection_app are imported inside main() to avoid
# initialization before argument parsing (allows --help to work without loading everything)
# ==============================================================================
# Multiprocessing Configuration - CRITICAL for Thread Safety
# ==============================================================================
#
# PROBLEM: Python 3.12+ warns about fork() with multi-threaded processes:
# "This process is multi-threaded, use of fork() may lead to deadlocks"
#
# WHY IT'S DANGEROUS:
# 1. This Flask app has multiple threads (HTTP handlers, workers, SocketIO)
# 2. fork() copies ONLY the calling thread to the child process
# 3. BUT fork() also copies all locks/mutexes in their current state
# 4. If another thread held a lock during fork() → child has locked lock with no owner
# 5. Result: PERMANENT DEADLOCK if child tries to acquire that lock
#
# SOLUTION: Use 'spawn' instead of 'fork'
# - spawn starts a fresh Python interpreter (no inherited threads or locks)
# - Slower (~200ms vs ~1ms) but safe with multi-threaded parent
# - Consistent across all platforms (Windows already uses spawn by default)
#
# IMPLEMENTATION:
# 1. Explicit contexts everywhere (primary protection):
# - playwright.py: ctx = multiprocessing.get_context('spawn')
# - puppeteer.py: ctx = multiprocessing.get_context('spawn')
# - isolated_opencv.py: ctx = multiprocessing.get_context('spawn')
# - isolated_libvips.py: ctx = multiprocessing.get_context('spawn')
#
# 2. Global default (defense-in-depth, below):
# - Safety net if future code forgets explicit context
# - Protects against third-party libraries using Process()
# - Costs nothing (explicit contexts always override it)
#
# WHY BOTH?
# - Explicit contexts: Clear, self-documenting, always works
# - Global default: Safety net for forgotten contexts or library code
# - If someone writes "Process()" instead of "ctx.Process()", still safe!
#
# See: https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
# ==============================================================================
import multiprocessing
import os
import sys
# Limit glibc malloc arena count to prevent RSS growth from concurrent requests.
# Default: glibc creates up to 8×CPU_cores arenas. Each concurrent thread/connection
# can trigger a new arena, and freed memory stays mapped in those arenas as RSS forever.
# With MALLOC_ARENA_MAX=2, at most 2 arenas are used; freed pages return to the OS faster.
# Must be set before worker threads start; env var is read lazily by glibc on first arena creation.
if 'MALLOC_ARENA_MAX' not in os.environ:
os.environ['MALLOC_ARENA_MAX'] = '2'
try:
import ctypes as _ctypes
_ctypes.CDLL('libc.so.6').mallopt(-8, 2) # M_ARENA_MAX = -8
except Exception:
pass
# Set spawn as global default (safety net - all our code uses explicit contexts anyway)
# Skip in tests to avoid breaking pytest-flask's LiveServer fixture (uses unpicklable local functions)
if 'pytest' not in sys.modules:
try:
if multiprocessing.get_start_method(allow_none=True) is None:
multiprocessing.set_start_method('spawn', force=False)
logger.debug("Set multiprocessing default to 'spawn' for thread safety (explicit contexts used everywhere)")
except RuntimeError:
logger.debug(f"Multiprocessing start method already set: {multiprocessing.get_start_method()}")
# Only global so we can access it in the signal handler
app = None
datastore = None
def get_version():
return __version__
# Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown
def sigshutdown_handler(_signo, _stack_frame):
name = signal.Signals(_signo).name
logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Fast shutdown initiated')
# Set exit flag immediately to stop all loops
app.config.exit.set()
datastore.stop_thread = True
# Log memory consumption before shutting down workers (cross-platform)
try:
import psutil
process = psutil.Process()
mem_info = process.memory_info()
rss_mb = mem_info.rss / 1024 / 1024
vms_mb = mem_info.vms / 1024 / 1024
logger.info(f"Memory consumption before worker shutdown: RSS={rss_mb:,.2f} MB, VMS={vms_mb:,.2f} MB")
except Exception as e:
logger.warning(f"Could not retrieve memory stats: {str(e)}")
# Shutdown workers and queues immediately
try:
from changedetectionio import worker_pool
worker_pool.shutdown_workers()
except Exception as e:
logger.error(f"Error shutting down workers: {str(e)}")
# Close janus queues properly
try:
from changedetectionio.flask_app import update_q, notification_q
update_q.close()
notification_q.close()
logger.debug("Queues closed successfully")
except Exception as e:
logger.critical(f"CRITICAL: Failed to close queues: {e}")
# Shutdown socketio server fast
from changedetectionio.flask_app import socketio_server
if socketio_server and hasattr(socketio_server, 'shutdown'):
try:
socketio_server.shutdown()
except Exception as e:
logger.error(f"Error shutting down Socket.IO server: {str(e)}")
# With immediate persistence, all data is already saved
logger.success('All data already persisted (immediate commits enabled).')
sys.exit()
def print_help():
"""Print help text for command line options"""
print('Usage: changedetection.py [options]')
print('')
print('Standard options:')
print(' -s SSL enable')
print(' -h HOST Listen host (default: 0.0.0.0)')
print(' -p PORT Listen port (default: 5000)')
print(' -d PATH Datastore path')
print(' -l LEVEL Log level (TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)')
print(' -c Cleanup unused snapshots')
print(' -C Create datastore directory if it doesn\'t exist')
print(' -P true/false Set all watches paused (true) or active (false)')
print('')
print('Add URLs on startup:')
print(' -u URL Add URL to watch (can be used multiple times)')
print(' -u0 \'JSON\' Set options for first -u URL (e.g. \'{"processor":"text_json_diff"}\')')
print(' -u1 \'JSON\' Set options for second -u URL (0-indexed)')
print(' -u2 \'JSON\' Set options for third -u URL, etc.')
print(' Available options: processor, fetch_backend, headers, method, etc.')
print(' See model/Watch.py for all available options')
print('')
print('Recheck on startup:')
print(' -r all Queue all watches for recheck on startup')
print(' -r UUID,... Queue specific watches (comma-separated UUIDs)')
print(' -r all N Queue all watches, wait for completion, repeat N times')
print(' -r UUID,... N Queue specific watches, wait for completion, repeat N times')
print('')
print('Batch mode:')
print(' -b Run in batch mode (process queue then exit)')
print(' Useful for CI/CD, cron jobs, or one-time checks')
print(' NOTE: Batch mode checks if Flask is running and aborts if port is in use')
print(' Use -p PORT to specify a different port if needed')
print('')
def main():
global datastore
global app
# Early help/version check before any initialization
if '--help' in sys.argv or '-help' in sys.argv:
print_help()
sys.exit(0)
if '--version' in sys.argv or '-v' in sys.argv:
print(f'changedetection.io {__version__}')
sys.exit(0)
# Import heavy modules after help/version checks to keep startup fast for those flags
from changedetectionio import store
from changedetectionio.flask_app import changedetection_app
datastore_path = None
# Set a default logger level
logger_level = 'DEBUG'
include_default_watches = True
all_paused = None # None means don't change, True/False to set
host = os.environ.get("LISTEN_HOST", "0.0.0.0").strip()
port = int(os.environ.get('PORT', 5000))
ssl_mode = False
# Lists for multiple URLs and their options
urls_to_add = []
url_options = {} # Key: index (0-based), Value: dict of options
recheck_watches = None # None, 'all', or list of UUIDs
recheck_repeat_count = 1 # Number of times to repeat recheck cycle
batch_mode = False # Run once then exit when queue is empty
# On Windows, create and use a default path.
if os.name == 'nt':
datastore_path = os.path.expandvars(r'%APPDATA%\changedetection.io')
os.makedirs(datastore_path, exist_ok=True)
else:
# Must be absolute so that send_from_directory doesnt try to make it relative to backend/
datastore_path = os.path.join(os.getcwd(), "../datastore")
# Pre-process arguments to extract -u, -u<N>, and -r options before getopt
# This allows unlimited -u0, -u1, -u2, ... options without predefining them
cleaned_argv = ['changedetection.py'] # Start with program name
i = 1
while i < len(sys.argv):
arg = sys.argv[i]
# Handle -u (add URL)
if arg == '-u' and i + 1 < len(sys.argv):
urls_to_add.append(sys.argv[i + 1])
i += 2
continue
# Handle -u<N> (set options for URL at index N)
if arg.startswith('-u') and len(arg) > 2 and arg[2:].isdigit():
idx = int(arg[2:])
if i + 1 < len(sys.argv):
try:
import json
url_options[idx] = json.loads(sys.argv[i + 1])
except json.JSONDecodeError as e:
print(f'Error: Invalid JSON for {arg}: {sys.argv[i + 1]}')
print(f'JSON decode error: {e}')
sys.exit(2)
i += 2
continue
# Handle -r (recheck watches)
if arg == '-r' and i + 1 < len(sys.argv):
recheck_arg = sys.argv[i + 1]
if recheck_arg.lower() == 'all':
recheck_watches = 'all'
else:
# Parse comma-separated list of UUIDs
recheck_watches = [uuid.strip() for uuid in recheck_arg.split(',') if uuid.strip()]
# Check for optional repeat count as third argument
if i + 2 < len(sys.argv) and sys.argv[i + 2].isdigit():
recheck_repeat_count = int(sys.argv[i + 2])
if recheck_repeat_count < 1:
print(f'Error: Repeat count must be at least 1, got {recheck_repeat_count}')
sys.exit(2)
i += 3
else:
i += 2
continue
# Handle -b (batch mode - run once and exit)
if arg == '-b':
batch_mode = True
i += 1
continue
# Keep other arguments for getopt
cleaned_argv.append(arg)
i += 1
try:
opts, args = getopt.getopt(cleaned_argv[1:], "6Csd:h:p:l:P:", "port")
except getopt.GetoptError as e:
print_help()
print(f'Error: {e}')
sys.exit(2)
create_datastore_dir = False
# Set a logger level via shell env variable
# Used: Dockerfile for CICD
# To set logger level for pytest, see the app function in tests/conftest.py
if os.getenv("LOGGER_LEVEL"):
level = os.getenv("LOGGER_LEVEL")
logger_level = int(level) if level.isdigit() else level.upper()
for opt, arg in opts:
if opt == '-s':
ssl_mode = True
if opt == '-h':
host = arg
if opt == '-p':
port = int(arg)
if opt == '-d':
datastore_path = arg
# Create the datadir if it doesnt exist
if opt == '-C':
create_datastore_dir = True
if opt == '-l':
logger_level = int(arg) if arg.isdigit() else arg.upper()
if opt == '-P':
try:
all_paused = bool(strtobool(arg))
except ValueError:
print(f'Error: Invalid value for -P option: {arg}')
print('Expected: true, false, yes, no, 1, or 0')
sys.exit(2)
# If URLs are provided, don't include default watches
if urls_to_add:
include_default_watches = False
logger.success(f"changedetection.io version {get_version()} starting.")
# Launch using SocketIO run method for proper integration (if enabled)
ssl_cert_file = os.getenv("SSL_CERT_FILE", 'cert.pem')
ssl_privkey_file = os.getenv("SSL_PRIVKEY_FILE", 'privkey.pem')
if os.getenv("SSL_CERT_FILE") and os.getenv("SSL_PRIVKEY_FILE"):
ssl_mode = True
# SSL mode could have been set by -s too, therefor fallback to default values
if ssl_mode:
if not os.path.isfile(ssl_cert_file) or not os.path.isfile(ssl_privkey_file):
logger.critical(f"Cannot start SSL/HTTPS mode, Please be sure that {ssl_cert_file}' and '{ssl_privkey_file}' exist in in {os.getcwd()}")
os._exit(2)
# Without this, a logger will be duplicated
logger.remove()
try:
log_level_for_stdout = { 'TRACE', 'DEBUG', 'INFO', 'SUCCESS' }
logger.configure(handlers=[
{"sink": sys.stdout, "level": logger_level,
"filter" : lambda record: record['level'].name in log_level_for_stdout},
{"sink": sys.stderr, "level": logger_level,
"filter": lambda record: record['level'].name not in log_level_for_stdout},
])
# Catch negative number or wrong log level name
except ValueError:
print("Available log level names: TRACE, DEBUG(default), INFO, SUCCESS,"
" WARNING, ERROR, CRITICAL")
sys.exit(2)
# Disable verbose pyppeteer logging to prevent memory leaks from large CDP messages
# Set both parent and child loggers since pyppeteer hardcodes DEBUG level
logging.getLogger('pyppeteer.connection').setLevel(logging.WARNING)
logging.getLogger('pyppeteer.connection.Connection').setLevel(logging.WARNING)
# isnt there some @thingy to attach to each route to tell it, that this route needs a datastore
app_config = {
'datastore_path': datastore_path,
'batch_mode': batch_mode,
'recheck_watches': recheck_watches,
'recheck_repeat_count': recheck_repeat_count
}
if not os.path.isdir(app_config['datastore_path']):
if create_datastore_dir:
os.makedirs(app_config['datastore_path'], exist_ok=True)
else:
logger.critical(
f"ERROR: Directory path for the datastore '{app_config['datastore_path']}'"
f" does not exist, cannot start, please make sure the"
f" directory exists or specify a directory with the -d option.\n"
f"Or use the -C parameter to create the directory.")
sys.exit(2)
try:
datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__, include_default_watches=include_default_watches)
except JSONDecodeError as e:
# Dont' start if the JSON DB looks corrupt
logger.critical(f"ERROR: JSON DB or Proxy List JSON at '{app_config['datastore_path']}' appears to be corrupt, aborting.")
logger.critical(str(e))
sys.exit(1)
# Testing mode: Exit cleanly after datastore initialization (for CI/CD upgrade tests)
if os.environ.get('TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD'):
logger.success(f"TESTING MODE: Datastore loaded successfully from {app_config['datastore_path']}")
logger.success(f"TESTING MODE: Schema version: {datastore.data['settings']['application'].get('schema_version', 'unknown')}")
logger.success(f"TESTING MODE: Loaded {len(datastore.data['watching'])} watches")
logger.success("TESTING MODE: Exiting cleanly (TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD is set)")
sys.exit(0)
# Apply all_paused setting if specified via CLI
if all_paused is not None:
datastore.data['settings']['application']['all_paused'] = all_paused
logger.info(f"Setting all watches paused: {all_paused}")
# Inject datastore into plugins that need access to settings
from changedetectionio.pluggy_interface import inject_datastore_into_plugins
inject_datastore_into_plugins(datastore)
# Step 1: Add URLs with their options (if provided via -u flags)
added_watch_uuids = []
if urls_to_add:
logger.info(f"Adding {len(urls_to_add)} URL(s) from command line")
for idx, url in enumerate(urls_to_add):
extras = url_options.get(idx, {})
if extras:
logger.debug(f"Adding watch {idx}: {url} with options: {extras}")
else:
logger.debug(f"Adding watch {idx}: {url}")
new_uuid = datastore.add_watch(url=url, extras=extras)
if new_uuid:
added_watch_uuids.append(new_uuid)
logger.success(f"Added watch: {url} (UUID: {new_uuid})")
else:
logger.error(f"Failed to add watch: {url}")
app = changedetection_app(app_config, datastore)
# Step 2: Queue newly added watches (if -u was provided in batch mode)
# This must happen AFTER app initialization so update_q is available
if batch_mode and added_watch_uuids:
from changedetectionio.flask_app import update_q
from changedetectionio import queuedWatchMetaData, worker_pool
logger.info(f"Batch mode: Queuing {len(added_watch_uuids)} newly added watches")
for watch_uuid in added_watch_uuids:
try:
worker_pool.queue_item_async_safe(
update_q,
queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})
)
logger.debug(f"Queued newly added watch: {watch_uuid}")
except Exception as e:
logger.error(f"Failed to queue watch {watch_uuid}: {e}")
# Step 3: Queue watches for recheck (if -r was provided)
# This must happen AFTER app initialization so update_q is available
if recheck_watches is not None:
from changedetectionio.flask_app import update_q
from changedetectionio import queuedWatchMetaData, worker_pool
watches_to_queue = []
if recheck_watches == 'all':
# Queue all watches, excluding those already queued in batch mode
all_watches = list(datastore.data['watching'].keys())
if batch_mode and added_watch_uuids:
# Exclude newly added watches that were already queued in batch mode
watches_to_queue = [uuid for uuid in all_watches if uuid not in added_watch_uuids]
logger.info(f"Queuing {len(watches_to_queue)} existing watches for recheck ({len(added_watch_uuids)} newly added watches already queued)")
else:
watches_to_queue = all_watches
logger.info(f"Queuing all {len(watches_to_queue)} watches for recheck")
else:
# Queue specific UUIDs
watches_to_queue = recheck_watches
logger.info(f"Queuing {len(watches_to_queue)} specific watches for recheck")
queued_count = 0
for watch_uuid in watches_to_queue:
if watch_uuid in datastore.data['watching']:
try:
worker_pool.queue_item_async_safe(
update_q,
queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})
)
queued_count += 1
logger.debug(f"Queued watch for recheck: {watch_uuid}")
except Exception as e:
logger.error(f"Failed to queue watch {watch_uuid}: {e}")
else:
logger.warning(f"Watch UUID not found in datastore: {watch_uuid}")
logger.success(f"Successfully queued {queued_count} watches for recheck")
# Step 4: Setup batch mode monitor (if -b was provided)
if batch_mode:
from changedetectionio.flask_app import update_q
# Safety check: Ensure Flask app is not already running on this port
# Batch mode should never run alongside the web server
import socket
test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
# Try to bind to the configured host:port (no SO_REUSEADDR - strict check)
test_socket.bind((host, port))
test_socket.close()
logger.debug(f"Batch mode: Port {port} is available (Flask app not running)")
except OSError as e:
test_socket.close()
# errno 98 = EADDRINUSE (Linux)
# errno 48 = EADDRINUSE (macOS)
# errno 10048 = WSAEADDRINUSE (Windows)
if e.errno in (48, 98, 10048) or "Address already in use" in str(e) or "already in use" in str(e).lower():
logger.critical(f"ERROR: Batch mode cannot run - port {port} is already in use")
logger.critical(f"The Flask web server appears to be running on {host}:{port}")
logger.critical(f"Batch mode is designed for standalone operation (CI/CD, cron jobs, etc.)")
logger.critical(f"Please either stop the Flask web server, or use a different port with -p PORT")
sys.exit(1)
else:
# Some other socket error - log but continue (might be network configuration issue)
logger.warning(f"Port availability check failed with unexpected error: {e}")
logger.warning(f"Continuing with batch mode anyway - be aware of potential conflicts")
def queue_watches_for_recheck(datastore, iteration):
"""Helper function to queue watches for recheck"""
watches_to_queue = []
if recheck_watches == 'all':
all_watches = list(datastore.data['watching'].keys())
if batch_mode and added_watch_uuids and iteration == 1:
# Only exclude newly added watches on first iteration
watches_to_queue = [uuid for uuid in all_watches if uuid not in added_watch_uuids]
else:
watches_to_queue = all_watches
logger.info(f"Batch mode (iteration {iteration}): Queuing all {len(watches_to_queue)} watches")
elif recheck_watches:
watches_to_queue = recheck_watches
logger.info(f"Batch mode (iteration {iteration}): Queuing {len(watches_to_queue)} specific watches")
queued_count = 0
for watch_uuid in watches_to_queue:
if watch_uuid in datastore.data['watching']:
try:
worker_pool.queue_item_async_safe(
update_q,
queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})
)
queued_count += 1
except Exception as e:
logger.error(f"Failed to queue watch {watch_uuid}: {e}")
else:
logger.warning(f"Watch UUID not found in datastore: {watch_uuid}")
logger.success(f"Batch mode (iteration {iteration}): Successfully queued {queued_count} watches")
return queued_count
def batch_mode_monitor():
"""Monitor queue and workers, shutdown or repeat when work is complete"""
import time
# Track iterations if repeat mode is enabled
current_iteration = 1
total_iterations = recheck_repeat_count if recheck_watches and recheck_repeat_count > 1 else 1
if total_iterations > 1:
logger.info(f"Batch mode: Will repeat recheck {total_iterations} times")
else:
logger.info("Batch mode: Waiting for all queued items to complete...")
# Wait a bit for workers to start processing
time.sleep(3)
try:
while current_iteration <= total_iterations:
logger.info(f"Batch mode: Waiting for iteration {current_iteration}/{total_iterations} to complete...")
# Use the shared wait_for_all_checks function
completed = worker_pool.wait_for_all_checks(update_q, timeout=300)
if not completed:
logger.warning(f"Batch mode: Iteration {current_iteration} timed out after 300 seconds")
logger.success(f"Batch mode: Iteration {current_iteration}/{total_iterations} completed")
# Check if we need to repeat
if current_iteration < total_iterations:
logger.info(f"Batch mode: Starting iteration {current_iteration + 1}...")
current_iteration += 1
# Re-queue watches for next iteration
queue_watches_for_recheck(datastore, current_iteration)
# Brief pause before continuing
time.sleep(2)
else:
# All iterations complete
logger.success(f"Batch mode: All {total_iterations} iterations completed, initiating shutdown")
# Trigger shutdown
import os, signal
os.kill(os.getpid(), signal.SIGTERM)
return
except Exception as e:
logger.error(f"Batch mode monitor error: {e}")
logger.error(f"Initiating emergency shutdown")
import os, signal
os.kill(os.getpid(), signal.SIGTERM)
# Start monitor in background thread
monitor_thread = threading.Thread(target=batch_mode_monitor, daemon=True, name="BatchModeMonitor")
monitor_thread.start()
logger.info("Batch mode enabled: Will exit after all queued items are processed")
# Get the SocketIO instance from the Flask app (created in flask_app.py)
from changedetectionio.flask_app import socketio_server
global socketio
socketio = socketio_server
signal.signal(signal.SIGTERM, sigshutdown_handler)
signal.signal(signal.SIGINT, sigshutdown_handler)
# Custom signal handler for memory cleanup
def sigusr_clean_handler(_signo, _stack_frame):
from changedetectionio.gc_cleanup import memory_cleanup
logger.info('SIGUSR1 received: Running memory cleanup')
return memory_cleanup(app)
# Register the SIGUSR1 signal handler
# Only register the signal handler if running on Linux
if platform.system() == "Linux":
signal.signal(signal.SIGUSR1, sigusr_clean_handler)
else:
logger.info("SIGUSR1 handler only registered on Linux, skipped.")
app.config['datastore_path'] = datastore_path
@app.context_processor
def inject_template_globals():
return dict(right_sticky="v"+__version__,
new_version_available=app.config['NEW_VERSION_AVAILABLE'],
has_password=datastore.data['settings']['application']['password'] != False,
socket_io_enabled=datastore.data['settings']['application'].get('ui', {}).get('socket_io_enabled', True),
all_paused=datastore.data['settings']['application'].get('all_paused', False),
all_muted=datastore.data['settings']['application'].get('all_muted', False)
)
# Monitored websites will not receive a Referer header when a user clicks on an outgoing link.
@app.after_request
def hide_referrer(response):
if strtobool(os.getenv("HIDE_REFERER", 'false')):
response.headers["Referrer-Policy"] = "same-origin"
return response
# Proxy sub-directory support
# Set environment var USE_X_SETTINGS=1 on this script
# And then in your proxy_pass settings
#
# proxy_set_header Host "localhost";
# proxy_set_header X-Forwarded-Prefix /app;
if os.getenv('USE_X_SETTINGS'):
logger.info("USE_X_SETTINGS is ENABLED")
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=1, # X-Forwarded-For (client IP)
x_proto=1, # X-Forwarded-Proto (http/https)
x_host=1, # X-Forwarded-Host (original host)
x_port=1, # X-Forwarded-Port (original port)
x_prefix=1 # X-Forwarded-Prefix (URL prefix)
)
# In batch mode, skip starting the HTTP server - just keep workers running
if batch_mode:
logger.info("Batch mode: Skipping HTTP server startup, workers will process queue")
logger.info("Batch mode: Main thread will wait for shutdown signal")
# Keep main thread alive until batch monitor triggers shutdown
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("Batch mode: Keyboard interrupt received")
pass
else:
# Normal mode: Start HTTP server
# SocketIO instance is already initialized in flask_app.py
if socketio_server:
if ssl_mode:
logger.success(f"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}")
socketio.run(app, host=host, port=int(port), debug=False,
ssl_context=(ssl_cert_file, ssl_privkey_file), allow_unsafe_werkzeug=True)
else:
socketio.run(app, host=host, port=int(port), debug=False, allow_unsafe_werkzeug=True)
else:
# Run Flask app without Socket.IO if disabled
logger.info("Starting Flask app without Socket.IO server")
if ssl_mode:
logger.success(f"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}")
app.run(host=host, port=int(port), debug=False,
ssl_context=(ssl_cert_file, ssl_privkey_file))
else:
app.run(host=host, port=int(port), debug=False)
================================================
FILE: changedetectionio/api/Import.py
================================================
from changedetectionio.strtobool import strtobool
from flask_restful import abort, Resource
from flask import request
from functools import wraps
from . import auth, validate_openapi_request
from ..validate_url import is_safe_valid_url
import json
# Number of URLs above which import switches to background processing
IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD = 20
def default_content_type(content_type='text/plain'):
"""Decorator to set a default Content-Type header if none is provided."""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not request.content_type:
# Set default content type in the request environment
request.environ['CONTENT_TYPE'] = content_type
return f(*args, **kwargs)
return wrapper
return decorator
def convert_query_param_to_type(value, schema_property):
"""
Convert a query parameter string to the appropriate type based on schema definition.
Args:
value: String value from query parameter
schema_property: Schema property definition with 'type' or 'anyOf' field
Returns:
Converted value in the appropriate type
Supports both OpenAPI 3.1 formats:
- type: [string, 'null'] (array format)
- anyOf: [{type: string}, {type: null}] (anyOf format)
"""
prop_type = schema_property.get('type')
# Handle OpenAPI 3.1 type arrays: type: [string, 'null']
if isinstance(prop_type, list):
# Use the first non-null type from the array
for t in prop_type:
if t != 'null':
prop_type = t
break
else:
prop_type = None
# Handle anyOf schemas (older format)
elif 'anyOf' in schema_property:
# Use the first non-null type from anyOf
for option in schema_property['anyOf']:
if option.get('type') and option.get('type') != 'null':
prop_type = option.get('type')
break
else:
prop_type = None
# Handle array type (e.g., notification_urls)
if prop_type == 'array':
# Support both comma-separated and JSON array format
if value.startswith('['):
try:
return json.loads(value)
except json.JSONDecodeError:
return [v.strip() for v in value.split(',')]
return [v.strip() for v in value.split(',')]
# Handle object type (e.g., time_between_check, headers)
elif prop_type == 'object':
try:
return json.loads(value)
except json.JSONDecodeError:
raise ValueError(f"Invalid JSON object for field: {value}")
# Handle boolean type
elif prop_type == 'boolean':
return strtobool(value)
# Handle integer type
elif prop_type == 'integer':
return int(value)
# Handle number type (float)
elif prop_type == 'number':
return float(value)
# Default: return as string
return value
class Import(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@default_content_type('text/plain') #3547 #3542
@validate_openapi_request('importWatches')
def post(self):
"""Import a list of watched URLs with optional watch configuration."""
from . import get_watch_schema_properties
# Special parameters that are NOT watch configuration
special_params = {'tag', 'tag_uuids', 'dedupe', 'proxy'}
extras = {}
# Handle special 'proxy' parameter
if request.args.get('proxy'):
plist = self.datastore.proxy_list
if not request.args.get('proxy') in plist:
proxy_list_str = ', '.join(plist) if plist else 'none configured'
return f"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'", 400
else:
extras['proxy'] = request.args.get('proxy')
# Handle special 'dedupe' parameter
dedupe = strtobool(request.args.get('dedupe', 'true'))
# Handle special 'tag' and 'tag_uuids' parameters
tags = request.args.get('tag')
tag_uuids = request.args.get('tag_uuids')
if tag_uuids:
tag_uuids = tag_uuids.split(',')
# Extract ALL other query parameters as watch configuration
# Get schema from OpenAPI spec (replaces old schema_create_watch)
schema_properties = get_watch_schema_properties()
for param_name, param_value in request.args.items():
# Skip special parameters
if param_name in special_params:
continue
# Skip if not in schema (unknown parameter)
if param_name not in schema_properties:
return f"Unknown watch configuration parameter: {param_name}", 400
# Convert to appropriate type based on schema
try:
converted_value = convert_query_param_to_type(param_value, schema_properties[param_name])
extras[param_name] = converted_value
except (ValueError, json.JSONDecodeError) as e:
return f"Invalid value for parameter '{param_name}': {str(e)}", 400
# Validate processor if provided
if 'processor' in extras:
from changedetectionio.processors import available_processors
available = [p[0] for p in available_processors()]
if extras['processor'] not in available:
return f"Invalid processor '{extras['processor']}'. Available processors: {', '.join(available)}", 400
# Validate fetch_backend if provided
if 'fetch_backend' in extras:
from changedetectionio.content_fetchers import available_fetchers
available = [f[0] for f in available_fetchers()]
# Also allow 'system' and extra_browser_* patterns
is_valid = (
extras['fetch_backend'] == 'system' or
extras['fetch_backend'] in available or
extras['fetch_backend'].startswith('extra_browser_')
)
if not is_valid:
return f"Invalid fetch_backend '{extras['fetch_backend']}'. Available: system, {', '.join(available)}", 400
# Validate notification_urls if provided
if 'notification_urls' in extras:
from wtforms import ValidationError
from changedetectionio.api.Notifications import validate_notification_urls
try:
validate_notification_urls(extras['notification_urls'])
except ValidationError as e:
return f"Invalid notification_urls: {str(e)}", 400
urls = request.get_data().decode('utf8').splitlines()
# Clean and validate URLs upfront
urls_to_import = []
for url in urls:
url = url.strip()
if not len(url):
continue
# Validate URL
if not is_safe_valid_url(url):
return f"Invalid or unsupported URL - {url}", 400
# Check for duplicates if dedupe is enabled
if dedupe and self.datastore.url_exists(url):
continue
urls_to_import.append(url)
# For small imports, process synchronously for immediate feedback
if len(urls_to_import) < IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD:
added = []
for url in urls_to_import:
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)
added.append(new_uuid)
return added, 200
# For large imports (>= 20), process in background thread
else:
import threading
from loguru import logger
def import_watches_background():
"""Background thread to import watches - discarded after completion."""
try:
added_count = 0
for url in urls_to_import:
try:
self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)
added_count += 1
except Exception as e:
logger.error(f"Error importing URL {url}: {e}")
logger.info(f"Background import complete: {added_count} watches created")
except Exception as e:
logger.error(f"Error in background import: {e}")
# Start background thread and return immediately
thread = threading.Thread(target=import_watches_background, daemon=True, name="ImportWatches-Background")
thread.start()
return {'status': f'Importing {len(urls_to_import)} URLs in background', 'count': len(urls_to_import)}, 202
================================================
FILE: changedetectionio/api/Notifications.py
================================================
from flask_restful import Resource, abort
from flask import request
from . import auth, validate_openapi_request
class Notifications(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getNotifications')
def get(self):
"""Return Notification URL List."""
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
return {
'notification_urls': notification_urls,
}, 200
@auth.check_token
@validate_openapi_request('addNotifications')
def post(self):
"""Create Notification URLs."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
from wtforms import ValidationError
try:
validate_notification_urls(notification_urls)
except ValidationError as e:
return str(e), 400
added_urls = []
for url in notification_urls:
clean_url = url.strip()
added_url = self.datastore.add_notification_url(clean_url)
if added_url:
added_urls.append(added_url)
if not added_urls:
return "No valid notification URLs were added", 400
return {'notification_urls': added_urls}, 201
@auth.check_token
@validate_openapi_request('replaceNotifications')
def put(self):
"""Replace Notification URLs."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
from wtforms import ValidationError
try:
validate_notification_urls(notification_urls)
except ValidationError as e:
return str(e), 400
if not isinstance(notification_urls, list):
return "Invalid input format", 400
clean_urls = [url.strip() for url in notification_urls if isinstance(url, str)]
self.datastore.data['settings']['application']['notification_urls'] = clean_urls
self.datastore.commit()
return {'notification_urls': clean_urls}, 200
@auth.check_token
@validate_openapi_request('deleteNotifications')
def delete(self):
"""Delete Notification URLs."""
json_data = request.get_json()
urls_to_delete = json_data.get("notification_urls", [])
if not isinstance(urls_to_delete, list):
abort(400, message="Expected a list of notification URLs.")
notification_urls = self.datastore.data['settings']['application'].get('notification_urls', [])
deleted = []
for url in urls_to_delete:
clean_url = url.strip()
if clean_url in notification_urls:
notification_urls.remove(clean_url)
deleted.append(clean_url)
if not deleted:
abort(400, message="No matching notification URLs found.")
self.datastore.data['settings']['application']['notification_urls'] = notification_urls
self.datastore.commit()
return 'OK', 204
def validate_notification_urls(notification_urls):
from changedetectionio.forms import ValidateAppRiseServers
validator = ValidateAppRiseServers()
class DummyForm: pass
dummy_form = DummyForm()
field = type("Field", (object,), {"data": notification_urls, "gettext": lambda self, x: x})()
validator(dummy_form, field)
================================================
FILE: changedetectionio/api/Search.py
================================================
from flask_restful import Resource, abort
from flask import request
from . import auth, validate_openapi_request
class Search(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('searchWatches')
def get(self):
"""Search for watches by URL or title text."""
query = request.args.get('q', '').strip()
tag_limit = request.args.get('tag', '').strip()
from changedetectionio.strtobool import strtobool
partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False
# Require a search query
if not query:
abort(400, message="Search query 'q' parameter is required")
# Use the search function from the datastore
matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial)
# Build the response with watch details
results = {}
for uuid in matching_uuids:
watch = self.datastore.data['watching'].get(uuid)
results[uuid] = {
'last_changed': watch.last_changed,
'last_checked': watch['last_checked'],
'last_error': watch['last_error'],
'title': watch['title'],
'url': watch['url'],
'viewed': watch.viewed
}
return results, 200
================================================
FILE: changedetectionio/api/Spec.py
================================================
import functools
from flask import make_response
from flask_restful import Resource
@functools.cache
def _get_spec_yaml():
"""Build and cache the merged spec as a YAML string (only serialized once per process)."""
import yaml
from changedetectionio.api import build_merged_spec_dict
return yaml.dump(build_merged_spec_dict(), default_flow_style=False, allow_unicode=True)
class Spec(Resource):
def get(self):
"""Return the merged OpenAPI spec including all registered processor extensions."""
return make_response(
_get_spec_yaml(),
200,
{'Content-Type': 'application/yaml'}
)
================================================
FILE: changedetectionio/api/SystemInfo.py
================================================
from flask_restful import Resource
from . import auth, validate_openapi_request
class SystemInfo(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
self.update_q = kwargs['update_q']
@auth.check_token
@validate_openapi_request('getSystemInfo')
def get(self):
"""Return system info."""
import time
overdue_watches = []
# Check all watches and report which have not been checked but should have been
for uuid, watch in self.datastore.data.get('watching', {}).items():
# see if now - last_checked is greater than the time that should have been
# this is not super accurate (maybe they just edited it) but better than nothing
t = watch.threshold_seconds()
if not t:
# Use the system wide default
t = self.datastore.threshold_seconds
time_since_check = time.time() - watch.get('last_checked')
# Allow 5 minutes of grace time before we decide it's overdue
if time_since_check - (5 * 60) > t:
overdue_watches.append(uuid)
from changedetectionio import __version__ as main_version
return {
'queue_size': self.update_q.qsize(),
'overdue_watches': overdue_watches,
'uptime': round(time.time() - self.datastore.start_time, 2),
'watch_count': len(self.datastore.data.get('watching', {})),
'version': main_version
}, 200
================================================
FILE: changedetectionio/api/Tags.py
================================================
from changedetectionio import queuedWatchMetaData
from changedetectionio import worker_pool
from flask_restful import abort, Resource
from loguru import logger
import threading
from flask import request
from . import auth
from . import validate_openapi_request
class Tag(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
self.update_q = kwargs['update_q']
# Get information about a single tag
# curl http://localhost:5000/api/v1/tag/<uuid_str:uuid>
@auth.check_token
@validate_openapi_request('getTag')
def get(self, uuid):
"""Get data for a single tag/group, toggle notification muting, or recheck all."""
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
if not tag:
abort(404, message=f'No tag exists with the UUID of {uuid}')
if request.args.get('recheck'):
# Recheck all watches with this tag, including muted
# First collect watches to queue
watches_to_queue = []
for k in sorted(self.datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):
watch_uuid = k[0]
watch = k[1]
if not watch['paused'] and tag['uuid'] in watch['tags']:
watches_to_queue.append(watch_uuid)
# If less than 20 watches, queue synchronously for immediate feedback
if len(watches_to_queue) < 20:
for watch_uuid in watches_to_queue:
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
return {'status': f'OK, queued {len(watches_to_queue)} watches for rechecking'}, 200
else:
# 20+ watches - queue in background thread to avoid blocking API response
def queue_watches_background():
"""Background thread to queue watches - discarded after completion."""
try:
for watch_uuid in watches_to_queue:
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
logger.info(f"Background queueing complete for tag {tag['uuid']}: {len(watches_to_queue)} watches queued")
except Exception as e:
logger.error(f"Error in background queueing for tag {tag['uuid']}: {e}")
# Start background thread and return immediately
thread = threading.Thread(target=queue_watches_background, daemon=True, name=f"QueueTag-{tag['uuid'][:8]}")
thread.start()
return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202
if request.args.get('muted', '') == 'muted':
tag['notification_muted'] = True
tag.commit()
return "OK", 200
elif request.args.get('muted', '') == 'unmuted':
tag['notification_muted'] = False
tag.commit()
return "OK", 200
# Filter out Watch-specific runtime fields that don't apply to Tags (yet)
# TODO: Future enhancement - aggregate these values from all Watches that have this tag:
# - check_count: sum of all watches' check_count
# - last_checked: most recent last_checked from all watches
# - last_changed: most recent last_changed from all watches
# - consecutive_filter_failures: count of watches with failures
# - etc.
# These come from watch_base inheritance but currently have no meaningful value for Tags
watch_only_fields = {
'browser_steps_last_error_step', 'check_count', 'consecutive_filter_failures',
'content-type', 'fetch_time', 'last_changed', 'last_checked', 'last_error',
'last_notification_error', 'last_viewed', 'notification_alert_count',
'page_title', 'previous_md5', 'remote_server_reply'
}
# Create clean tag dict without Watch-specific fields
clean_tag = {k: v for k, v in tag.items() if k not in watch_only_fields}
return clean_tag
@auth.check_token
@validate_openapi_request('deleteTag')
def delete(self, uuid):
"""Delete a tag/group and remove it from all watches."""
if not self.datastore.data['settings']['application']['tags'].get(uuid):
abort(400, message='No tag exists with the UUID of {}'.format(uuid))
# Delete the tag, and any tag reference
del self.datastore.data['settings']['application']['tags'][uuid]
# Remove tag from all watches
for watch_uuid, watch in self.datastore.data['watching'].items():
if watch.get('tags') and uuid in watch['tags']:
watch['tags'].remove(uuid)
watch.commit()
return 'OK', 204
@auth.check_token
@validate_openapi_request('updateTag')
def put(self, uuid):
"""Update tag information."""
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
if not tag:
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
# Make a mutable copy of request.json for modification
json_data = dict(request.json)
# Validate notification_urls if provided
if 'notification_urls' in json_data:
from wtforms import ValidationError
from changedetectionio.api.Notifications import validate_notification_urls
try:
notification_urls = json_data.get('notification_urls', [])
validate_notification_urls(notification_urls)
except ValidationError as e:
return str(e), 400
# Filter out readOnly fields (extracted from OpenAPI spec Tag schema)
# These are system-managed fields that should never be user-settable
from . import get_readonly_tag_fields
readonly_fields = get_readonly_tag_fields()
# Tag model inherits from watch_base but has no @property attributes of its own
# So we only need to filter readOnly fields
for field in readonly_fields:
json_data.pop(field, None)
# Validate remaining fields - reject truly unknown fields
# Get valid fields from Tag schema
from . import get_tag_schema_properties
valid_fields = set(get_tag_schema_properties().keys())
# Check for unknown fields
unknown_fields = set(json_data.keys()) - valid_fields
if unknown_fields:
return f"Unknown field(s): {', '.join(sorted(unknown_fields))}", 400
tag.update(json_data)
tag.commit()
# Clear checksums for all watches using this tag to force reprocessing
# Tag changes affect inherited configuration
cleared_count = self.datastore.clear_checksums_for_tag(uuid)
logger.info(f"Tag {uuid} updated via API, cleared {cleared_count} watch checksums")
return "OK", 200
@auth.check_token
@validate_openapi_request('createTag')
def post(self):
"""Create a single tag/group."""
json_data = request.get_json()
title = json_data.get("title",'').strip()
# Validate that only valid fields are provided
# Get valid fields from Tag schema
from . import get_tag_schema_properties
valid_fields = set(get_tag_schema_properties().keys())
# Check for unknown fields
unknown_fields = set(json_data.keys()) - valid_fields
if unknown_fields:
return f"Unknown field(s): {', '.join(sorted(unknown_fields))}", 400
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
class Tags(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('listTags')
def get(self):
"""List tags/groups."""
result = {}
for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
result[uuid] = {
'date_created': tag.get('date_created', 0),
'notification_muted': tag.get('notification_muted', False),
'title': tag.get('title', ''),
'uuid': tag.get('uuid')
}
return result, 200
================================================
FILE: changedetectionio/api/Watch.py
================================================
import os
import threading
from changedetectionio.validate_url import is_safe_valid_url
from changedetectionio.favicon_utils import get_favicon_mime_type
from . import auth
from changedetectionio import queuedWatchMetaData, strtobool
from changedetectionio import worker_pool
from flask import request, make_response, send_from_directory
from flask_restful import abort, Resource
from loguru import logger
import copy
from . import validate_openapi_request, get_readonly_watch_fields
from ..notification import valid_notification_formats
from ..notification.handler import newline_re
def validate_time_between_check_required(json_data):
"""
Validate that at least one time interval is specified when not using default settings.
Returns None if valid, or error message string if invalid.
Defaults to using global settings if time_between_check_use_default is not provided.
"""
# Default to using global settings if not specified
use_default = json_data.get('time_between_check_use_default', True)
# If using default settings, no validation needed
if use_default:
return None
# If not using defaults, check if time_between_check exists and has at least one non-zero value
time_check = json_data.get('time_between_check')
if not time_check:
# No time_between_check provided and not using defaults - this is an error
return "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
# time_between_check exists, check if it has at least one non-zero value
if any([
(time_check.get('weeks') or 0) > 0,
(time_check.get('days') or 0) > 0,
(time_check.get('hours') or 0) > 0,
(time_check.get('minutes') or 0) > 0,
(time_check.get('seconds') or 0) > 0
]):
return None
# time_between_check exists but all values are 0 or empty - this is an error
return "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
class Watch(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
self.update_q = kwargs['update_q']
# Get information about a single watch, excluding the history list (can be large)
# curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>
# @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK"
# ?recheck=true
@auth.check_token
@validate_openapi_request('getWatch')
def get(self, uuid):
"""Get information about a single watch, recheck, pause, or mute."""
# Get watch reference first (for pause/mute operations)
watch_obj = self.datastore.data['watching'].get(uuid)
if not watch_obj:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
# Create a dict copy for JSON response (with lock for thread safety)
# This is much faster than deepcopy and doesn't copy the datastore reference
# WARNING: dict() is a SHALLOW copy - nested dicts are shared with original!
# Only safe because we only ADD scalar properties (line 97-101), never modify nested dicts
# If you need to modify nested dicts, use: from copy import deepcopy; watch = deepcopy(dict(watch_obj))
with self.datastore.lock:
watch = dict(watch_obj)
if request.args.get('recheck'):
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
return "OK", 200
if request.args.get('paused', '') == 'paused':
watch_obj.pause()
watch_obj.commit()
return "OK", 200
elif request.args.get('paused', '') == 'unpaused':
watch_obj.unpause()
watch_obj.commit()
return "OK", 200
if request.args.get('muted', '') == 'muted':
watch_obj.mute()
watch_obj.commit()
return "OK", 200
elif request.args.get('muted', '') == 'unmuted':
watch_obj.unmute()
watch_obj.commit()
return "OK", 200
# Return without history, get that via another API call
# Properties are not returned as a JSON, so add the required props manually
watch['history_n'] = watch_obj.history_n
# attr .last_changed will check for the last written text snapshot on change
watch['last_changed'] = watch_obj.last_changed
watch['viewed'] = watch_obj.viewed
watch['link'] = watch_obj.link,
return watch
@auth.check_token
@validate_openapi_request('deleteWatch')
def delete(self, uuid):
"""Delete a watch and related history."""
if not self.datastore.data['watching'].get(uuid):
abort(400, message='No watch exists with the UUID of {}'.format(uuid))
self.datastore.delete(uuid)
return 'OK', 204
@auth.check_token
@validate_openapi_request('updateWatch')
def put(self, uuid):
"""Update watch information."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
if request.json.get('proxy'):
plist = self.datastore.proxy_list
if not plist or request.json.get('proxy') not in plist:
proxy_list_str = ', '.join(plist) if plist else 'none configured'
return f"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'", 400
# Validate time_between_check when not using defaults
validation_error = validate_time_between_check_required(request.json)
if validation_error:
return validation_error, 400
# Validate notification_urls if provided
if 'notification_urls' in request.json:
from wtforms import ValidationError
from changedetectionio.api.Notifications import validate_notification_urls
try:
notification_urls = request.json.get('notification_urls', [])
validate_notification_urls(notification_urls)
except ValidationError as e:
return str(e), 400
# XSS etc protection - validate URL if it's being updated
if 'url' in request.json:
new_url = request.json.get('url')
# URL must be a non-empty string
if new_url is None:
return "URL cannot be null", 400
if not isinstance(new_url, str):
return "URL must be a string", 400
if not new_url.strip():
return "URL cannot be empty or whitespace only", 400
if not is_safe_valid_url(new_url.strip()):
return "Invalid or unsupported URL format. URL must use http://, https://, or ftp:// protocol", 400
# Handle processor-config-* fields separately (save to JSON, not datastore)
from changedetectionio import processors
# Make a mutable copy of request.json for modification
json_data = dict(request.json)
# Extract and remove processor config fields from json_data
processor_config_data = processors.extract_processor_config_from_form_data(json_data)
# Filter out readOnly fields (extracted from OpenAPI spec Watch schema)
# These are system-managed fields that should never be user-settable
readonly_fields = get_readonly_watch_fields()
# Also filter out @property attributes (computed/derived values from the model)
# These are not stored and should be ignored in PUT requests
from changedetectionio.model.Watch import model as WatchModel
property_fields = WatchModel.get_property_names()
# Combine both sets of fields to ignore
fields_to_ignore = readonly_fields | property_fields
# Remove all ignored fields from update data
for field in fields_to_ignore:
json_data.pop(field, None)
# Validate remaining fields - reject truly unknown fields
# Get valid fields from WatchBase schema
from . import get_watch_schema_properties
valid_fields = set(get_watch_schema_properties().keys())
# Also allow last_viewed (explicitly defined in UpdateWatch schema)
valid_fields.add('last_viewed')
# Check for unknown fields
unknown_fields = set(json_data.keys()) - valid_fields
if unknown_fields:
return f"Unknown field(s): {', '.join(sorted(unknown_fields))}", 400
# Update watch with regular (non-processor-config) fields
watch.update(json_data)
watch.commit()
# Save processor config to JSON file
processors.save_processor_config(self.datastore, uuid, processor_config_data)
return "OK", 200
class WatchHistory(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
# Get a list of available history for a watch by UUID
# curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>/history
@auth.check_token
@validate_openapi_request('getWatchHistory')
def get(self, uuid):
"""Get a list of all historical snapshots available for a watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
return watch.history, 200
class WatchSingleHistory(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getWatchSnapshot')
def get(self, uuid, timestamp):
"""Get single snapshot from watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
if not len(watch.history):
abort(404, message=f"Watch found but no history exists for the UUID {uuid}")
if timestamp == 'latest':
timestamp = list(watch.history.keys())[-1]
# Validate that the timestamp exists in history
if timestamp not in watch.history:
abort(404, message=f"No history snapshot found for timestamp '{timestamp}'")
if request.args.get('html'):
content = watch.get_fetched_html(timestamp)
if content:
response = make_response(content, 200)
response.mimetype = "text/html"
else:
response = make_response("No content found", 404)
response.mimetype = "text/plain"
else:
content = watch.get_history_snapshot(timestamp=timestamp)
response = make_response(content, 200)
response.mimetype = "text/plain"
return response
class WatchHistoryDiff(Resource):
"""
Generate diff between two historical snapshots.
Note: This API endpoint currently returns text-based diffs and works best
with the text_json_diff processor. Future processor types (like image_diff,
restock_diff) may want to implement their own specialized API endpoints
for returning processor-specific data (e.g., price charts, image comparisons).
The web UI diff page (/diff/<uuid>) is processor-aware and delegates rendering
to processors/{type}/difference.py::render() for processor-specific visualizations.
"""
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getWatchHistoryDiff')
def get(self, uuid, from_timestamp, to_timestamp):
"""Generate diff between two historical snapshots."""
from changedetectionio import diff
from changedetectionio.notification.handler import apply_service_tweaks
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
if not len(watch.history):
abort(404, message=f"Watch found but no history exists for the UUID {uuid}")
history_keys = list(watch.history.keys())
# Handle 'latest' keyword for to_timestamp
if to_timestamp == 'latest':
to_timestamp = history_keys[-1]
# Handle 'previous' keyword for from_timestamp (second-most-recent)
if from_timestamp == 'previous':
if len(history_keys) < 2:
abort(404, message=f"Not enough history entries. Need at least 2 snapshots for 'previous'")
from_timestamp = history_keys[-2]
# Validate timestamps exist
if from_timestamp not in watch.history:
abort(404, message=f"From timestamp {from_timestamp} not found in watch history")
if to_timestamp not in watch.history:
abort(404, message=f"To timestamp {to_timestamp} not found in watch history")
# Get the format parameter (default to 'text')
output_format = request.args.get('format', 'text').lower()
# Validate format
if output_format not in valid_notification_formats.keys():
abort(400, message=f"Invalid format. Must be one of: {', '.join(valid_notification_formats.keys())}")
# Get the word_diff parameter (default to False - line-level mode)
word_diff = strtobool(request.args.get('word_diff', 'false'))
# Get the no_markup parameter (default to False)
no_markup = strtobool(request.args.get('no_markup', 'false'))
# Retrieve snapshot contents
from_version_file_contents = watch.get_history_snapshot(from_timestamp)
to_version_file_contents = watch.get_history_snapshot(to_timestamp)
# Get diff preferences from query parameters (matching UI preferences in DIFF_PREFERENCES_CONFIG)
# Support both 'type' (UI parameter) and 'word_diff' (API parameter) for backward compatibility
diff_type = request.args.get('type', 'diffLines')
if diff_type == 'diffWords':
word_diff = True
# Get boolean diff preferences with defaults from DIFF_PREFERENCES_CONFIG
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'))
include_replaced = strtobool(request.args.get('replaced', 'true'))
# Generate the diff with all preferences
content = diff.render_diff(
previous_version_file_contents=from_version_file_contents,
newest_version_file_contents=to_version_file_contents,
ignore_junk=ignore_whitespace,
include_equal=not changes_only,
include_removed=include_removed,
include_added=include_added,
include_replaced=include_replaced,
word_diff=word_diff,
)
# Skip formatting if no_markup is set
if no_markup:
mimetype = "text/plain"
else:
# Apply formatting based on the requested format
if output_format == 'htmlcolor':
from changedetectionio.notification.handler import apply_html_color_to_body
content = apply_html_color_to_body(n_body=content)
mimetype = "text/html"
else:
# Apply service tweaks for text/html formats
# Pass empty URL and title as they're not used for the placeholder replacement we need
_, content, _ = apply_service_tweaks(
url='',
n_body=content,
n_title='',
requested_output_format=output_format
)
mimetype = "text/html" if output_format == 'html' else "text/plain"
if 'html' in output_format:
content = newline_re.sub('<br>\r\n', content)
response = make_response(content, 200)
response.mimetype = mimetype
return response
class WatchFavicon(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getWatchFavicon')
def get(self, uuid):
"""Get favicon for a watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
favicon_filename = watch.get_favicon_filename()
if favicon_filename:
# Use cached MIME type detection
filepath = os.path.join(watch.data_dir, favicon_filename)
mime = get_favicon_mime_type(filepath)
response = make_response(send_from_directory(watch.data_dir, favicon_filename))
response.headers['Content-type'] = mime
response.headers['Cache-Control'] = 'max-age=300, must-revalidate' # Cache for 5 minutes, then revalidate
return response
abort(404, message=f'No Favicon available for {uuid}')
class CreateWatch(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
self.update_q = kwargs['update_q']
@auth.check_token
@validate_openapi_request('createWatch')
def post(self):
"""Create a single watch."""
json_data = request.get_json()
url = json_data['url'].strip()
if not is_safe_valid_url(url):
return "Invalid or unsupported URL", 400
if json_data.get('proxy'):
plist = self.datastore.proxy_list
if not plist or json_data.get('proxy') not in plist:
proxy_list_str = ', '.join(plist) if plist else 'none configured'
return f"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'", 400
# Validate time_between_check when not using defaults
validation_error = validate_time_between_check_required(json_data)
if validation_error:
return validation_error, 400
# Validate notification_urls if provided
if 'notification_urls' in json_data:
from wtforms import ValidationError
from changedetectionio.api.Notifications import validate_notification_urls
try:
notification_urls = json_data.get('notification_urls', [])
validate_notification_urls(notification_urls)
except ValidationError as e:
return str(e), 400
# Handle processor-config-* fields separately (save to JSON, not watch)
from changedetectionio import processors
extras = copy.deepcopy(json_data)
# Extract and remove processor config fields from extras
processor_config_data = processors.extract_processor_config_from_form_data(extras)
# Because we renamed 'tag' to 'tags' but don't want to change the API (can do this in v2 of the API)
tags = None
if extras.get('tag'):
tags = extras.get('tag')
del extras['tag']
del extras['url']
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
# Save processor config to separate JSON file
if new_uuid and processor_config_data:
processors.save_processor_config(self.datastore, new_uuid, processor_config_data)
if new_uuid:
# Dont queue because the scheduler will check that it hasnt been checked before anyway
# worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
return {'uuid': new_uuid}, 201
else:
# Check if it was a limit issue
page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')
if page_watch_limit:
try:
page_watch_limit = int(page_watch_limit)
current_watch_count = len(self.datastore.data['watching'])
if current_watch_count >= page_watch_limit:
return f"Watch limit reached ({current_watch_count}/{page_watch_limit} watches). Cannot add more watches.", 429
except ValueError:
pass
return "Invalid or unsupported URL", 400
@auth.check_token
@validate_openapi_request('listWatches')
def get(self):
"""List watches."""
list = {}
tag_limit = request.args.get('tag', '').lower()
for uuid, watch in self.datastore.data['watching'].items():
# Watch tags by name (replace the other calls?)
tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
if tag_limit and not any(v.get('title').lower() == tag_limit for k, v in tags.items()):
continue
list[uuid] = {
'last_changed': watch.last_changed,
'last_checked': watch['last_checked'],
'last_error': watch['last_error'],
'link': watch.link,
'page_title': watch['page_title'],
'tags': [*tags], # Unpack dict keys to list (can't use list() since variable named 'list')
'title': watch['title'],
'url': watch['url'],
'viewed': watch.viewed
}
if request.args.get('recheck_all'):
# Collect all watches to queue
watches_to_queue = self.datastore.data['watching'].keys()
# If less than 20 watches, queue synchronously for immediate feedback
if len(watches_to_queue) < 20:
# Get already queued/running UUIDs once (efficient)
queued_uuids = set(self.update_q.get_queued_uuids())
running_uuids = set(worker_pool.get_running_uuids())
# Filter out watches that are already queued or running
watches_to_queue_filtered = [
uuid for uuid in watches_to_queue
if uuid not in queued_uuids and uuid not in running_uuids
]
# Queue only the filtered watches
for uuid in watches_to_queue_filtered:
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
# Provide feedback about skipped watches
skipped_count = len(watches_to_queue) - len(watches_to_queue_filtered)
if skipped_count > 0:
return {'status': f'OK, queued {len(watches_to_queue_filtered)} watches for rechecking ({skipped_count} already queued or running)'}, 200
else:
return {'status': f'OK, queued {len(watches_to_queue_filtered)} watches for rechecking'}, 200
else:
# 20+ watches - queue in background thread to avoid blocking API response
# Capture queued/running state before background thread
queued_uuids = set(self.update_q.get_queued_uuids())
running_uuids = set(worker_pool.get_running_uuids())
def queue_all_watches_background():
"""Background thread to queue all watches - discarded after completion."""
try:
queued_count = 0
skipped_count = 0
for uuid in watches_to_queue:
# Check if already queued or running (state captured at start)
if uuid not in queued_uuids and uuid not in running_uuids:
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
queued_count += 1
else:
skipped_count += 1
logger.info(f"Background queueing complete: {queued_count} watches queued, {skipped_count} skipped (already queued/running)")
except Exception as e:
logger.error(f"Error in background queueing all watches: {e}")
# Start background thread and return immediately
thread = threading.Thread(target=queue_all_watches_background, daemon=True, name="QueueAllWatches-Background")
thread.start()
return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202
return list, 200
================================================
FILE: changedetectionio/api/__init__.py
================================================
import functools
from flask import request, abort
from loguru import logger
@functools.cache
def build_merged_spec_dict():
"""
Load the base OpenAPI spec and merge in any per-processor api.yaml extensions.
Each processor can provide an api.yaml file alongside its __init__.py that defines
additional schemas (e.g., processor_config_restock_diff). These are merged into
WatchBase.properties so the spec accurately reflects what the API accepts.
Plugin processors (via pluggy) are also supported - they just need an api.yaml
next to their processor module.
Returns the merged dict (cached - do not mutate the returned value).
"""
import os
import yaml
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
if not os.path.exists(spec_path):
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
with open(spec_path, 'r', encoding='utf-8') as f:
spec_dict = yaml.safe_load(f)
try:
from changedetectionio.processors import find_processors, get_parent_module
for module, proc_name in find_processors():
parent = get_parent_module(module)
if not parent or not hasatt
gitextract_1122jxp9/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── actions/ │ │ └── extract-memory-report/ │ │ └── action.yml │ ├── dependabot.yml │ ├── nginx-reverse-proxy-test.conf │ ├── test/ │ │ └── Dockerfile-alpine │ └── workflows/ │ ├── codeql-analysis.yml │ ├── containers.yml │ ├── pypi-release.yml │ ├── test-container-build.yml │ ├── test-only.yml │ └── test-stack-reusable-workflow.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .ruff.toml ├── COMMERCIAL_LICENCE.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README-pip.md ├── README.md ├── babel.cfg ├── changedetection.py ├── changedetectionio/ │ ├── .gitignore │ ├── PLUGIN_README.md │ ├── __init__.py │ ├── api/ │ │ ├── Import.py │ │ ├── Notifications.py │ │ ├── Search.py │ │ ├── Spec.py │ │ ├── SystemInfo.py │ │ ├── Tags.py │ │ ├── Watch.py │ │ ├── __init__.py │ │ └── auth.py │ ├── auth_decorator.py │ ├── blueprint/ │ │ ├── __init__.py │ │ ├── backups/ │ │ │ ├── __init__.py │ │ │ ├── restore.py │ │ │ └── templates/ │ │ │ ├── backup_create.html │ │ │ └── backup_restore.html │ │ ├── browser_steps/ │ │ │ ├── TODO.txt │ │ │ └── __init__.py │ │ ├── check_proxies/ │ │ │ └── __init__.py │ │ ├── imports/ │ │ │ ├── __init__.py │ │ │ ├── importer.py │ │ │ └── templates/ │ │ │ └── import.html │ │ ├── price_data_follower/ │ │ │ └── __init__.py │ │ ├── rss/ │ │ │ ├── __init__.py │ │ │ ├── _util.py │ │ │ ├── blueprint.py │ │ │ ├── main_feed.py │ │ │ ├── single_watch.py │ │ │ └── tag.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ └── templates/ │ │ │ ├── notification-log.html │ │ │ └── settings.html │ │ ├── tags/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── form.py │ │ │ └── templates/ │ │ │ ├── edit-tag.html │ │ │ └── groups-overview.html │ │ ├── ui/ │ │ │ ├── __init__.py │ │ │ ├── diff.py │ │ │ ├── edit.py │ │ │ ├── notification.py │ │ │ ├── preview.py │ │ │ ├── templates/ │ │ │ │ ├── clear_all_history.html │ │ │ │ ├── diff-offscreen-options.html │ │ │ │ ├── diff.html │ │ │ │ ├── edit.html │ │ │ │ └── preview.html │ │ │ └── views.py │ │ └── watchlist/ │ │ ├── __init__.py │ │ └── templates/ │ │ └── watch-overview.html │ ├── browser_steps/ │ │ ├── __init__.py │ │ └── browser_steps.py │ ├── conditions/ │ │ ├── __init__.py │ │ ├── blueprint.py │ │ ├── default_plugin.py │ │ ├── exceptions.py │ │ ├── form.py │ │ ├── pluggy_interface.py │ │ └── plugins/ │ │ ├── __init__.py │ │ ├── levenshtein_plugin.py │ │ └── wordcount_plugin.py │ ├── content_fetchers/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── exceptions/ │ │ │ └── __init__.py │ │ ├── playwright.py │ │ ├── puppeteer.py │ │ ├── requests.py │ │ ├── res/ │ │ │ ├── __init__.py │ │ │ ├── favicon-fetcher.js │ │ │ ├── lock-elements-sizing.js │ │ │ ├── stock-not-in-stock.js │ │ │ ├── unlock-elements-sizing.js │ │ │ └── xpath_element_scraper.js │ │ ├── screenshot_handler.py │ │ └── webdriver_selenium.py │ ├── custom_queue.py │ ├── diff/ │ │ ├── __init__.py │ │ └── tokenizers/ │ │ ├── __init__.py │ │ ├── natural_text.py │ │ └── words_and_html.py │ ├── favicon_utils.py │ ├── flask_app.py │ ├── forms.py │ ├── gc_cleanup.py │ ├── html_tools.py │ ├── is_safe_url.py │ ├── jinja2_custom/ │ │ ├── __init__.py │ │ ├── extensions/ │ │ │ ├── TimeExtension.py │ │ │ └── __init__.py │ │ ├── plugins/ │ │ │ ├── __init__.py │ │ │ └── regex.py │ │ └── safe_jinja.py │ ├── languages.py │ ├── model/ │ │ ├── App.py │ │ ├── Tag.py │ │ ├── Tags.py │ │ ├── Watch.py │ │ ├── __init__.py │ │ ├── persistence.py │ │ └── schema_utils.py │ ├── notification/ │ │ ├── __init__.py │ │ ├── apprise_plugin/ │ │ │ ├── __init__.py │ │ │ ├── assets.py │ │ │ ├── custom_handlers.py │ │ │ └── discord.py │ │ ├── email_helpers.py │ │ └── handler.py │ ├── notification_service.py │ ├── pluggy_interface.py │ ├── processors/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── base.py │ │ ├── exceptions.py │ │ ├── extract.py │ │ ├── image_ssim_diff/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── difference.py │ │ │ ├── edit_hook.py │ │ │ ├── forms.py │ │ │ ├── image_handler/ │ │ │ │ ├── __init__.py │ │ │ │ ├── isolated_libvips.py │ │ │ │ ├── isolated_opencv.py │ │ │ │ └── libvips_handler.py │ │ │ ├── preview.py │ │ │ ├── processor.py │ │ │ ├── templates/ │ │ │ │ └── image_ssim_diff/ │ │ │ │ ├── diff.html │ │ │ │ └── preview.html │ │ │ └── util.py │ │ ├── magic.py │ │ ├── restock_diff/ │ │ │ ├── __init__.py │ │ │ ├── api.yaml │ │ │ ├── forms.py │ │ │ ├── processor.py │ │ │ └── pure_python_extractor.py │ │ ├── templates/ │ │ │ └── extract.html │ │ └── text_json_diff/ │ │ ├── __init__.py │ │ ├── difference.py │ │ └── processor.py │ ├── pytest.ini │ ├── queue_handlers.py │ ├── queuedWatchMetaData.py │ ├── realtime/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── events.py │ │ └── socket_server.py │ ├── rss_tools.py │ ├── run_basic_tests.sh │ ├── run_custom_browser_url_tests.sh │ ├── run_proxy_tests.sh │ ├── run_socks_proxy_tests.sh │ ├── static/ │ │ ├── favicons/ │ │ │ ├── browserconfig.xml │ │ │ └── site.webmanifest │ │ ├── js/ │ │ │ ├── browser-steps.js │ │ │ ├── comparison-slider.js │ │ │ ├── conditions.js │ │ │ ├── csrf.js │ │ │ ├── diff-overview.js │ │ │ ├── diff-render.js │ │ │ ├── flask-toast-bridge.js │ │ │ ├── global-settings.js │ │ │ ├── hamburger-menu.js │ │ │ ├── language-selector.js │ │ │ ├── modal.js │ │ │ ├── notifications.js │ │ │ ├── plugins.js │ │ │ ├── preview.js │ │ │ ├── realtime.js │ │ │ ├── recheck-proxy.js │ │ │ ├── scheduler.js │ │ │ ├── search-modal.js │ │ │ ├── snippet-to-image.js │ │ │ ├── stepper.js │ │ │ ├── tabs.js │ │ │ ├── toast.js │ │ │ ├── toggle-theme.js │ │ │ ├── vis.js │ │ │ ├── visual-selector.js │ │ │ ├── watch-overview.js │ │ │ └── watch-settings.js │ │ └── styles/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── diff-image.css │ │ ├── diff.css │ │ ├── package.json │ │ ├── pure-min.css │ │ ├── scss/ │ │ │ ├── _settings.scss │ │ │ ├── diff-image.scss │ │ │ ├── diff.scss │ │ │ ├── parts/ │ │ │ │ ├── _action_sidebar.scss │ │ │ │ ├── _arrows.scss │ │ │ │ ├── _browser-steps.scss │ │ │ │ ├── _conditions_table.scss │ │ │ │ ├── _darkmode.scss │ │ │ │ ├── _diff_image.scss │ │ │ │ ├── _edit.scss │ │ │ │ ├── _extra_browsers.scss │ │ │ │ ├── _extra_proxies.scss │ │ │ │ ├── _hamburger_menu.scss │ │ │ │ ├── _language.scss │ │ │ │ ├── _lister_extra.scss │ │ │ │ ├── _login_form.scss │ │ │ │ ├── _love.scss │ │ │ │ ├── _menu.scss │ │ │ │ ├── _minitabs.scss │ │ │ │ ├── _modal.scss │ │ │ │ ├── _notification_bubble.scss │ │ │ │ ├── _pagination.scss │ │ │ │ ├── _preview_text_filter.scss │ │ │ │ ├── _search_modal.scss │ │ │ │ ├── _socket.scss │ │ │ │ ├── _spinners.scss │ │ │ │ ├── _tabs.scss │ │ │ │ ├── _toast.scss │ │ │ │ ├── _variables.scss │ │ │ │ ├── _visualselector.scss │ │ │ │ ├── _watch_table-mobile.scss │ │ │ │ ├── _watch_table.scss │ │ │ │ └── _widgets.scss │ │ │ └── styles.scss │ │ └── styles.css │ ├── store/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── file_saving_datastore.py │ │ └── updates.py │ ├── strtobool.py │ ├── templates/ │ │ ├── IMPORTANT.md │ │ ├── _common_fields.html │ │ ├── _helpers.html │ │ ├── base.html │ │ ├── edit/ │ │ │ ├── include_subtract.html │ │ │ └── text-options.html │ │ ├── login.html │ │ └── menu.html │ ├── test_cli_opts.sh │ ├── tests/ │ │ ├── __init__.py │ │ ├── apprise/ │ │ │ ├── test_apprise_asset.py │ │ │ └── test_apprise_custom_api_call.py │ │ ├── conftest.py │ │ ├── custom_browser_url/ │ │ │ ├── __init__.py │ │ │ └── test_custom_browser_url.py │ │ ├── fetchers/ │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── test_content.py │ │ │ └── test_custom_js_before_content.py │ │ ├── import/ │ │ │ └── spreadsheet.xlsx │ │ ├── itemprop_test_examples/ │ │ │ ├── README.md │ │ │ └── a.txt │ │ ├── plugins/ │ │ │ └── test_processor.py │ │ ├── proxy_list/ │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── proxies.json-example │ │ │ ├── squid-auth.conf │ │ │ ├── squid-passwords.txt │ │ │ ├── squid.conf │ │ │ ├── test_multiple_proxy.py │ │ │ ├── test_noproxy.py │ │ │ ├── test_proxy.py │ │ │ ├── test_proxy_noconnect.py │ │ │ └── test_select_custom_proxy.py │ │ ├── proxy_socks5/ │ │ │ ├── proxies.json-example │ │ │ ├── proxies.json-example-noauth │ │ │ ├── test_socks5_proxy.py │ │ │ └── test_socks5_proxy_sources.py │ │ ├── restock/ │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ └── test_restock.py │ │ ├── smtp/ │ │ │ ├── smtp-test-server.py │ │ │ └── test_notification_smtp.py │ │ ├── test_access_control.py │ │ ├── test_add_replace_remove_filter.py │ │ ├── test_api.py │ │ ├── test_api_notification_urls_validation.py │ │ ├── test_api_notifications.py │ │ ├── test_api_openapi.py │ │ ├── test_api_search.py │ │ ├── test_api_security.py │ │ ├── test_api_tags.py │ │ ├── test_auth.py │ │ ├── test_automatic_follow_ldjson_price.py │ │ ├── test_backend.py │ │ ├── test_backup.py │ │ ├── test_basic_socketio.py │ │ ├── test_block_while_text_present.py │ │ ├── test_clone.py │ │ ├── test_commit_persistence.py │ │ ├── test_conditions.py │ │ ├── test_css_selector.py │ │ ├── test_datastore_isolation.py │ │ ├── test_element_removal.py │ │ ├── test_encoding.py │ │ ├── test_errorhandling.py │ │ ├── test_extract_csv.py │ │ ├── test_extract_regex.py │ │ ├── test_filter_exist_changes.py │ │ ├── test_filter_failure_notification.py │ │ ├── test_group.py │ │ ├── test_history_consistency.py │ │ ├── test_html_to_text.py │ │ ├── test_i18n.py │ │ ├── test_ignore.py │ │ ├── test_ignore_regex_text.py │ │ ├── test_ignore_text.py │ │ ├── test_ignorehyperlinks.py │ │ ├── test_ignorestatuscode.py │ │ ├── test_ignorewhitespace.py │ │ ├── test_import.py │ │ ├── test_jinja2.py │ │ ├── test_jsonpath_jq_selector.py │ │ ├── test_live_preview.py │ │ ├── test_nonrenderable_pages.py │ │ ├── test_notification.py │ │ ├── test_notification_errors.py │ │ ├── test_obfuscations.py │ │ ├── test_pdf.py │ │ ├── test_preview_endpoints.py │ │ ├── test_queue_handler.py │ │ ├── test_request.py │ │ ├── test_restock_itemprop.py │ │ ├── test_rss.py │ │ ├── test_rss_group.py │ │ ├── test_rss_reader_mode.py │ │ ├── test_rss_single_watch.py │ │ ├── test_scheduler.py │ │ ├── test_search.py │ │ ├── test_security.py │ │ ├── test_settings_tag_force_reprocess.py │ │ ├── test_share_watch.py │ │ ├── test_source.py │ │ ├── test_trigger.py │ │ ├── test_trigger_regex.py │ │ ├── test_trigger_regex_with_filter.py │ │ ├── test_ui.py │ │ ├── test_unique_lines.py │ │ ├── test_watch_edited_flag.py │ │ ├── test_watch_fields_storage.py │ │ ├── test_xpath_default_namespace.py │ │ ├── test_xpath_selector.py │ │ ├── test_xpath_selector_unit.py │ │ ├── unit/ │ │ │ ├── __init__.py │ │ │ ├── test-content/ │ │ │ │ ├── README.md │ │ │ │ ├── after-2.txt │ │ │ │ ├── after.txt │ │ │ │ └── before.txt │ │ │ ├── test_conditions.py │ │ │ ├── test_html_to_text.py │ │ │ ├── test_jinja2_security.py │ │ │ ├── test_notification_diff.py │ │ │ ├── test_restock_logic.py │ │ │ ├── test_scheduler.py │ │ │ ├── test_semver.py │ │ │ ├── test_time_extension.py │ │ │ ├── test_time_handler.py │ │ │ └── test_watch_model.py │ │ ├── util.py │ │ └── visualselector/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_fetch_data.py │ ├── time_handler.py │ ├── translations/ │ │ ├── README.md │ │ ├── cs/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── de/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── en_GB/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── en_US/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── es/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── fr/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── it/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── ko/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── messages.pot │ │ ├── uk/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ ├── zh/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── messages.mo │ │ │ └── messages.po │ │ └── zh_Hant_TW/ │ │ └── LC_MESSAGES/ │ │ ├── messages.mo │ │ └── messages.po │ ├── validate_url.py │ ├── widgets/ │ │ ├── __init__.py │ │ ├── ternary_boolean.py │ │ └── test_custom_text.py │ ├── worker.py │ └── worker_pool.py ├── docker-compose.yml ├── docker-entrypoint.sh ├── docs/ │ ├── .gitignore │ ├── README.md │ ├── api-spec.yaml │ ├── api_v1/ │ │ └── index.html │ └── package.json ├── requirements.txt ├── runtime.txt ├── setup.cfg └── setup.py
SYMBOL INDEX (1666 symbols across 237 files)
FILE: changedetectionio/__init__.py
function get_version (line 94) | def get_version():
function sigshutdown_handler (line 98) | def sigshutdown_handler(_signo, _stack_frame):
function print_help (line 146) | def print_help():
function main (line 181) | def main():
FILE: changedetectionio/api/Import.py
function default_content_type (line 13) | def default_content_type(content_type='text/plain'):
function convert_query_param_to_type (line 26) | def convert_query_param_to_type(value, schema_property):
class Import (line 96) | class Import(Resource):
method __init__ (line 97) | def __init__(self, **kwargs):
method post (line 104) | def post(self):
FILE: changedetectionio/api/Notifications.py
class Notifications (line 5) | class Notifications(Resource):
method __init__ (line 6) | def __init__(self, **kwargs):
method get (line 12) | def get(self):
method post (line 23) | def post(self):
method put (line 50) | def put(self):
method delete (line 72) | def delete(self):
function validate_notification_urls (line 97) | def validate_notification_urls(notification_urls):
FILE: changedetectionio/api/Search.py
class Search (line 5) | class Search(Resource):
method __init__ (line 6) | def __init__(self, **kwargs):
method get (line 12) | def get(self):
FILE: changedetectionio/api/Spec.py
function _get_spec_yaml (line 7) | def _get_spec_yaml():
class Spec (line 14) | class Spec(Resource):
method get (line 15) | def get(self):
FILE: changedetectionio/api/SystemInfo.py
class SystemInfo (line 5) | class SystemInfo(Resource):
method __init__ (line 6) | def __init__(self, **kwargs):
method get (line 13) | def get(self):
FILE: changedetectionio/api/Tags.py
class Tag (line 13) | class Tag(Resource):
method __init__ (line 14) | def __init__(self, **kwargs):
method get (line 23) | def get(self, uuid):
method delete (line 92) | def delete(self, uuid):
method put (line 110) | def put(self, uuid):
method post (line 162) | def post(self):
class Tags (line 191) | class Tags(Resource):
method __init__ (line 192) | def __init__(self, **kwargs):
method get (line 198) | def get(self):
FILE: changedetectionio/api/Watch.py
function validate_time_between_check_required (line 20) | def validate_time_between_check_required(json_data):
class Watch (line 53) | class Watch(Resource):
method __init__ (line 54) | def __init__(self, **kwargs):
method get (line 65) | def get(self, uuid):
method delete (line 112) | def delete(self, uuid):
method put (line 122) | def put(self, uuid):
class WatchHistory (line 214) | class WatchHistory(Resource):
method __init__ (line 215) | def __init__(self, **kwargs):
method get (line 223) | def get(self, uuid):
class WatchSingleHistory (line 231) | class WatchSingleHistory(Resource):
method __init__ (line 232) | def __init__(self, **kwargs):
method get (line 238) | def get(self, uuid, timestamp):
class WatchHistoryDiff (line 269) | class WatchHistoryDiff(Resource):
method __init__ (line 281) | def __init__(self, **kwargs):
method get (line 287) | def get(self, uuid, from_timestamp, to_timestamp):
class WatchFavicon (line 387) | class WatchFavicon(Resource):
method __init__ (line 388) | def __init__(self, **kwargs):
method get (line 394) | def get(self, uuid):
class CreateWatch (line 414) | class CreateWatch(Resource):
method __init__ (line 415) | def __init__(self, **kwargs):
method post (line 422) | def post(self):
method get (line 492) | def get(self):
FILE: changedetectionio/api/__init__.py
function build_merged_spec_dict (line 6) | def build_merged_spec_dict():
function get_openapi_spec (line 66) | def get_openapi_spec():
function get_openapi_schema_dict (line 72) | def get_openapi_schema_dict():
function _resolve_schema_properties (line 82) | def _resolve_schema_properties(schema_name):
function get_watch_schema_properties (line 116) | def get_watch_schema_properties():
function get_tag_schema_properties (line 128) | def get_tag_schema_properties():
function validate_openapi_request (line 136) | def validate_openapi_request(operation_id):
FILE: changedetectionio/api/auth.py
function check_token (line 8) | def check_token(f):
FILE: changedetectionio/auth_decorator.py
function login_optionally_required (line 6) | def login_optionally_required(func):
FILE: changedetectionio/blueprint/backups/__init__.py
function create_backup (line 16) | def create_backup(datastore_path, watches: dict, tags: dict = None):
function construct_blueprint (line 94) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/backups/restore.py
class RestoreForm (line 29) | class RestoreForm(Form):
function import_from_zip (line 40) | def import_from_zip(zip_stream, datastore, include_groups, include_group...
function construct_restore_blueprint (line 173) | def construct_restore_blueprint(datastore):
FILE: changedetectionio/blueprint/browser_steps/__init__.py
function _start_browser_steps_loop (line 38) | def _start_browser_steps_loop():
function _ensure_browser_steps_loop (line 70) | def _ensure_browser_steps_loop():
function run_async_in_browser_loop (line 94) | def run_async_in_browser_loop(coro):
function _close_session_resources (line 105) | async def _close_session_resources(session_data, label=''):
function cleanup_expired_sessions (line 134) | def cleanup_expired_sessions():
function cleanup_session_for_watch (line 168) | def cleanup_session_for_watch(watch_uuid):
function construct_blueprint (line 197) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/check_proxies/__init__.py
function threadpool (line 20) | def threadpool(f, executor=None):
function construct_blueprint (line 28) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/imports/__init__.py
function construct_blueprint (line 7) | def construct_blueprint(datastore: ChangeDetectionStore, update_q, queue...
FILE: changedetectionio/blueprint/imports/importer.py
class Importer (line 10) | class Importer():
method __init__ (line 15) | def __init__(self):
method run (line 22) | def run(self,
class import_url_list (line 29) | class import_url_list(Importer):
method run (line 33) | def run(self,
class import_distill_io_json (line 81) | class import_distill_io_json(Importer):
method run (line 82) | def run(self,
class import_xlsx_wachete (line 142) | class import_xlsx_wachete(Importer):
method run (line 144) | def run(self,
class import_xlsx_custom (line 221) | class import_xlsx_custom(Importer):
method run (line 223) | def run(self,
FILE: changedetectionio/blueprint/price_data_follower/__init__.py
function construct_blueprint (line 13) | def construct_blueprint(datastore: ChangeDetectionStore, update_q: Prior...
FILE: changedetectionio/blueprint/rss/_util.py
function scan_invalid_chars_in_rss (line 16) | def scan_invalid_chars_in_rss(content):
function clean_entry_content (line 36) | def clean_entry_content(content):
function generate_watch_guid (line 44) | def generate_watch_guid(watch, timestamp):
function validate_rss_token (line 55) | def validate_rss_token(datastore, request):
function get_rss_template (line 71) | def get_rss_template(datastore, watch, rss_content_format, default_html,...
function get_watch_label (line 85) | def get_watch_label(datastore, watch):
function add_watch_categories (line 93) | def add_watch_categories(fe, watch, datastore):
function build_notification_context (line 101) | def build_notification_context(watch, timestamp_from, timestamp_to, watc...
function render_notification (line 114) | def render_notification(n_object, notification_service, watch, datastore,
function populate_feed_entry (line 130) | def populate_feed_entry(fe, watch, content, guid, timestamp, link=None, ...
FILE: changedetectionio/blueprint/rss/blueprint.py
function construct_blueprint (line 9) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/rss/main_feed.py
function construct_main_feed_routes (line 5) | def construct_main_feed_routes(rss_blueprint, datastore):
FILE: changedetectionio/blueprint/rss/single_watch.py
function construct_single_watch_routes (line 3) | def construct_single_watch_routes(rss_blueprint, datastore):
FILE: changedetectionio/blueprint/rss/tag.py
function construct_tag_routes (line 1) | def construct_tag_routes(rss_blueprint, datastore):
FILE: changedetectionio/blueprint/settings/__init__.py
function construct_blueprint (line 15) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/tags/__init__.py
function construct_blueprint (line 10) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/tags/form.py
class group_restock_settings_form (line 11) | class group_restock_settings_form(restock_settings_form):
class SingleTag (line 14) | class SingleTag(Form):
FILE: changedetectionio/blueprint/ui/__init__.py
function _handle_operations (line 13) | def _handle_operations(op, uuids, datastore, worker_pool, update_q, queu...
function construct_blueprint (line 119) | def construct_blueprint(datastore: ChangeDetectionStore, update_q, worke...
FILE: changedetectionio/blueprint/ui/diff.py
function construct_blueprint (line 20) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/ui/edit.py
function construct_blueprint (line 14) | def construct_blueprint(datastore: ChangeDetectionStore, update_q, queue...
FILE: changedetectionio/blueprint/ui/notification.py
function construct_blueprint (line 8) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/ui/preview.py
function construct_blueprint (line 10) | def construct_blueprint(datastore: ChangeDetectionStore):
FILE: changedetectionio/blueprint/ui/views.py
function construct_blueprint (line 8) | def construct_blueprint(datastore: ChangeDetectionStore, update_q, queue...
FILE: changedetectionio/blueprint/watchlist/__init__.py
function construct_blueprint (line 13) | def construct_blueprint(datastore: ChangeDetectionStore, update_q, queue...
FILE: changedetectionio/browser_steps/browser_steps.py
function browser_steps_get_valid_steps (line 11) | def browser_steps_get_valid_steps(browser_steps: list):
class steppable_browser_interface (line 66) | class steppable_browser_interface():
method __init__ (line 71) | def __init__(self, start_url):
method call_action (line 75) | async def call_action(self, action_name, selector=None, optional_value...
method action_goto_url (line 134) | async def action_goto_url(self, selector=None, value=None):
method action_goto_site (line 145) | async def action_goto_site(self, selector=None, value=None):
method action_click_element_containing_text (line 148) | async def action_click_element_containing_text(self, selector=None, va...
method action_click_element_containing_text_if_exists (line 158) | async def action_click_element_containing_text_if_exists(self, selecto...
method action_enter_text_in_field (line 170) | async def action_enter_text_in_field(self, selector, value):
method action_execute_js (line 176) | async def action_execute_js(self, selector, value):
method action_click_element (line 182) | async def action_click_element(self, selector, value):
method action_click_element_if_exists (line 189) | async def action_click_element_if_exists(self, selector, value):
method action_click_x_y (line 204) | async def action_click_x_y(self, selector, value):
method action__select_by_option_text (line 219) | async def action__select_by_option_text(self, selector, value):
method action_scroll_down (line 225) | async def action_scroll_down(self, selector, value):
method action_wait_for_seconds (line 230) | async def action_wait_for_seconds(self, selector, value):
method action_wait_for_text (line 237) | async def action_wait_for_text(self, selector, value):
method action_wait_for_text_in_element (line 249) | async def action_wait_for_text_in_element(self, selector, value):
method action_press_enter (line 264) | async def action_press_enter(self, selector, value):
method action_press_page_up (line 268) | async def action_press_page_up(self, selector, value):
method action_press_page_down (line 271) | async def action_press_page_down(self, selector, value):
method action_check_checkbox (line 274) | async def action_check_checkbox(self, selector, value):
method action_uncheck_checkbox (line 280) | async def action_uncheck_checkbox(self, selector, value):
method action_remove_elements (line 287) | async def action_remove_elements(self, selector, value):
method action_make_all_child_elements_visible (line 294) | async def action_make_all_child_elements_visible(self, selector, value):
class browsersteps_live_ui (line 314) | class browsersteps_live_ui(steppable_browser_interface):
method __init__ (line 338) | def __init__(self, playwright_browser, proxy=None, headers=None, start...
method __del__ (line 347) | def __del__(self):
method connect (line 353) | async def connect(self, proxy=None):
method mark_as_closed (line 382) | def mark_as_closed(self):
method cleanup (line 388) | async def cleanup(self):
method has_expired (line 429) | def has_expired(self):
method get_current_state (line 441) | async def get_current_state(self):
FILE: changedetectionio/conditions/__init__.py
function filter_complete_rules (line 33) | def filter_complete_rules(ruleset):
function convert_to_jsonlogic (line 40) | def convert_to_jsonlogic(logic_operator: str, rule_dict: list):
function execute_ruleset_against_all_plugins (line 81) | def execute_ruleset_against_all_plugins(current_watch_uuid: str, applica...
function collect_ui_edit_stats_extras (line 153) | def collect_ui_edit_stats_extras(watch):
FILE: changedetectionio/conditions/blueprint.py
function construct_blueprint (line 9) | def construct_blueprint(datastore):
FILE: changedetectionio/conditions/default_plugin.py
function register_operators (line 11) | def register_operators():
function register_operator_choices (line 48) | def register_operator_choices():
function register_field_choices (line 60) | def register_field_choices():
function add_data (line 70) | def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
FILE: changedetectionio/conditions/exceptions.py
class EmptyConditionRuleRowNotUsable (line 1) | class EmptyConditionRuleRowNotUsable(Exception):
method __init__ (line 2) | def __init__(self):
method __str__ (line 5) | def __str__(self):
FILE: changedetectionio/conditions/form.py
class ConditionFormRow (line 5) | class ConditionFormRow(Form):
method validate (line 24) | def validate(self, extra_validators=None):
FILE: changedetectionio/conditions/pluggy_interface.py
class ConditionsSpec (line 14) | class ConditionsSpec:
method register_operators (line 18) | def register_operators():
method register_operator_choices (line 23) | def register_operator_choices():
method register_field_choices (line 28) | def register_field_choices():
method add_data (line 33) | def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
method ui_edit_stats_extras (line 38) | def ui_edit_stats_extras(watch):
function load_plugins_from_directory (line 52) | def load_plugins_from_directory():
FILE: changedetectionio/conditions/plugins/levenshtein_plugin.py
function levenshtein_ratio_recent_history (line 14) | def levenshtein_ratio_recent_history(watch, incoming_text=None):
function register_operators (line 45) | def register_operators():
function register_operator_choices (line 49) | def register_operator_choices():
function register_field_choices (line 54) | def register_field_choices():
function add_data (line 61) | def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
function ui_edit_stats_extras (line 76) | def ui_edit_stats_extras(watch):
FILE: changedetectionio/conditions/plugins/wordcount_plugin.py
function count_words_in_history (line 12) | def count_words_in_history(watch, incoming_text=None):
function register_operators (line 30) | def register_operators():
function register_operator_choices (line 35) | def register_operator_choices():
function register_field_choices (line 40) | def register_field_choices():
function add_data (line 47) | def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
function _generate_stats_html (line 58) | def _generate_stats_html(watch):
function ui_edit_stats_extras (line 79) | def ui_edit_stats_extras(watch):
function ui_edit_stats_extras (line 84) | def ui_edit_stats_extras(watch):
FILE: changedetectionio/content_fetchers/__init__.py
function available_fetchers (line 39) | def available_fetchers():
function get_plugin_fetchers (line 66) | def get_plugin_fetchers():
FILE: changedetectionio/content_fetchers/base.py
function manage_user_agent (line 8) | def manage_user_agent(headers, current_ua=''):
class Fetcher (line 41) | class Fetcher():
method __init__ (line 78) | def __init__(self, **kwargs):
method get_status_icon_data (line 88) | def get_status_icon_data(cls):
method clear_content (line 105) | def clear_content(self):
method get_error (line 118) | def get_error(self):
method run (line 122) | async def run(self,
method quit (line 139) | async def quit(self, watch=None):
method get_last_status_code (line 143) | def get_last_status_code(self):
method screenshot_step (line 147) | def screenshot_step(self, step_n):
method is_ready (line 155) | def is_ready(self):
method get_all_headers (line 158) | def get_all_headers(self):
method iterate_browser_steps (line 165) | async def iterate_browser_steps(self, start_url=None):
method delete_browser_steps_screenshots (line 202) | def delete_browser_steps_screenshots(self):
method save_step_html (line 211) | def save_step_html(self, step_n):
FILE: changedetectionio/content_fetchers/exceptions/__init__.py
class Non200ErrorCodeReceived (line 3) | class Non200ErrorCodeReceived(Exception):
method __init__ (line 4) | def __init__(self, status_code, url, screenshot=None, xpath_data=None,...
class checksumFromPreviousCheckWasTheSame (line 18) | class checksumFromPreviousCheckWasTheSame(Exception):
method __init__ (line 19) | def __init__(self):
class JSActionExceptions (line 23) | class JSActionExceptions(Exception):
method __init__ (line 24) | def __init__(self, status_code, url, screenshot, message=''):
class BrowserConnectError (line 31) | class BrowserConnectError(Exception):
method __init__ (line 33) | def __init__(self, msg):
class BrowserFetchTimedOut (line 38) | class BrowserFetchTimedOut(Exception):
method __init__ (line 40) | def __init__(self, msg):
class BrowserStepsStepException (line 45) | class BrowserStepsStepException(Exception):
method __init__ (line 46) | def __init__(self, step_n, original_e):
class PageUnloadable (line 54) | class PageUnloadable(Exception):
method __init__ (line 55) | def __init__(self, status_code=None, url='', message='', screenshot=Fa...
class BrowserStepsInUnsupportedFetcher (line 63) | class BrowserStepsInUnsupportedFetcher(Exception):
method __init__ (line 64) | def __init__(self, url):
class EmptyReply (line 68) | class EmptyReply(Exception):
method __init__ (line 69) | def __init__(self, status_code, url, screenshot=None):
class ScreenshotUnavailable (line 77) | class ScreenshotUnavailable(Exception):
method __init__ (line 78) | def __init__(self, status_code, url, page_html=None):
class ReplyWithContentButNoText (line 88) | class ReplyWithContentButNoText(Exception):
method __init__ (line 89) | def __init__(self, status_code, url, screenshot=None, has_filters=Fals...
FILE: changedetectionio/content_fetchers/playwright.py
function capture_full_page_async (line 16) | async def capture_full_page_async(page, screenshot_format='JPEG', watch_...
class fetcher (line 150) | class fetcher(Fetcher):
method get_status_icon_data (line 172) | def get_status_icon_data(cls):
method __init__ (line 180) | def __init__(self, proxy_override=None, custom_browser_connection_url=...
method screenshot_step (line 214) | async def screenshot_step(self, step_n=''):
method save_step_html (line 232) | async def save_step_html(self, step_n):
method run (line 247) | async def run(self,
class PlaywrightFetcherPlugin (line 459) | class PlaywrightFetcherPlugin:
method register_content_fetcher (line 462) | def register_content_fetcher(self):
FILE: changedetectionio/content_fetchers/puppeteer.py
function capture_full_page (line 24) | async def capture_full_page(page, screenshot_format='JPEG', watch_uuid=N...
class fetcher (line 168) | class fetcher(Fetcher):
method get_status_icon_data (line 186) | def get_status_icon_data(cls):
method __init__ (line 194) | def __init__(self, proxy_override=None, custom_browser_connection_url=...
method quit (line 224) | async def quit(self, watch=None):
method fetch_page (line 256) | async def fetch_page(self,
method main (line 497) | async def main(self, **kwargs):
method run (line 500) | async def run(self,
class PuppeteerFetcherPlugin (line 548) | class PuppeteerFetcherPlugin:
method register_content_fetcher (line 551) | def register_content_fetcher(self):
FILE: changedetectionio/content_fetchers/requests.py
class fetcher (line 15) | class fetcher(Fetcher):
method __init__ (line 18) | def __init__(self, proxy_override=None, custom_browser_connection_url=...
method _run_sync (line 23) | def _run_sync(self,
method run (line 208) | async def run(self,
method quit (line 244) | async def quit(self, watch=None):
class RequestsFetcherPlugin (line 258) | class RequestsFetcherPlugin:
method register_content_fetcher (line 261) | def register_content_fetcher(self):
FILE: changedetectionio/content_fetchers/res/lock-elements-sizing.js
method constructor (line 100) | constructor() {}
method observe (line 101) | observe() {}
method unobserve (line 102) | unobserve() {}
method disconnect (line 103) | disconnect() {}
FILE: changedetectionio/content_fetchers/res/stock-not-in-stock.js
function isItemInStock (line 3) | function isItemInStock() {
FILE: changedetectionio/content_fetchers/res/xpath_element_scraper.js
function getxpath (line 14) | function getxpath(e) {
function collectVisibleElements (line 84) | function collectVisibleElements(parent, visibleElements) {
FILE: changedetectionio/content_fetchers/screenshot_handler.py
function stitch_images_worker_raw_bytes (line 11) | def stitch_images_worker_raw_bytes(pipe_conn, original_page_height, capt...
FILE: changedetectionio/content_fetchers/webdriver_selenium.py
class fetcher (line 8) | class fetcher(Fetcher):
method get_status_icon_data (line 23) | def get_status_icon_data(cls):
method __init__ (line 31) | def __init__(self, proxy_override=None, custom_browser_connection_url=...
method run (line 63) | async def run(self,
class WebDriverSeleniumFetcherPlugin (line 190) | class WebDriverSeleniumFetcherPlugin:
method register_content_fetcher (line 193) | def register_content_fetcher(self):
FILE: changedetectionio/custom_queue.py
class NotificationQueue (line 7) | class NotificationQueue(queue.Queue):
method __init__ (line 15) | def __init__(self, maxsize=0):
method put (line 22) | def put(self, item, block=True, timeout=None):
class SignalPriorityQueue (line 41) | class SignalPriorityQueue(queue.PriorityQueue):
method __init__ (line 50) | def __init__(self, maxsize=0):
method put (line 57) | def put(self, item, block=True, timeout=None):
method get (line 79) | def get(self, block=True, timeout=None):
method get_uuid_position (line 91) | def get_uuid_position(self, target_uuid):
method get_all_queued_uuids (line 155) | def get_all_queued_uuids(self, limit=None, offset=0):
method get_queue_summary (line 249) | def get_queue_summary(self):
class AsyncSignalPriorityQueue (line 298) | class AsyncSignalPriorityQueue(asyncio.PriorityQueue):
method __init__ (line 306) | def __init__(self, maxsize=0):
method put (line 313) | async def put(self, item):
method get (line 334) | async def get(self):
method queue (line 347) | def queue(self):
method get_uuid_position (line 354) | def get_uuid_position(self, target_uuid):
method get_all_queued_uuids (line 414) | def get_all_queued_uuids(self, limit=None, offset=0):
method get_queue_summary (line 496) | def get_queue_summary(self):
FILE: changedetectionio/diff/__init__.py
function render_inline_word_diff (line 49) | def render_inline_word_diff(before_line: str, after_line: str, ignore_ju...
function render_nested_line_diff (line 172) | def render_nested_line_diff(before_line: str, after_line: str, ignore_ju...
function same_slicer (line 263) | def same_slicer(lst: List[str], start: int, end: int) -> List[str]:
function customSequenceMatcher (line 267) | def customSequenceMatcher(
function render_diff (line 382) | def render_diff(
FILE: changedetectionio/diff/tokenizers/natural_text.py
function tokenize_words (line 11) | def tokenize_words(text: str) -> List[str]:
FILE: changedetectionio/diff/tokenizers/words_and_html.py
function tokenize_words_and_html (line 13) | def tokenize_words_and_html(text: str) -> List[str]:
FILE: changedetectionio/favicon_utils.py
function get_favicon_mime_type (line 10) | def get_favicon_mime_type(filepath):
FILE: changedetectionio/flask_app.py
class StrictUUIDConverter (line 75) | class StrictUUIDConverter(BaseConverter):
method to_python (line 79) | def to_python(self, value: str) -> str:
method to_url (line 91) | def to_url(self, value) -> str:
function _configure_plugin_templates (line 139) | def _configure_plugin_templates():
function init_app_secret (line 172) | def init_app_secret(datastore_path):
function get_darkmode_state (line 191) | def get_darkmode_state():
function get_css_version (line 196) | def get_css_version():
function get_socketio_path (line 200) | def get_socketio_path():
function _is_safe_valid_url (line 211) | def _is_safe_valid_url(test_url):
function _jinja2_filter_format_number_locale (line 217) | def _jinja2_filter_format_number_locale(value: float) -> str:
function _jinja2_filter_regex_search (line 224) | def _jinja2_filter_regex_search(value, pattern):
function _watch_is_checking_now (line 229) | def _watch_is_checking_now(watch_obj, format="%Y-%m-%d %H:%M:%S"):
function _get_watch_queue_position (line 233) | def _get_watch_queue_position(watch_obj):
function _get_current_worker_count (line 239) | def _get_current_worker_count():
function _get_worker_status_info (line 244) | def _get_worker_status_info():
function _jinja2_filter_datetime (line 261) | def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"):
function _jinja2_filter_datetimestamp (line 274) | def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"):
function _jinja2_filter_pagination_slice (line 287) | def _jinja2_filter_pagination_slice(arr, skip):
function _jinja2_filter_seconds_precise (line 295) | def _jinja2_filter_seconds_precise(timestamp):
function _jinja2_filter_format_duration (line 302) | def _jinja2_filter_format_duration(seconds):
function _jinja2_filter_fetcher_status_icons (line 343) | def _jinja2_filter_fetcher_status_icons(fetcher_name):
function _jinja2_filter_sanitize_tag_class (line 394) | def _jinja2_filter_sanitize_tag_class(tag_title):
class User (line 415) | class User(flask_login.UserMixin):
method set_password (line 418) | def set_password(self, password):
method get_user (line 420) | def get_user(self, email="defaultuser@changedetection.io"):
method is_authenticated (line 422) | def is_authenticated(self):
method is_active (line 424) | def is_active(self):
method is_anonymous (line 426) | def is_anonymous(self):
method get_id (line 428) | def get_id(self):
method check_password (line 432) | def check_password(self, password):
function changedetection_app (line 459) | def changedetection_app(config=None, datastore_o=None):
function check_for_new_version (line 1015) | def check_for_new_version():
function notification_runner (line 1043) | def notification_runner(worker_id=0):
function ticker_thread_check_time_launch_checks (line 1097) | def ticker_thread_check_time_launch_checks():
FILE: changedetectionio/forms.py
class StringListField (line 64) | class StringListField(StringField):
method _value (line 67) | def _value(self):
method process_formdata (line 78) | def process_formdata(self, valuelist):
class SaltyPasswordField (line 88) | class SaltyPasswordField(StringField):
method build_password (line 92) | def build_password(self, password):
method process_formdata (line 106) | def process_formdata(self, valuelist):
class StringTagUUID (line 115) | class StringTagUUID(StringField):
method _value (line 120) | def _value(self):
class TimeDurationForm (line 138) | class TimeDurationForm(Form):
class TimeStringField (line 142) | class TimeStringField(Field):
method _value (line 148) | def _value(self):
method process_formdata (line 154) | def process_formdata(self, valuelist):
class validateTimeZoneName (line 166) | class validateTimeZoneName(object):
method __init__ (line 171) | def __init__(self, message=None):
method __call__ (line 174) | def __call__(self, form, field):
class ScheduleLimitDaySubForm (line 180) | class ScheduleLimitDaySubForm(Form):
class ScheduleLimitForm (line 185) | class ScheduleLimitForm(Form):
method __init__ (line 200) | def __init__(
function validate_time_between_check_has_values (line 219) | def validate_time_between_check_has_values(form):
class RequiredTimeInterval (line 235) | class RequiredTimeInterval(object):
method __init__ (line 240) | def __init__(self, message=None):
method __call__ (line 243) | def __call__(self, form, field):
class TimeBetweenCheckForm (line 248) | class TimeBetweenCheckForm(Form):
method __init__ (line 256) | def __init__(self, formdata=None, obj=None, prefix="", data=None, meta...
method validate (line 261) | def validate(self, **kwargs):
class EnhancedFormField (line 279) | class EnhancedFormField(FormField):
method __init__ (line 285) | def __init__(self, form_class, label=None, validators=None, separator=...
method validate (line 301) | def validate(self, form, extra_validators=()):
class RequiredFormField (line 330) | class RequiredFormField(FormField):
method __init__ (line 336) | def __init__(self, form_class, label=None, validators=None, separator=...
method process (line 339) | def process(self, formdata, data=unset_value, extra_filters=None):
method errors (line 363) | def errors(self):
class StringDictKeyValue (line 374) | class StringDictKeyValue(StringField):
method _value (line 377) | def _value(self):
method process_formdata (line 387) | def process_formdata(self, valuelist):
class ValidateContentFetcherIsReady (line 411) | class ValidateContentFetcherIsReady(object):
method __init__ (line 415) | def __init__(self, message=None):
method __call__ (line 418) | def __call__(self, form, field):
class ValidateNotificationBodyAndTitleWhenURLisSet (line 446) | class ValidateNotificationBodyAndTitleWhenURLisSet(object):
method __init__ (line 452) | def __init__(self, message=None):
method __call__ (line 455) | def __call__(self, form, field):
class ValidateAppRiseServers (line 461) | class ValidateAppRiseServers(object):
method __init__ (line 466) | def __init__(self, message=None):
method __call__ (line 469) | def __call__(self, form, field):
class ValidateJinja2Template (line 490) | class ValidateJinja2Template(object):
method __call__ (line 494) | def __call__(self, form, field):
class validateURL (line 530) | class validateURL(object):
method __init__ (line 536) | def __init__(self, message=None):
method __call__ (line 539) | def __call__(self, form, field):
function validate_url (line 544) | def validate_url(test_url):
class ValidateSinglePythonRegexString (line 551) | class ValidateSinglePythonRegexString(object):
method __init__ (line 552) | def __init__(self, message=None):
method __call__ (line 555) | def __call__(self, form, field):
class ValidateListRegex (line 563) | class ValidateListRegex(object):
method __init__ (line 567) | def __init__(self, message=None):
method __call__ (line 570) | def __call__(self, form, field):
class ValidateCSSJSONXPATHInput (line 582) | class ValidateCSSJSONXPATHInput(object):
method __init__ (line 588) | def __init__(self, message=None, allow_xpath=True, allow_json=True):
method __call__ (line 593) | def __call__(self, form, field):
class ValidateSimpleURL (line 680) | class ValidateSimpleURL:
method __init__ (line 682) | def __init__(self, message=None):
method __call__ (line 685) | def __call__(self, form, field):
class ValidateStartsWithRegex (line 695) | class ValidateStartsWithRegex(object):
method __init__ (line 696) | def __init__(self, regex, *, flags=0, message=None, allow_empty=True, ...
method __call__ (line 703) | def __call__(self, form, field):
class quickWatchForm (line 725) | class quickWatchForm(Form):
class commonSettingsForm (line 734) | class commonSettingsForm(Form):
method __init__ (line 737) | def __init__(self, formdata=None, obj=None, prefix="", data=None, meta...
class importForm (line 761) | class importForm(Form):
class SingleBrowserStep (line 767) | class SingleBrowserStep(Form):
class processor_text_json_diff_form (line 778) | class processor_text_json_diff_form(commonSettingsForm):
method extra_tab_content (line 837) | def extra_tab_content(self):
method extra_form_content (line 840) | def extra_form_content(self):
method validate (line 843) | def validate(self, **kwargs):
method __init__ (line 899) | def __init__(
class SingleExtraProxy (line 916) | class SingleExtraProxy(Form):
class SingleExtraBrowser (line 929) | class SingleExtraBrowser(Form):
class DefaultUAInputForm (line 941) | class DefaultUAInputForm(Form):
class globalSettingsRequestForm (line 947) | class globalSettingsRequestForm(Form):
method validate_extra_proxies (line 970) | def validate_extra_proxies(self, extra_validators=None):
class globalSettingsApplicationUIForm (line 977) | class globalSettingsApplicationUIForm(Form):
class globalSettingsApplicationForm (line 984) | class globalSettingsApplicationForm(commonSettingsForm):
class globalSettingsForm (line 1039) | class globalSettingsForm(Form):
method __init__ (line 1043) | def __init__(self, formdata=None, obj=None, prefix="", data=None, meta...
class extractDataForm (line 1054) | class extractDataForm(Form):
FILE: changedetectionio/gc_cleanup.py
function memory_cleanup (line 12) | def memory_cleanup(app=None):
FILE: changedetectionio/html_tools.py
class JSONNotFound (line 22) | class JSONNotFound(ValueError):
method __init__ (line 23) | def __init__(self, msg):
function _build_safe_xpath3_parser (line 38) | def _build_safe_xpath3_parser():
function perl_style_slash_enclosed_regex_to_options (line 76) | def perl_style_slash_enclosed_regex_to_options(regex):
function include_filters (line 90) | def include_filters(include_filters, html_content, append_pretty_line_fo...
function subtractive_css_selector (line 108) | def subtractive_css_selector(css_selector, content):
function subtractive_xpath_selector (line 125) | def subtractive_xpath_selector(selectors: List[str], html_content: str) ...
function element_removal (line 152) | def element_removal(selectors: List[str], html_content):
function elementpath_tostring (line 180) | def elementpath_tostring(obj):
function xpath_filter (line 222) | def xpath_filter(xpath_filter, html_content, append_pretty_line_formatti...
function xpath1_filter (line 295) | def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatt...
function extract_element (line 350) | def extract_element(find='title', html_content=''):
function _parse_json (line 364) | def _parse_json(json_data, json_filter):
function _get_stripped_text_from_json_match (line 390) | def _get_stripped_text_from_json_match(match):
function extract_json_blob_from_html (line 411) | def extract_json_blob_from_html(content, ensure_is_ldjson_info_type, jso...
function extract_json_as_string (line 473) | def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_t...
function strip_ignore_text (line 521) | def strip_ignore_text(content, wordlist, mode="content"):
function cdata_in_document_to_text (line 588) | def cdata_in_document_to_text(html_content: str, render_anchor_tag_conte...
function html_to_text (line 601) | def html_to_text(html_content: str, render_anchor_tag_content=False, is_...
function has_ldjson_product_info (line 656) | def has_ldjson_product_info(content):
function workarounds_for_obfuscations (line 678) | def workarounds_for_obfuscations(content):
function get_triggered_text (line 694) | def get_triggered_text(content, trigger_text):
function extract_title (line 709) | def extract_title(data: bytes | str, sniff_bytes: int = 2048, scan_chars...
FILE: changedetectionio/is_safe_url.py
function is_safe_url (line 21) | def is_safe_url(target, app):
FILE: changedetectionio/jinja2_custom/extensions/TimeExtension.py
class TimeExtension (line 89) | class TimeExtension(Extension):
method __init__ (line 102) | def __init__(self, environment):
method _datetime (line 111) | def _datetime(self, timezone, operator, offset, datetime_format):
method _now (line 150) | def _now(self, timezone, datetime_format):
method parse (line 173) | def parse(self, parser):
FILE: changedetectionio/jinja2_custom/plugins/regex.py
function regex_replace (line 11) | def regex_replace(value: str, pattern: str, replacement: str = '', count...
FILE: changedetectionio/jinja2_custom/safe_jinja.py
function create_jinja_env (line 18) | def create_jinja_env(extensions=None, **kwargs) -> jinja2.sandbox.Immuta...
function render (line 49) | def render(template_str, **args: t.Any) -> str:
function render_fully_escaped (line 54) | def render_fully_escaped(content):
FILE: changedetectionio/languages.py
function get_timeago_locale (line 9) | def get_timeago_locale(flask_locale):
function get_available_languages (line 75) | def get_available_languages():
function get_language_codes (line 100) | def get_language_codes():
function get_flag_for_locale (line 105) | def get_flag_for_locale(locale):
function get_name_for_locale (line 110) | def get_name_for_locale(locale):
FILE: changedetectionio/model/App.py
class model (line 19) | class model(dict):
method __init__ (line 84) | def __init__(self, *arg, datastore_path=None, **kw):
function parse_headers_from_text_file (line 96) | def parse_headers_from_text_file(filepath):
FILE: changedetectionio/model/Tag.py
class model (line 26) | class model(EntityPersistenceMixin, watch_base):
method __init__ (line 44) | def __init__(self, *arg, **kw):
FILE: changedetectionio/model/Tags.py
class TagsDict (line 9) | class TagsDict(dict):
method __init__ (line 12) | def __init__(self, *args, datastore_path: str | os.PathLike, **kwargs)...
method __delitem__ (line 16) | def __delitem__(self, key: str) -> None:
method pop (line 31) | def pop(self, key: str, default=_SENTINEL):
FILE: changedetectionio/model/Watch.py
function _brotli_save (line 54) | def _brotli_save(contents, filepath, mode=None, fallback_uncompressed=Fa...
class model (line 136) | class model(EntityPersistenceMixin, watch_base):
method __init__ (line 234) | def __init__(self, *arg, **kw):
method viewed (line 256) | def viewed(self):
method has_unviewed (line 264) | def has_unviewed(self):
method link (line 268) | def link(self):
method domain_only_from_link (line 297) | def domain_only_from_link(self):
method history_index_filename (line 304) | def history_index_filename(self):
method clear_watch (line 312) | def clear_watch(self):
method is_source_type_url (line 353) | def is_source_type_url(self):
method get_fetch_backend (line 357) | def get_fetch_backend(self):
method fetcher_supports_screenshots (line 392) | def fetcher_supports_screenshots(self):
method is_pdf (line 411) | def is_pdf(self):
method label (line 424) | def label(self):
method last_changed (line 429) | def last_changed(self):
method history_n (line 438) | def history_n(self):
method history (line 442) | def history(self):
method has_history (line 495) | def has_history(self):
method has_browser_steps (line 500) | def has_browser_steps(self):
method has_restock_info (line 508) | def has_restock_info(self):
method newest_history_key (line 516) | def newest_history_key(self):
method get_from_version_based_on_last_viewed (line 529) | def get_from_version_based_on_last_viewed(self):
method get_history_snapshot (line 554) | def get_history_snapshot(self, timestamp=None, filepath=None):
method _write_atomic (line 599) | def _write_atomic(self, dest, data, mode='wb'):
method history_trim (line 609) | def history_trim(self, newest_n_items):
method save_history_blob (line 645) | def save_history_blob(self, contents, timestamp, snapshot_id):
method has_empty_checktime (line 724) | def has_empty_checktime(self):
method threshold_seconds (line 730) | def threshold_seconds(self):
method lines_contain_something_unique_compared_to_history (line 740) | def lines_contain_something_unique_compared_to_history(self, lines: li...
method get_screenshot (line 771) | def get_screenshot(self):
method favicon_is_expired (line 779) | def favicon_is_expired(self):
method bump_favicon (line 801) | def bump_favicon(self, url, favicon_base_64: str) -> None:
method get_favicon_filename (line 847) | def get_favicon_filename(self) -> str | None:
method get_screenshot_as_thumbnail (line 867) | def get_screenshot_as_thumbnail(self, max_age=3200):
method __get_file_ctime (line 931) | def __get_file_ctime(self, filename):
method error_text_ctime (line 938) | def error_text_ctime(self):
method snapshot_text_ctime (line 942) | def snapshot_text_ctime(self):
method snapshot_screenshot_ctime (line 950) | def snapshot_screenshot_ctime(self):
method snapshot_error_screenshot_ctime (line 954) | def snapshot_error_screenshot_ctime(self):
method get_error_text (line 957) | def get_error_text(self):
method get_error_snapshot (line 965) | def get_error_snapshot(self):
method pause (line 973) | def pause(self):
method unpause (line 976) | def unpause(self):
method toggle_pause (line 979) | def toggle_pause(self):
method mute (line 982) | def mute(self):
method unmute (line 985) | def unmute(self):
method toggle_mute (line 988) | def toggle_mute(self):
method _get_commit_data (line 991) | def _get_commit_data(self):
method extra_notification_token_values (line 1022) | def extra_notification_token_values(self):
method extra_notification_token_placeholder_info (line 1027) | def extra_notification_token_placeholder_info(self):
method extract_regex_from_all_history (line 1033) | def extract_regex_from_all_history(self, regex):
method has_special_diff_filter_options_set (line 1078) | def has_special_diff_filter_options_set(self):
method save_error_text (line 1091) | def save_error_text(self, contents):
method save_xpath_data (line 1097) | def save_xpath_data(self, data, as_error=False):
method save_screenshot (line 1116) | def save_screenshot(self, screenshot: bytes, as_error=False):
method get_last_fetched_text_before_filters (line 1130) | def get_last_fetched_text_before_filters(self):
method save_last_text_fetched_before_filters (line 1145) | def save_last_text_fetched_before_filters(self, contents):
method save_last_fetched_html (line 1150) | def save_last_fetched_html(self, timestamp, contents):
method get_fetched_html (line 1157) | def get_fetched_html(self, timestamp):
method _prune_last_fetched_html_snapshots (line 1169) | def _prune_last_fetched_html_snapshots(self):
method get_browsersteps_available_screenshots (line 1184) | def get_browsersteps_available_screenshots(self):
method compile_error_texts (line 1193) | def compile_error_texts(self, has_proxies=None):
FILE: changedetectionio/model/__init__.py
class watch_base (line 15) | class watch_base(dict):
method __init__ (line 158) | def __init__(self, *arg, **kw):
method _mark_field_as_edited (line 320) | def _mark_field_as_edited(self, key):
method __setitem__ (line 348) | def __setitem__(self, key, value):
method __delitem__ (line 363) | def __delitem__(self, key):
method update (line 368) | def update(self, *args, **kwargs):
method pop (line 386) | def pop(self, key, *args):
method setdefault (line 392) | def setdefault(self, key, default=None):
method was_edited (line 402) | def was_edited(self):
method reset_watch_edited_flag (line 411) | def reset_watch_edited_flag(self):
method get_property_names (line 420) | def get_property_names(cls):
method __deepcopy__ (line 455) | def __deepcopy__(self, memo):
method __getstate__ (line 507) | def __getstate__(self):
method __setstate__ (line 537) | def __setstate__(self, state):
method data_dir (line 555) | def data_dir(self):
method ensure_data_dir_exists (line 564) | def ensure_data_dir_exists(self):
method get_global_setting (line 575) | def get_global_setting(self, *path):
method _get_commit_data (line 599) | def _get_commit_data(self):
method _save_to_disk (line 621) | def _save_to_disk(self, data_dict, uuid):
method commit (line 634) | def commit(self):
FILE: changedetectionio/model/persistence.py
function _determine_entity_type (line 12) | def _determine_entity_type(cls):
class EntityPersistenceMixin (line 37) | class EntityPersistenceMixin:
method _save_to_disk (line 52) | def _save_to_disk(self, data_dict, uuid):
FILE: changedetectionio/model/schema_utils.py
function get_openapi_schema_dict (line 12) | def get_openapi_schema_dict():
function _resolve_readonly_fields (line 30) | def _resolve_readonly_fields(schema_name):
function get_readonly_watch_fields (line 72) | def get_readonly_watch_fields():
function get_readonly_tag_fields (line 86) | def get_readonly_tag_fields():
FILE: changedetectionio/notification/apprise_plugin/custom_handlers.py
function notify_supported_methods (line 65) | def notify_supported_methods(func):
function _register_http_handler (line 73) | def _register_http_handler(schema, send_func):
function _get_auth (line 126) | def _get_auth(parsed_url: dict) -> str | tuple[str, str]:
function _get_headers (line 139) | def _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict:
function _get_params (line 155) | def _get_params(parsed_url: dict) -> CaseInsensitiveDict:
function apprise_http_custom_handler (line 172) | def apprise_http_custom_handler(
FILE: changedetectionio/notification/apprise_plugin/discord.py
class NotifyDiscordCustom (line 31) | class NotifyDiscordCustom(NotifyDiscord):
method send (line 37) | def send(self, body, title="", notify_type=None, attach=None, **kwargs):
method _send_with_colored_embeds (line 56) | def _send_with_colored_embeds(self, body, title, notify_type, attach, ...
method _parse_body_into_chunks (line 200) | def _parse_body_into_chunks(self, body):
function discord_custom_wrapper (line 278) | def discord_custom_wrapper(body, title, notify_type, meta, body_format=N...
FILE: changedetectionio/notification/email_helpers.py
function as_monospaced_html_email (line 1) | def as_monospaced_html_email(content: str, title: str) -> str:
FILE: changedetectionio/notification/handler.py
function markup_text_links_to_html (line 19) | def markup_text_links_to_html(body):
function notification_format_align_with_apprise (line 59) | def notification_format_align_with_apprise(n_format : str):
function apply_html_color_to_body (line 83) | def apply_html_color_to_body(n_body: str):
function apply_discord_markdown_to_body (line 100) | def apply_discord_markdown_to_body(n_body):
function apply_standard_markdown_to_body (line 124) | def apply_standard_markdown_to_body(n_body):
function replace_placemarkers_in_text (line 150) | def replace_placemarkers_in_text(text, url, requested_output_format):
function apply_service_tweaks (line 206) | def apply_service_tweaks(url, n_body, n_title, requested_output_format):
function process_notification (line 301) | def process_notification(n_object: NotificationContextData, datastore):
function create_notification_parameters (line 470) | def create_notification_parameters(n_object: NotificationContextData, da...
FILE: changedetectionio/notification_service.py
function _check_cascading_vars (line 17) | def _check_cascading_vars(datastore, var_name, watch):
class FormattableTimestamp (line 57) | class FormattableTimestamp(str):
method __new__ (line 72) | def __new__(cls, timestamp):
method __call__ (line 84) | def __call__(self, format=_DEFAULT_FORMAT):
class FormattableDiff (line 91) | class FormattableDiff(str):
method __new__ (line 108) | def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
method __call__ (line 120) | def __call__(self, lines=None, added_only=False, removed_only=False, c...
class NotificationContextData (line 148) | class NotificationContextData(dict):
method __init__ (line 149) | def __init__(self, initial_data=None, **kwargs):
method set_random_for_validation (line 194) | def set_random_for_validation(self):
method __setitem__ (line 205) | def __setitem__(self, key, value):
function add_rendered_diff_to_notification_vars (line 212) | def add_rendered_diff_to_notification_vars(notification_scan_text:str, p...
function set_basic_notification_vars (line 263) | def set_basic_notification_vars(current_snapshot, prev_snapshot, watch, ...
class NotificationService (line 284) | class NotificationService:
method __init__ (line 290) | def __init__(self, datastore, notification_q):
method queue_notification_for_watch (line 294) | def queue_notification_for_watch(self, n_object: NotificationContextDa...
method send_content_changed_notification (line 351) | def send_content_changed_notification(self, watch_uuid):
method send_filter_failure_notification (line 390) | def send_filter_failure_notification(self, watch_uuid):
method send_step_failure_notification (line 437) | def send_step_failure_notification(self, watch_uuid, step_n):
function create_notification_service (line 485) | def create_notification_service(datastore, notification_q):
FILE: changedetectionio/pluggy_interface.py
class ChangeDetectionSpec (line 14) | class ChangeDetectionSpec:
method ui_edit_stats_extras (line 18) | def ui_edit_stats_extras(watch):
method register_content_fetcher (line 30) | def register_content_fetcher(self):
method fetcher_status_icon (line 42) | def fetcher_status_icon(fetcher_name):
method plugin_static_path (line 55) | def plugin_static_path(self):
method get_itemprop_availability_override (line 64) | def get_itemprop_availability_override(self, content, fetcher_name, fe...
method plugin_settings_tab (line 89) | def plugin_settings_tab(self):
method register_processor (line 109) | def register_processor(self):
method update_handler_alter (line 133) | def update_handler_alter(update_handler, watch, datastore):
method update_finalize (line 154) | def update_finalize(update_handler, watch, datastore, processing_excep...
function load_plugins_from_directories (line 185) | def load_plugins_from_directories():
function inject_datastore_into_plugins (line 218) | def inject_datastore_into_plugins(datastore):
function register_builtin_fetchers (line 234) | def register_builtin_fetchers():
function collect_ui_edit_stats_extras (line 256) | def collect_ui_edit_stats_extras(watch):
function collect_fetcher_status_icons (line 271) | def collect_fetcher_status_icons(fetcher_name):
function get_itemprop_availability_from_plugin (line 291) | def get_itemprop_availability_from_plugin(content, fetcher_name, fetcher...
function get_active_plugins (line 324) | def get_active_plugins():
function get_fetcher_capabilities (line 362) | def get_fetcher_capabilities(watch, datastore):
function get_plugin_settings_tabs (line 417) | def get_plugin_settings_tabs():
function load_plugin_settings (line 446) | def load_plugin_settings(datastore_path, plugin_id):
function save_plugin_settings (line 470) | def save_plugin_settings(datastore_path, plugin_id, settings):
function get_plugin_template_paths (line 494) | def get_plugin_template_paths():
function apply_update_handler_alter (line 550) | def apply_update_handler_alter(update_handler, watch, datastore):
function apply_update_finalize (line 583) | def apply_update_finalize(update_handler, watch, datastore, processing_e...
FILE: changedetectionio/processors/__init__.py
function find_sub_packages (line 9) | def find_sub_packages(package_name):
function find_processors (line 21) | def find_processors():
function get_parent_module (line 71) | def get_parent_module(module):
function get_custom_watch_obj_for_processor (line 85) | def get_custom_watch_obj_for_processor(processor_name):
function find_processor_module (line 99) | def find_processor_module(processor_name):
function get_processor_module (line 119) | def get_processor_module(processor_name):
function get_processor_submodule (line 140) | def get_processor_submodule(processor_name, submodule_name):
function get_plugin_processor_metadata (line 176) | def get_plugin_processor_metadata():
function _available_processors_cached (line 194) | def _available_processors_cached(locale_str):
function available_processors (line 260) | def available_processors():
function get_default_processor (line 277) | def get_default_processor():
function get_processor_badge_texts (line 291) | def get_processor_badge_texts():
function get_processor_descriptions (line 314) | def get_processor_descriptions():
function generate_processor_badge_colors (line 344) | def generate_processor_badge_colors(processor_name):
function get_processor_badge_css (line 384) | def get_processor_badge_css():
function save_processor_config (line 416) | def save_processor_config(datastore, watch_uuid, config_data):
function extract_processor_config_from_form_data (line 460) | def extract_processor_config_from_form_data(form_data):
FILE: changedetectionio/processors/base.py
class difference_detection_processor (line 18) | class difference_detection_processor():
method __init__ (line 29) | def __init__(self, datastore, watch_uuid):
method update_last_raw_content_checksum (line 46) | def update_last_raw_content_checksum(self, checksum):
method read_last_raw_content_checksum (line 72) | def read_last_raw_content_checksum(self):
method validate_iana_url (line 101) | async def validate_iana_url(self):
method call_browser (line 118) | async def call_browser(self, preferred_proxy_id=None):
method get_extra_watch_config (line 275) | def get_extra_watch_config(self, filename):
method update_extra_watch_config (line 306) | def update_extra_watch_config(self, filename, data, merge=True):
method get_raw_document_checksum (line 353) | def get_raw_document_checksum(self):
method run_changedetection (line 362) | def run_changedetection(self, watch, force_reprocess=False):
FILE: changedetectionio/processors/exceptions.py
class ProcessorException (line 1) | class ProcessorException(Exception):
method __init__ (line 2) | def __init__(self, message=None, status_code=None, url=None, screensho...
FILE: changedetectionio/processors/extract.py
function render_form (line 14) | def render_form(watch, datastore, request, url_for, render_template, fla...
function process_extraction (line 72) | def process_extraction(watch, datastore, request, url_for, make_response...
FILE: changedetectionio/processors/image_ssim_diff/difference.py
function get_asset (line 27) | def get_asset(asset_name, watch, datastore, request):
function _detect_mime_type (line 160) | def _detect_mime_type(img_bytes):
function _draw_bounding_box_if_configured (line 185) | def _draw_bounding_box_if_configured(img_bytes, watch, datastore):
function render (line 306) | def render(watch, datastore, request, url_for, render_template, flash, r...
FILE: changedetectionio/processors/image_ssim_diff/edit_hook.py
function on_config_save (line 22) | def on_config_save(watch, processor_config, datastore):
function analyze_region_features (line 106) | def analyze_region_features(screenshot_bytes, x, y, width, height):
function save_template_to_file (line 132) | def save_template_to_file(watch, screenshot_bytes, x, y, width, height):
FILE: changedetectionio/processors/image_ssim_diff/forms.py
function validate_bounding_box (line 13) | def validate_bounding_box(form, field):
function validate_selection_mode (line 34) | def validate_selection_mode(form, field):
class processor_settings_form (line 43) | class processor_settings_form(processor_text_json_diff_form):
method extra_tab_content (line 85) | def extra_tab_content(self):
method extra_form_content (line 89) | def extra_form_content(self):
FILE: changedetectionio/processors/image_ssim_diff/image_handler/__init__.py
class ImageDiffHandler (line 12) | class ImageDiffHandler(ABC):
method load_from_bytes (line 21) | def load_from_bytes(self, img_bytes: bytes) -> Any:
method save_to_bytes (line 34) | def save_to_bytes(self, img: Any, format: str = 'png', quality: int = ...
method crop (line 49) | def crop(self, img: Any, left: int, top: int, right: int, bottom: int)...
method resize (line 66) | def resize(self, img: Any, max_width: int, max_height: int) -> Any:
method get_dimensions (line 81) | def get_dimensions(self, img: Any) -> Tuple[int, int]:
method to_grayscale (line 94) | def to_grayscale(self, img: Any) -> Any:
method gaussian_blur (line 107) | def gaussian_blur(self, img: Any, sigma: float) -> Any:
method absolute_difference (line 121) | def absolute_difference(self, img1: Any, img2: Any) -> Any:
method threshold (line 135) | def threshold(self, img: Any, threshold_value: int) -> Tuple[float, Any]:
method apply_red_overlay (line 151) | def apply_red_overlay(self, img: Any, mask: Any) -> bytes:
method close (line 165) | def close(self, img: Any) -> None:
method find_template (line 175) | def find_template(
method save_template (line 197) | def save_template(
method draw_bounding_box (line 217) | def draw_bounding_box(
FILE: changedetectionio/processors/image_ssim_diff/image_handler/isolated_libvips.py
function _worker_generate_diff (line 21) | def _worker_generate_diff(conn, img_bytes_from, img_bytes_to, threshold,...
function generate_diff_isolated (line 78) | def generate_diff_isolated(img_bytes_from, img_bytes_to, threshold, blur...
function calculate_change_percentage_isolated (line 132) | def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, t...
function compare_images_isolated (line 217) | def compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blu...
FILE: changedetectionio/processors/image_ssim_diff/image_handler/isolated_opencv.py
function _worker_compare (line 16) | def _worker_compare(conn, img_bytes_from, img_bytes_to, pixel_difference...
function compare_images_isolated (line 109) | async def compare_images_isolated(img_bytes_from, img_bytes_to, pixel_di...
function _worker_generate_diff (line 200) | def _worker_generate_diff(conn, img_bytes_from, img_bytes_to, pixel_diff...
function generate_diff_isolated (line 273) | async def generate_diff_isolated(img_bytes_from, img_bytes_to, pixel_dif...
function _worker_draw_bounding_box (line 355) | def _worker_draw_bounding_box(conn, img_bytes, x, y, width, height, colo...
function draw_bounding_box_isolated (line 393) | async def draw_bounding_box_isolated(img_bytes, x, y, width, height, col...
function _worker_calculate_percentage (line 486) | def _worker_calculate_percentage(conn, img_bytes_from, img_bytes_to, pix...
function calculate_change_percentage_isolated (line 548) | async def calculate_change_percentage_isolated(img_bytes_from, img_bytes...
FILE: changedetectionio/processors/image_ssim_diff/image_handler/libvips_handler.py
class LibvipsImageDiffHandler (line 26) | class LibvipsImageDiffHandler(ImageDiffHandler):
method __init__ (line 37) | def __init__(self):
method load_from_bytes (line 41) | def load_from_bytes(self, img_bytes: bytes) -> pyvips.Image:
method save_to_bytes (line 45) | def save_to_bytes(self, img: pyvips.Image, format: str = 'png', qualit...
method crop (line 88) | def crop(self, img: pyvips.Image, left: int, top: int, right: int, bot...
method resize (line 94) | def resize(self, img: pyvips.Image, max_width: int, max_height: int) -...
method get_dimensions (line 118) | def get_dimensions(self, img: pyvips.Image) -> Tuple[int, int]:
method to_grayscale (line 122) | def to_grayscale(self, img: pyvips.Image) -> pyvips.Image:
method gaussian_blur (line 126) | def gaussian_blur(self, img: pyvips.Image, sigma: float) -> pyvips.Image:
method absolute_difference (line 132) | def absolute_difference(self, img1: pyvips.Image, img2: pyvips.Image) ...
method threshold (line 140) | def threshold(self, img: pyvips.Image, threshold_value: int) -> Tuple[...
method apply_red_overlay (line 157) | def apply_red_overlay(self, img: pyvips.Image, mask: pyvips.Image) -> ...
method close (line 213) | def close(self, img: pyvips.Image) -> None:
method find_template (line 221) | def find_template(
method save_template (line 310) | def save_template(
FILE: changedetectionio/processors/image_ssim_diff/preview.py
function get_asset (line 11) | def get_asset(asset_name, watch, datastore, request):
function render (line 72) | def render(watch, datastore, request, url_for, render_template, flash, r...
FILE: changedetectionio/processors/image_ssim_diff/processor.py
function _ (line 20) | def _(x): return x
class perform_site_check (line 27) | class perform_site_check(difference_detection_processor):
method run_changedetection (line 33) | def run_changedetection(self, watch, force_reprocess=False):
FILE: changedetectionio/processors/magic.py
class guess_stream_type (line 49) | class guess_stream_type():
method __init__ (line 59) | def __init__(self, http_content_header, content):
FILE: changedetectionio/processors/restock_diff/__init__.py
class Restock (line 14) | class Restock(dict):
method parse_currency (line 16) | def parse_currency(self, raw_value: str) -> Union[float, None]:
method __init__ (line 39) | def __init__(self, *args, **kwargs):
method __setitem__ (line 58) | def __setitem__(self, key, value):
class Watch (line 66) | class Watch(BaseWatch):
method __init__ (line 67) | def __init__(self, *arg, **kw):
method clear_watch (line 72) | def clear_watch(self):
method extra_notification_token_values (line 76) | def extra_notification_token_values(self):
method extra_notification_token_placeholder_info (line 81) | def extra_notification_token_placeholder_info(self):
FILE: changedetectionio/processors/restock_diff/forms.py
class RestockSettingsForm (line 14) | class RestockSettingsForm(Form):
class processor_settings_form (line 33) | class processor_settings_form(processor_text_json_diff_form):
method extra_tab_content (line 36) | def extra_tab_content(self):
method extra_form_content (line 39) | def extra_form_content(self):
FILE: changedetectionio/processors/restock_diff/processor.py
function _ (line 13) | def _(x): return x # Translation marker for extraction only
class UnableToExtractRestockData (line 20) | class UnableToExtractRestockData(Exception):
method __init__ (line 21) | def __init__(self, status_code):
class MoreThanOnePriceFound (line 26) | class MoreThanOnePriceFound(Exception):
method __init__ (line 27) | def __init__(self):
function _search_prop_by_value (line 30) | def _search_prop_by_value(matches, value):
function _deduplicate_prices (line 36) | def _deduplicate_prices(data):
function _extract_itemprop_availability_worker (line 131) | def _extract_itemprop_availability_worker(pipe_conn):
function extract_itemprop_availability_safe (line 203) | def extract_itemprop_availability_safe(html_content) -> Restock:
function get_itemprop_availability (line 315) | def get_itemprop_availability(html_content) -> Restock:
function is_between (line 388) | def is_between(number, lower=None, upper=None):
class perform_site_check (line 403) | class perform_site_check(difference_detection_processor):
method run_changedetection (line 407) | def run_changedetection(self, watch, force_reprocess=False):
FILE: changedetectionio/processors/restock_diff/pure_python_extractor.py
class JSONLDExtractor (line 21) | class JSONLDExtractor(HTMLParser):
method __init__ (line 29) | def __init__(self):
method handle_starttag (line 35) | def handle_starttag(self, tag, attrs):
method handle_data (line 44) | def handle_data(self, data):
method handle_endtag (line 48) | def handle_endtag(self, tag):
class OpenGraphExtractor (line 68) | class OpenGraphExtractor(HTMLParser):
method __init__ (line 75) | def __init__(self):
method handle_starttag (line 79) | def handle_starttag(self, tag, attrs):
class MicrodataExtractor (line 91) | class MicrodataExtractor(HTMLParser):
method __init__ (line 100) | def __init__(self):
method handle_starttag (line 105) | def handle_starttag(self, tag, attrs):
method handle_data (line 133) | def handle_data(self, data):
method handle_endtag (line 152) | def handle_endtag(self, tag):
function extract_metadata_pure_python (line 157) | def extract_metadata_pure_python(html_content):
function query_price_availability (line 210) | def query_price_availability(extracted_data):
FILE: changedetectionio/processors/text_json_diff/__init__.py
function _task (line 12) | def _task(watch, update_handler):
function prepare_filter_prevew (line 38) | def prepare_filter_prevew(datastore, watch_uuid, form_data):
FILE: changedetectionio/processors/text_json_diff/difference.py
function build_diff_cell_visualizer (line 23) | def build_diff_cell_visualizer(content, resolution=100):
function render (line 102) | def render(watch, datastore, request, url_for, render_template, flash, r...
FILE: changedetectionio/processors/text_json_diff/processor.py
function _ (line 22) | def _(x): return x
class FilterNotFoundInResponse (line 34) | class FilterNotFoundInResponse(ValueError):
method __init__ (line 35) | def __init__(self, msg, screenshot=None, xpath_data=None):
class PDFToHTMLToolNotFound (line 41) | class PDFToHTMLToolNotFound(ValueError):
method __init__ (line 42) | def __init__(self, msg):
class FilterConfig (line 46) | class FilterConfig:
method __init__ (line 49) | def __init__(self, watch, datastore):
method _get_merged_rules (line 57) | def _get_merged_rules(self, attr, include_global=False):
method include_filters (line 70) | def include_filters(self):
method subtractive_selectors (line 80) | def subtractive_selectors(self):
method extract_text (line 89) | def extract_text(self):
method ignore_text (line 93) | def ignore_text(self):
method trigger_text (line 97) | def trigger_text(self):
method text_should_not_be_present (line 101) | def text_should_not_be_present(self):
method has_include_filters (line 105) | def has_include_filters(self):
method has_include_json_filters (line 109) | def has_include_json_filters(self):
method has_subtractive_selectors (line 113) | def has_subtractive_selectors(self):
class ContentTransformer (line 117) | class ContentTransformer:
method trim_whitespace (line 121) | def trim_whitespace(text):
method remove_duplicate_lines (line 127) | def remove_duplicate_lines(text):
method sort_alphabetically (line 132) | def sort_alphabetically(text):
method extract_by_regex (line 139) | def extract_by_regex(text, regex_patterns):
class RuleEngine (line 169) | class RuleEngine:
method evaluate_trigger_text (line 173) | def evaluate_trigger_text(content, trigger_patterns):
method evaluate_text_should_not_be_present (line 192) | def evaluate_text_should_not_be_present(content, patterns):
method evaluate_conditions (line 209) | def evaluate_conditions(watch, datastore, content):
class ContentProcessor (line 227) | class ContentProcessor:
method __init__ (line 230) | def __init__(self, fetcher, watch, filter_config, datastore):
method preprocess_rss (line 236) | def preprocess_rss(self, content):
method preprocess_pdf (line 253) | def preprocess_pdf(self, raw_content):
method preprocess_json (line 281) | def preprocess_json(self, raw_content):
method apply_include_filters (line 295) | def apply_include_filters(self, content, stream_content_type):
method apply_subtractive_selectors (line 343) | def apply_subtractive_selectors(self, content):
method extract_text_from_html (line 347) | def extract_text_from_html(self, html_content, stream_content_type):
class ChecksumCalculator (line 358) | class ChecksumCalculator:
method calculate (line 362) | def calculate(text, ignore_whitespace=False):
class perform_site_check (line 371) | class perform_site_check(difference_detection_processor):
method run_changedetection (line 373) | def run_changedetection(self, watch, force_reprocess=False):
method _apply_diff_filtering (line 597) | def _apply_diff_filtering(self, watch, stripped_text, text_before_filt...
FILE: changedetectionio/queue_handlers.py
class RecheckPriorityQueue (line 15) | class RecheckPriorityQueue:
method __init__ (line 41) | def __init__(self, maxsize: int = 0):
method put (line 65) | def put(self, item, block: bool = True, timeout: Optional[float] = None):
method get (line 102) | def get(self, block: bool = True, timeout: Optional[float] = None):
method async_put (line 137) | async def async_put(self, item, executor=None):
method async_get (line 161) | async def async_get(self, executor=None, timeout=1.0):
method qsize (line 205) | def qsize(self) -> int:
method empty (line 214) | def empty(self) -> bool:
method get_queued_uuids (line 218) | def get_queued_uuids(self) -> list:
method clear (line 227) | def clear(self):
method close (line 252) | def close(self):
method queue (line 262) | def queue(self):
method get_uuid_position (line 271) | def get_uuid_position(self, target_uuid: str) -> Dict[str, Any]:
method get_all_queued_uuids (line 301) | def get_all_queued_uuids(self, limit: Optional[int] = None, offset: in...
method get_queue_summary (line 336) | def get_queue_summary(self) -> Dict[str, Any]:
method _get_item_uuid (line 379) | def _get_item_uuid(self, item) -> str:
method _emit_put_signals (line 388) | def _emit_put_signals(self, item):
method _emit_get_signals (line 404) | def _emit_get_signals(self):
class NotificationQueue (line 413) | class NotificationQueue:
method __init__ (line 427) | def __init__(self, maxsize: int = 0, datastore=None):
method set_datastore (line 439) | def set_datastore(self, datastore):
method put (line 443) | def put(self, item: Dict[str, Any], block: bool = True, timeout: Optio...
method async_put (line 461) | async def async_put(self, item: Dict[str, Any], executor=None):
method get (line 484) | def get(self, block: bool = True, timeout: Optional[float] = None):
method async_get (line 499) | async def async_get(self, executor=None):
method qsize (line 519) | def qsize(self) -> int:
method empty (line 528) | def empty(self) -> bool:
method close (line 532) | def close(self):
method _emit_notification_signal (line 540) | def _emit_notification_signal(self, item: Dict[str, Any]):
FILE: changedetectionio/queuedWatchMetaData.py
class PrioritizedItem (line 8) | class PrioritizedItem:
FILE: changedetectionio/realtime/events.py
function register_watch_operation_handlers (line 6) | def register_watch_operation_handlers(socketio, datastore):
FILE: changedetectionio/realtime/socket_server.py
class SignalHandler (line 14) | class SignalHandler:
method __init__ (line 17) | def __init__(self, socketio_instance, datastore):
method handle_watch_small_status_update (line 46) | def handle_watch_small_status_update(self, *args, **kwargs):
method handle_signal (line 62) | def handle_signal(self, *args, **kwargs):
method handle_watch_bumped_favicon_signal (line 85) | def handle_watch_bumped_favicon_signal(self, *args, **kwargs):
method handle_deleted_signal (line 95) | def handle_deleted_signal(self, *args, **kwargs):
method handle_queue_length (line 105) | def handle_queue_length(self, *args, **kwargs):
method handle_notification_event (line 120) | def handle_notification_event(self, *args, **kwargs):
function handle_watch_update (line 139) | def handle_watch_update(socketio, **kwargs):
function _suppress_werkzeug_ws_abrupt_disconnect_noise (line 202) | def _suppress_werkzeug_ws_abrupt_disconnect_noise():
function init_socketio (line 223) | def init_socketio(app, datastore):
FILE: changedetectionio/rss_tools.py
function cdata_in_document_to_text (line 9) | def cdata_in_document_to_text(html_content: str, render_anchor_tag_conte...
function format_rss_items (line 150) | def format_rss_items(rss_content: str, render_anchor_tag_content=False) ...
FILE: changedetectionio/static/js/browser-steps.js
function reset (line 37) | function reset() {
function set_scale (line 58) | function set_scale() {
function process_selected (line 188) | function process_selected(selected_in_xpath_list) {
function draw_circle_on_canvas (line 231) | function draw_circle_on_canvas(x, y) {
function executeBrowserStep (line 239) | function executeBrowserStep(url, data = {}) {
function start (line 281) | function start() {
function disable_browsersteps_ui (line 315) | function disable_browsersteps_ui() {
function set_greyed_state (line 355) | function set_greyed_state() {
FILE: changedetectionio/static/js/comparison-slider.js
function initComparisonSlider (line 10) | function initComparisonSlider() {
FILE: changedetectionio/static/js/conditions.js
function setupButtonHandlers (line 3) | function setupButtonHandlers() {
function reindexRules (line 126) | function reindexRules() {
FILE: changedetectionio/static/js/diff-overview.js
function setupDiffNavigation (line 1) | function setupDiffNavigation() {
function toggle (line 97) | function toggle(hash_name) {
function clean (line 154) | function clean(event) {
function dragTextHandler (line 165) | function dragTextHandler(event) {
FILE: changedetectionio/static/js/diff-render.js
function updateVisualizerPosition (line 71) | function updateVisualizerPosition() {
function handleResize (line 101) | function handleResize() {
function changed (line 147) | function changed() {
FILE: changedetectionio/static/js/flask-toast-bridge.js
function getMessageCategory (line 61) | function getMessageCategory(messageEl) {
function mapCategoryToToastType (line 88) | function mapCategoryToToastType(category) {
FILE: changedetectionio/static/js/hamburger-menu.js
function openMenu (line 14) | function openMenu() {
function closeMenu (line 21) | function closeMenu() {
function toggleMenu (line 28) | function toggleMenu() {
FILE: changedetectionio/static/js/plugins.js
function complete (line 20) | function complete() {
function escapeHtml (line 73) | function escapeHtml(text) {
function toggleOpacity (line 166) | function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
function toggleVisibility (line 182) | function toggleVisibility(checkboxSelector, fieldSelector, inverted) {
FILE: changedetectionio/static/js/preview.js
function redirectToVersion (line 1) | function redirectToVersion(version) {
function setupDateWidget (line 14) | function setupDateWidget() {
FILE: changedetectionio/static/js/realtime.js
function reapplyTableStripes (line 5) | function reapplyTableStripes() {
function bindSocketHandlerButtonsEvents (line 12) | function bindSocketHandlerButtonsEvents(socket) {
FILE: changedetectionio/static/js/recheck-proxy.js
function setup_html_widget (line 5) | function setup_html_widget() {
function set_proxy_check_status (line 13) | function set_proxy_check_status(proxy_key, state) {
function pollServer (line 31) | function pollServer() {
FILE: changedetectionio/static/js/scheduler.js
function getTimeInTimezone (line 1) | function getTimeInTimezone(timezone) {
FILE: changedetectionio/static/js/search-modal.js
function openSearchModal (line 17) | function openSearchModal() {
function closeSearchModal (line 28) | function closeSearchModal() {
FILE: changedetectionio/static/js/snippet-to-image.js
constant IMAGE_PADDING (line 7) | const IMAGE_PADDING = 5;
constant JPEG_QUALITY (line 8) | const JPEG_QUALITY = 0.95;
constant CANVAS_SCALE (line 9) | const CANVAS_SCALE = 1;
constant RENDER_DELAY_MS (line 10) | const RENDER_DELAY_MS = 50;
function getTargetUrl (line 15) | function getTargetUrl() {
function getFormattedDate (line 22) | function getFormattedDate() {
function getVersionInfo (line 37) | function getVersionInfo() {
function findTextNodeWithNewline (line 56) | function findTextNodeWithNewline(node, searchBackwards = false) {
function findLineBoundary (line 80) | function findLineBoundary(node, container, searchBackwards = false) {
function getLastTextNode (line 104) | function getLastTextNode(container) {
function expandRangeToFullLines (line 118) | function expandRangeToFullLines(range, container) {
function createCaptureElement (line 163) | function createCaptureElement(selectedFragment, originalElement) {
function countLines (line 233) | function countLines(content) {
function createFooter (line 251) | function createFooter(selectedLines, totalLines) {
function addExifMetadata (line 282) | function addExifMetadata(jpegDataUrl) {
function dataURLtoBlob (line 314) | function dataURLtoBlob(dataURL) {
function downloadImage (line 329) | function downloadImage(jpegDataUrl) {
function copyImageToClipboard (line 339) | async function copyImageToClipboard(jpegDataUrl) {
function shareImage (line 355) | async function shareImage(platform, jpegDataUrl) {
function displayImage (line 411) | function displayImage(jpegDataUrl) {
function setButtonState (line 614) | function setButtonState(button, isLoading, originalHtml = '') {
function diffToJpeg (line 638) | async function diffToJpeg() {
FILE: changedetectionio/static/js/tabs.js
function set_active_tab (line 24) | function set_active_tab() {
function focus_error_tab (line 32) | function focus_error_tab() {
FILE: changedetectionio/static/js/toast.js
function init (line 37) | function init(userConfig = {}) {
function createContainer (line 45) | function createContainer() {
function show (line 57) | function show(message, options = {}) {
function createToastElement (line 89) | function createToastElement(message, options) {
function createIcon (line 131) | function createIcon(type) {
function setupAutoDismiss (line 174) | function setupAutoDismiss(toast, duration) {
function removeToast (line 219) | function removeToast(toast) {
function success (line 233) | function success(message, options = {}) {
function error (line 237) | function error(message, options = {}) {
function warning (line 241) | function warning(message, options = {}) {
function info (line 245) | function info(message, options = {}) {
function clear (line 252) | function clear() {
FILE: changedetectionio/static/js/vis.js
function show_related_elem (line 5) | function show_related_elem(e) {
FILE: changedetectionio/static/js/visual-selector.js
function clearReset (line 49) | function clearReset() {
function splitToList (line 65) | function splitToList(v) {
function sortScrapedElementsBySize (line 69) | function sortScrapedElementsBySize() {
function bootstrapVisualSelector (line 105) | function bootstrapVisualSelector() {
function alertIfFilterNotFound (line 126) | function alertIfFilterNotFound() {
function fetchData (line 138) | function fetchData() {
function updateFiltersText (line 164) | function updateFiltersText() {
function setScale (line 175) | function setScale() {
function reflowSelector (line 197) | function reflowSelector() {
function highlightCurrentSelected (line 270) | function highlightCurrentSelected() {
function initializeDrawMode (line 284) | function initializeDrawMode() {
function enableDrawMode (line 345) | function enableDrawMode() {
function disableDrawMode (line 377) | function disableDrawMode() {
function handleDrawMouseDown (line 423) | function handleDrawMouseDown(e) {
function handleDrawMouseMove (line 455) | function handleDrawMouseMove(e) {
function handleDrawMouseUp (line 494) | function handleDrawMouseUp(e) {
function drawBox (line 528) | function drawBox() {
function drawResizeHandles (line 554) | function drawResizeHandles(x, y, w, h) {
function isInsideBox (line 572) | function isInsideBox(x, y) {
function getResizeHandle (line 583) | function getResizeHandle(x, y) {
function getHandleCursor (line 607) | function getHandleCursor(handle) {
function resizeBox (line 617) | function resizeBox(x, y) {
FILE: changedetectionio/static/js/watch-overview.js
function normalizeUrl (line 2) | function normalizeUrl(el) {
FILE: changedetectionio/static/js/watch-settings.js
function request_textpreview_update (line 2) | function request_textpreview_update() {
FILE: changedetectionio/store/__init__.py
class ChangeDetectionStore (line 50) | class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
method __init__ (line 53) | def __init__(self, datastore_path="/datastore", include_default_watche...
method save_version_copy_json_db (line 64) | def save_version_copy_json_db(self, version_tag):
method _load_settings (line 82) | def _load_settings(self, filename="changedetection.json"):
method _apply_settings (line 102) | def _apply_settings(self, settings_data):
method _rehydrate_tags (line 136) | def _rehydrate_tags(self):
method _rehydrate_watches (line 151) | def _rehydrate_watches(self):
method _load_state (line 169) | def _load_state(self, main_settings_filename="changedetection.json"):
method reload_state (line 190) | def reload_state(self, datastore_path, include_default_watches, versio...
method init_fresh_install (line 248) | def init_fresh_install(self, include_default_watches, version_tag):
method rehydrate_entity (line 321) | def rehydrate_entity(self, uuid, entity, processor_override=None):
method _watch_exists (line 341) | def _watch_exists(self, uuid):
method _get_watch_dict (line 345) | def _get_watch_dict(self, uuid):
method _build_settings_data (line 349) | def _build_settings_data(self):
method _save_settings (line 376) | def _save_settings(self):
method _load_watches (line 391) | def _load_watches(self):
method _load_tags (line 408) | def _load_tags(self):
method _delete_watch (line 439) | def _delete_watch(self, uuid):
method set_last_viewed (line 458) | def set_last_viewed(self, uuid, timestamp):
method remove_password (line 467) | def remove_password(self):
method clear_all_last_checksums (line 471) | def clear_all_last_checksums(self):
method clear_checksums_for_tag (line 499) | def clear_checksums_for_tag(self, tag_uuid):
method commit (line 528) | def commit(self):
method update_watch (line 543) | def update_watch(self, uuid, update_obj):
method threshold_seconds (line 564) | def threshold_seconds(self):
method unread_changes_count (line 573) | def unread_changes_count(self):
method data (line 582) | def data(self):
method delete (line 600) | def delete(self, uuid):
method clone (line 649) | def clone(self, uuid):
method url_exists (line 660) | def url_exists(self, url):
method clear_watch_history (line 670) | def clear_watch_history(self, uuid):
method add_watch (line 674) | def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_imm...
method _watch_resource_exists (line 796) | def _watch_resource_exists(self, watch_uuid, resource_name):
method visualselector_data_is_ready (line 812) | def visualselector_data_is_ready(self, watch_uuid):
method proxy_list (line 826) | def proxy_list(self):
method get_preferred_proxy_for_watch (line 855) | def get_preferred_proxy_for_watch(self, uuid):
method has_extra_headers_file (line 889) | def has_extra_headers_file(self):
method get_all_base_headers (line 893) | def get_all_base_headers(self):
method get_all_headers_in_textfile_for_watch (line 900) | def get_all_headers_in_textfile_for_watch(self, uuid):
method get_tag_overrides_for_watch (line 936) | def get_tag_overrides_for_watch(self, uuid, attr):
method add_tag (line 947) | def add_tag(self, title):
method get_all_tags_for_watch (line 980) | def get_all_tags_for_watch(self, uuid):
method extra_browsers (line 991) | def extra_browsers(self):
method tag_exists_by_name (line 1002) | def tag_exists_by_name(self, tag_name):
method any_watches_have_processor_by_name (line 1008) | def any_watches_have_processor_by_name(self, processor_name):
method search_watches_for_url (line 1014) | def search_watches_for_url(self, query, tag_limit=None, partial=False):
method get_unique_notification_tokens_available (line 1049) | def get_unique_notification_tokens_available(self):
method get_unique_notification_token_placeholders_available (line 1062) | def get_unique_notification_token_placeholders_available(self):
method add_notification_url (line 1075) | def add_notification_url(self, notification_url):
FILE: changedetectionio/store/base.py
class DataStore (line 12) | class DataStore(ABC):
method reload_state (line 27) | def reload_state(self, datastore_path, include_default_watches, versio...
method add_watch (line 39) | def add_watch(self, url, **kwargs):
method update_watch (line 53) | def update_watch(self, uuid, update_obj):
method delete (line 64) | def delete(self, uuid):
method data (line 75) | def data(self):
FILE: changedetectionio/store/file_saving_datastore.py
function save_json_atomic (line 36) | def save_json_atomic(file_path, data_dict, label="file", max_size_mb=10):
function save_entity_atomic (line 178) | def save_entity_atomic(entity_dir, uuid, entity_dict, filename, entity_t...
function save_watch_atomic (line 200) | def save_watch_atomic(watch_dir, uuid, watch_dict):
function load_watch_from_file (line 211) | def load_watch_from_file(watch_json, uuid, rehydrate_entity_func):
function load_all_watches (line 273) | def load_all_watches(datastore_path, rehydrate_entity_func):
function load_tag_from_file (line 335) | def load_tag_from_file(tag_json, uuid, rehydrate_entity_func):
function load_all_tags (line 399) | def load_all_tags(datastore_path, rehydrate_entity_func):
class FileSavingDataStore (line 455) | class FileSavingDataStore(DataStore):
method __init__ (line 469) | def __init__(self):
method _save_settings (line 472) | def _save_settings(self):
method _load_watches (line 484) | def _load_watches(self):
method _delete_watch (line 495) | def _delete_watch(self, uuid):
FILE: changedetectionio/store/updates.py
function create_backup_tarball (line 32) | def create_backup_tarball(datastore_path, update_number):
class DatastoreUpdatesMixin (line 120) | class DatastoreUpdatesMixin:
method get_updates_available (line 128) | def get_updates_available(self):
method run_updates (line 145) | def run_updates(self, current_schema_version=None):
method update_1 (line 245) | def update_1(self):
method update_2 (line 258) | def update_2(self):
method update_3 (line 285) | def update_3(self):
method update_4 (line 290) | def update_4(self):
method update_5 (line 300) | def update_5(self):
method update_7 (line 322) | def update_7(self):
method update_8 (line 332) | def update_8(self):
method update_9 (line 343) | def update_9(self):
method update_10 (line 382) | def update_10(self):
method update_12 (line 392) | def update_12(self):
method update_13 (line 405) | def update_13(self):
method update_14 (line 414) | def update_14(self):
method update_15 (line 424) | def update_15(self):
method update_16 (line 436) | def update_16(self):
method update_17 (line 442) | def update_17(self):
method update_18 (line 449) | def update_18(self):
method update_19 (line 463) | def update_19(self):
method update_20 (line 478) | def update_20(self):
method update_21 (line 496) | def update_21(self):
method update_23 (line 502) | def update_23(self):
method update_24 (line 536) | def update_24(self):
method update_25 (line 548) | def update_25(self):
method migrate_legacy_db_format (line 560) | def migrate_legacy_db_format(self):
method update_26 (line 669) | def update_26(self):
method update_29 (line 673) | def update_29(self):
method update_30 (line 733) | def update_30(self):
FILE: changedetectionio/strtobool.py
function strtobool (line 19) | def strtobool(value):
FILE: changedetectionio/tests/apprise/test_apprise_asset.py
function apprise_asset (line 13) | def apprise_asset() -> AppriseAsset:
function test_apprise_asset_init (line 19) | def test_apprise_asset_init(apprise_asset: AppriseAsset):
FILE: changedetectionio/tests/apprise/test_apprise_custom_api_call.py
function test_get_auth (line 26) | def test_get_auth(url, expected_auth):
function test_get_headers (line 45) | def test_get_headers(url, body, expected_content_type):
function test_get_params (line 66) | def test_get_params(url, expected_params):
function test_apprise_custom_api_call_success (line 82) | def test_apprise_custom_api_call_success(mock_request, url, schema, meth...
function test_apprise_custom_api_call_with_auth (line 100) | def test_apprise_custom_api_call_with_auth(mock_request):
function test_apprise_custom_api_call_failure (line 129) | def test_apprise_custom_api_call_failure(mock_request, exception_type, e...
function test_invalid_url_parsing (line 144) | def test_invalid_url_parsing():
function test_http_methods (line 162) | def test_http_methods(mock_request, schema, expected_method):
function test_https_method_conversion (line 190) | def test_https_method_conversion(
FILE: changedetectionio/tests/conftest.py
function reportlog (line 35) | def reportlog(pytestconfig):
function per_test_log_file (line 43) | def per_test_log_file(request):
function pytest_runtest_makereport (line 120) | def pytest_runtest_makereport(item, call):
function environment (line 130) | def environment(mocker):
function format_memory_human (line 140) | def format_memory_human(bytes_value):
function track_memory (line 151) | def track_memory(memory_usage, ):
function measure_memory_usage (line 160) | def measure_memory_usage(request):
function cleanup (line 183) | def cleanup(datastore_path):
function pytest_configure (line 195) | def pytest_configure(config):
function pytest_addoption (line 223) | def pytest_addoption(parser):
function datastore_path (line 237) | def datastore_path(tmp_path_factory, request):
function prepare_test_function (line 266) | def prepare_test_function(live_server, datastore_path):
function set_test_name (line 395) | def set_test_name(request):
function app (line 404) | def app(request, datastore_path):
FILE: changedetectionio/tests/custom_browser_url/test_custom_browser_url.py
function do_test (line 7) | def do_test(client, live_server, make_test_use_extra_browser=False):
function test_request_via_custom_browser_url (line 76) | def test_request_via_custom_browser_url(client, live_server, measure_mem...
function test_request_not_via_custom_browser_url (line 82) | def test_request_not_via_custom_browser_url(client, live_server, measure...
FILE: changedetectionio/tests/fetchers/test_content.py
function test_fetch_webdriver_content (line 11) | def test_fetch_webdriver_content(client, live_server, measure_memory_usa...
FILE: changedetectionio/tests/fetchers/test_custom_js_before_content.py
function test_execute_custom_js (line 6) | def test_execute_custom_js(client, live_server, measure_memory_usage, da...
FILE: changedetectionio/tests/plugins/test_processor.py
function test_check_plugin_processor (line 8) | def test_check_plugin_processor(client, live_server, measure_memory_usag...
FILE: changedetectionio/tests/proxy_list/test_multiple_proxy.py
function test_preferred_proxy (line 8) | def test_preferred_proxy(client, live_server, measure_memory_usage, data...
FILE: changedetectionio/tests/proxy_list/test_noproxy.py
function test_noproxy_option (line 8) | def test_noproxy_option(client, live_server, measure_memory_usage, datas...
FILE: changedetectionio/tests/proxy_list/test_proxy.py
function test_check_basic_change_detection_functionality (line 8) | def test_check_basic_change_detection_functionality(client, live_server,...
FILE: changedetectionio/tests/proxy_list/test_proxy_noconnect.py
function test_proxy_noconnect_custom (line 15) | def test_proxy_noconnect_custom(client, live_server, measure_memory_usag...
FILE: changedetectionio/tests/proxy_list/test_select_custom_proxy.py
function test_select_custom (line 9) | def test_select_custom(client, live_server, measure_memory_usage, datast...
function test_custom_proxy_validation (line 53) | def test_custom_proxy_validation(client, live_server, measure_memory_usa...
FILE: changedetectionio/tests/proxy_socks5/test_socks5_proxy.py
function set_response (line 8) | def set_response(datastore_path):
function test_socks5 (line 22) | def test_socks5(client, live_server, measure_memory_usage, datastore_path):
FILE: changedetectionio/tests/proxy_socks5/test_socks5_proxy_sources.py
function set_response (line 7) | def set_response(datastore_path):
function test_socks5_from_proxiesjson_file (line 23) | def test_socks5_from_proxiesjson_file(client, live_server, measure_memor...
FILE: changedetectionio/tests/restock/test_restock.py
function set_original_response (line 14) | def set_original_response(datastore_path):
function set_back_in_stock_response (line 35) | def set_back_in_stock_response(datastore_path):
function test_restock_detection (line 53) | def test_restock_detection(client, live_server, measure_memory_usage, da...
FILE: changedetectionio/tests/smtp/smtp-test-server.py
class CustomSMTPHandler (line 16) | class CustomSMTPHandler:
method handle_DATA (line 17) | async def handle_DATA(self, server, session, envelope):
function echo_last_message (line 75) | def echo_last_message():
function run_flask (line 93) | def run_flask():
FILE: changedetectionio/tests/smtp/test_notification_smtp.py
function get_last_message_from_smtp_server (line 30) | def get_last_message_from_smtp_server():
function test_check_notification_email_formats_default_HTML (line 44) | def test_check_notification_email_formats_default_HTML(client, live_serv...
function test_check_notification_plaintext_format (line 115) | def test_check_notification_plaintext_format(client, live_server, measur...
function test_check_notification_html_color_format (line 177) | def test_check_notification_html_color_format(client, live_server, measu...
function test_check_notification_markdown_format (line 262) | def test_check_notification_markdown_format(client, live_server, measure...
function test_check_notification_email_formats_default_Text_override_HTML (line 344) | def test_check_notification_email_formats_default_Text_override_HTML(cli...
function test_check_plaintext_document_plaintext_notification_smtp (line 465) | def test_check_plaintext_document_plaintext_notification_smtp(client, li...
function test_check_plaintext_document_html_notifications (line 518) | def test_check_plaintext_document_html_notifications(client, live_server...
function test_check_plaintext_document_html_color_notifications (line 598) | def test_check_plaintext_document_html_color_notifications(client, live_...
function test_check_html_document_plaintext_notification (line 671) | def test_check_html_document_plaintext_notification(client, live_server,...
function test_check_html_notification_with_apprise_format_is_html (line 728) | def test_check_html_notification_with_apprise_format_is_html(client, liv...
FILE: changedetectionio/tests/test_access_control.py
function test_check_access_control (line 5) | def test_check_access_control(app, client, live_server, measure_memory_u...
FILE: changedetectionio/tests/test_add_replace_remove_filter.py
function set_original (line 13) | def set_original(datastore_path, excluding=None, add_line=None):
function test_check_removed_line_contains_trigger (line 45) | def test_check_removed_line_contains_trigger(client, live_server, measur...
function test_check_add_line_contains_trigger (line 114) | def test_check_add_line_contains_trigger(client, live_server, measure_me...
FILE: changedetectionio/tests/test_api.py
function set_original_response (line 12) | def set_original_response(datastore_path):
function set_modified_response (line 30) | def set_modified_response(datastore_path):
function is_valid_uuid (line 48) | def is_valid_uuid(val):
function test_api_simple (line 60) | def test_api_simple(client, live_server, measure_memory_usage, datastore...
function test_roundtrip_API (line 339) | def test_roundtrip_API(client, live_server, measure_memory_usage, datast...
function test_access_denied (line 401) | def test_access_denied(client, live_server, measure_memory_usage, datast...
function test_api_watch_PUT_update (line 446) | def test_api_watch_PUT_update(client, live_server, measure_memory_usage,...
function test_api_import (line 562) | def test_api_import(client, live_server, measure_memory_usage, datastore...
function test_api_import_small_synchronous (line 707) | def test_api_import_small_synchronous(client, live_server, measure_memor...
function test_api_import_large_background (line 741) | def test_api_import_large_background(client, live_server, measure_memory...
function test_api_restock_processor_config (line 818) | def test_api_restock_processor_config(client, live_server, measure_memor...
function test_api_conflict_UI_password (line 900) | def test_api_conflict_UI_password(client, live_server, measure_memory_us...
function test_api_url_validation (line 944) | def test_api_url_validation(client, live_server, measure_memory_usage, d...
function test_api_time_between_check_validation (line 1117) | def test_api_time_between_check_validation(client, live_server, measure_...
FILE: changedetectionio/tests/test_api_notification_urls_validation.py
function test_watch_notification_urls_validation (line 25) | def test_watch_notification_urls_validation(client, live_server, measure...
function test_tag_notification_urls_validation (line 121) | def test_tag_notification_urls_validation(client, live_server, measure_m...
FILE: changedetectionio/tests/test_api_notifications.py
function test_api_notifications_crud (line 7) | def test_api_notifications_crud(client, live_server, measure_memory_usag...
FILE: changedetectionio/tests/test_api_openapi.py
function test_openapi_merged_spec_contains_restock_fields (line 15) | def test_openapi_merged_spec_contains_restock_fields():
function test_openapi_validation_invalid_content_type_on_create_watch (line 59) | def test_openapi_validation_invalid_content_type_on_create_watch(client,...
function test_openapi_validation_missing_required_field_create_watch (line 77) | def test_openapi_validation_missing_required_field_create_watch(client, ...
function test_openapi_validation_invalid_field_in_request_body (line 95) | def test_openapi_validation_invalid_field_in_request_body(client, live_s...
function test_openapi_validation_import_wrong_content_type (line 135) | def test_openapi_validation_import_wrong_content_type(client, live_serve...
function test_openapi_validation_import_correct_content_type_succeeds (line 153) | def test_openapi_validation_import_correct_content_type_succeeds(client,...
function test_openapi_validation_get_requests_bypass_validation (line 171) | def test_openapi_validation_get_requests_bypass_validation(client, live_...
function test_openapi_validation_create_tag_missing_required_title (line 196) | def test_openapi_validation_create_tag_missing_required_title(client, li...
function test_openapi_validation_watch_update_allows_partial_updates (line 214) | def test_openapi_validation_watch_update_allows_partial_updates(client, ...
FILE: changedetectionio/tests/test_api_search.py
function test_api_search (line 9) | def test_api_search(client, live_server, measure_memory_usage, datastore...
FILE: changedetectionio/tests/test_api_security.py
function set_original_response (line 16) | def set_original_response(datastore_path):
function is_valid_uuid (line 29) | def is_valid_uuid(val):
function test_api_path_traversal_in_uuids (line 41) | def test_api_path_traversal_in_uuids(client, live_server, measure_memory...
function test_api_injection_via_headers_and_proxy (line 107) | def test_api_injection_via_headers_and_proxy(client, live_server, measur...
function test_api_large_payload_dos (line 179) | def test_api_large_payload_dos(client, live_server, measure_memory_usage...
function test_api_utf8_encoding_edge_cases (line 250) | def test_api_utf8_encoding_edge_cases(client, live_server, measure_memor...
function test_api_concurrency_race_conditions (line 324) | def test_api_concurrency_race_conditions(client, live_server, measure_me...
function test_api_time_validation_edge_cases (line 403) | def test_api_time_validation_edge_cases(client, live_server, measure_mem...
function test_api_browser_steps_validation (line 480) | def test_api_browser_steps_validation(client, live_server, measure_memor...
function test_api_queue_manipulation (line 553) | def test_api_queue_manipulation(client, live_server, measure_memory_usag...
function test_api_history_edge_cases (line 597) | def test_api_history_edge_cases(client, live_server, measure_memory_usag...
function test_api_notification_edge_cases (line 656) | def test_api_notification_edge_cases(client, live_server, measure_memory...
function test_api_tag_edge_cases (line 708) | def test_api_tag_edge_cases(client, live_server, measure_memory_usage, d...
function test_api_authentication_edge_cases (line 758) | def test_api_authentication_edge_cases(client, live_server, measure_memo...
FILE: changedetectionio/tests/test_api_tags.py
function test_api_tags_listing (line 8) | def test_api_tags_listing(client, live_server, measure_memory_usage, dat...
function test_api_tag_restock_processor_config (line 179) | def test_api_tag_restock_processor_config(client, live_server, measure_m...
function test_roundtrip_API (line 270) | def test_roundtrip_API(client, live_server, measure_memory_usage, datast...
FILE: changedetectionio/tests/test_auth.py
function test_basic_auth (line 8) | def test_basic_auth(client, live_server, measure_memory_usage, datastore...
FILE: changedetectionio/tests/test_automatic_follow_ldjson_price.py
function set_response_with_ldjson (line 9) | def set_response_with_ldjson(datastore_path):
function set_response_without_ldjson (line 63) | def set_response_without_ldjson(datastore_path):
function test_check_ldjson_price_autodetect (line 84) | def test_check_ldjson_price_autodetect(client, live_server, measure_memo...
function _test_runner_check_bad_format_ignored (line 137) | def _test_runner_check_bad_format_ignored(live_server, client, has_ldjso...
function test_bad_ldjson_is_correctly_ignored (line 153) | def test_bad_ldjson_is_correctly_ignored(client, live_server, measure_me...
FILE: changedetectionio/tests/test_backend.py
function test_inscriptus (line 11) | def test_inscriptus():
function test_check_basic_change_detection_functionality (line 19) | def test_check_basic_change_detection_functionality(client, live_server,...
function test_title_scraper (line 151) | def test_title_scraper(client, live_server, measure_memory_usage, datast...
function test_title_scraper_html_only (line 189) | def test_title_scraper_html_only(client, live_server, measure_memory_usa...
function test_requests_timeout (line 210) | def test_requests_timeout(client, live_server, measure_memory_usage, dat...
function test_non_text_mime_or_downloads (line 248) | def test_non_text_mime_or_downloads(client, live_server, measure_memory_...
function test_standard_text_plain (line 307) | def test_standard_text_plain(client, live_server, measure_memory_usage, ...
function test_plaintext_even_if_xml_content (line 368) | def test_plaintext_even_if_xml_content(client, live_server, measure_memo...
function test_plaintext_even_if_xml_content_and_can_apply_filters (line 396) | def test_plaintext_even_if_xml_content_and_can_apply_filters(client, liv...
FILE: changedetectionio/tests/test_backup.py
function test_backup (line 12) | def test_backup(client, live_server, measure_memory_usage, datastore_path):
function test_watch_data_package_download (line 83) | def test_watch_data_package_download(client, live_server, measure_memory...
function test_backup_restore (line 122) | def test_backup_restore(client, live_server, measure_memory_usage, datas...
function test_backup_restore_zip_slip_rejected (line 205) | def test_backup_restore_zip_slip_rejected(client, live_server, measure_m...
function test_backup_restore_zip_bomb_rejected (line 229) | def test_backup_restore_zip_bomb_rejected(client, live_server, measure_m...
FILE: changedetectionio/tests/test_basic_socketio.py
function run_socketio_watch_update_test (line 13) | def run_socketio_watch_update_test(client, live_server, password_mode=""...
function test_everything (line 109) | def test_everything(live_server, client, measure_memory_usage, datastore...
FILE: changedetectionio/tests/test_block_while_text_present.py
function set_original_ignore_response (line 9) | def set_original_ignore_response(datastore_path):
function set_modified_original_ignore_response (line 26) | def set_modified_original_ignore_response(datastore_path):
function set_modified_response_minus_block_text (line 46) | def set_modified_response_minus_block_text(datastore_path):
function test_check_block_changedetection_text_NOT_present (line 65) | def test_check_block_changedetection_text_NOT_present(client, live_serve...
FILE: changedetectionio/tests/test_clone.py
function test_clone_functionality (line 9) | def test_clone_functionality(client, live_server, measure_memory_usage, ...
FILE: changedetectionio/tests/test_commit_persistence.py
function test_watch_commit_persists_to_disk (line 24) | def test_watch_commit_persists_to_disk(client, live_server):
function test_watch_commit_survives_reload (line 49) | def test_watch_commit_survives_reload(client, live_server):
function test_watch_commit_atomic_on_crash (line 80) | def test_watch_commit_atomic_on_crash(client, live_server):
function test_multiple_watches_commit_independently (line 107) | def test_multiple_watches_commit_independently(client, live_server):
function test_concurrent_watch_commits_dont_corrupt (line 151) | def test_concurrent_watch_commits_dont_corrupt(client, live_server):
function test_concurrent_modifications_during_commit (line 188) | def test_concurrent_modifications_during_commit(client, live_server):
function test_datastore_lock_protects_commit_snapshot (line 235) | def test_datastore_lock_protects_commit_snapshot(client, live_server):
function test_processor_config_never_in_watch_json (line 283) | def test_processor_config_never_in_watch_json(client, live_server):
function test_api_post_saves_processor_config_separately (line 318) | def test_api_post_saves_processor_config_separately(client, live_server):
function test_api_put_saves_processor_config_separately (line 358) | def test_api_put_saves_processor_config_separately(client, live_server):
function test_ui_edit_saves_processor_config_separately (line 398) | def test_ui_edit_saves_processor_config_separately(client, live_server):
function test_browser_steps_normalized_to_empty_list (line 430) | def test_browser_steps_normalized_to_empty_list(client, live_server):
function test_settings_persist_after_update (line 458) | def test_settings_persist_after_update(client, live_server):
function test_tag_mute_persists (line 485) | def test_tag_mute_persists(client, live_server):
function test_tag_delete_removes_from_watches (line 515) | def test_tag_delete_removes_from_watches(client, live_server):
function test_watch_pause_unpause_persists (line 560) | def test_watch_pause_unpause_persists(client, live_server):
function test_watch_mute_unmute_persists (line 589) | def test_watch_mute_unmute_persists(client, live_server):
function test_ui_watch_edit_persists_all_fields (line 618) | def test_ui_watch_edit_persists_all_fields(client, live_server):
FILE: changedetectionio/tests/test_conditions.py
function set_original_response (line 11) | def set_original_response(datastore_path, number="50"):
function set_number_in_range_response (line 24) | def set_number_in_range_response(datastore_path, number="75"):
function set_number_out_of_range_response (line 37) | def set_number_out_of_range_response(datastore_path, number="150"):
function test_conditions_with_text_and_number (line 55) | def test_conditions_with_text_and_number(client, live_server, measure_me...
function test_condition_validate_rule_row (line 141) | def test_condition_validate_rule_row(client, live_server, measure_memory...
function test_wordcount_conditions_plugin (line 197) | def test_wordcount_conditions_plugin(client, live_server, measure_memory...
function test_lev_conditions_plugin (line 231) | def test_lev_conditions_plugin(client, live_server, measure_memory_usage...
FILE: changedetectionio/tests/test_css_selector.py
function set_original_response (line 12) | def set_original_response(datastore_path):
function set_modified_response (line 29) | def set_modified_response(datastore_path):
function test_include_filters_output (line 49) | def test_include_filters_output():
function test_check_markup_include_filters_restriction (line 73) | def test_check_markup_include_filters_restriction(client, live_server, m...
function test_check_multiple_filters (line 120) | def test_check_multiple_filters(client, live_server, measure_memory_usag...
function test_filter_is_empty_help_suggestion (line 169) | def test_filter_is_empty_help_suggestion(client, live_server, measure_me...
FILE: changedetectionio/tests/test_datastore_isolation.py
function test_client_and_live_server_share_datastore (line 4) | def test_client_and_live_server_share_datastore(client, live_server):
FILE: changedetectionio/tests/test_element_removal.py
function set_response_with_multiple_index (line 14) | def set_response_with_multiple_index(datastore_path):
function set_original_response (line 44) | def set_original_response(datastore_path):
function set_modified_response (line 73) | def set_modified_response(datastore_path):
function test_element_removal_output (line 102) | def test_element_removal_output():
function test_element_removal_full (line 150) | def test_element_removal_full(client, live_server, measure_memory_usage,...
function test_element_removal_nth_offset_no_shift (line 212) | def test_element_removal_nth_offset_no_shift(client, live_server, measur...
FILE: changedetectionio/tests/test_encoding.py
function test_surrogate_characters_in_content_are_sanitized (line 15) | def test_surrogate_characters_in_content_are_sanitized():
function test_utf8_content_without_charset_header (line 36) | def test_utf8_content_without_charset_header(client, live_server, datast...
function test_shiftjis_with_meta_charset (line 59) | def test_shiftjis_with_meta_charset(client, live_server, datastore_path):
function set_html_response (line 78) | def set_html_response(datastore_path):
function test_check_encoding_detection (line 91) | def test_check_encoding_detection(client, live_server, measure_memory_us...
function test_check_encoding_detection_missing_content_type_header (line 119) | def test_check_encoding_detection_missing_content_type_header(client, li...
FILE: changedetectionio/tests/test_errorhandling.py
function _runner_test_http_errors (line 12) | def _runner_test_http_errors(client, live_server, http_code, expected_te...
function test_http_error_handler (line 50) | def test_http_error_handler(client, live_server, measure_memory_usage, d...
function test_DNS_errors (line 58) | def test_DNS_errors(client, live_server, measure_memory_usage, datastore...
function test_low_level_errors_clear_correctly (line 87) | def test_low_level_errors_clear_correctly(client, live_server, measure_m...
FILE: changedetectionio/tests/test_extract_csv.py
function test_check_extract_text_from_diff (line 9) | def test_check_extract_text_from_diff(client, live_server, measure_memor...
FILE: changedetectionio/tests/test_extract_regex.py
function set_original_response (line 11) | def set_original_response(datastore_path):
function set_modified_response (line 29) | def set_modified_response(datastore_path):
function set_multiline_response (line 49) | def set_multiline_response(datastore_path):
function test_check_filter_multiline (line 74) | def test_check_filter_multiline(client, live_server, measure_memory_usag...
function test_check_filter_and_regex_extract (line 123) | def test_check_filter_and_regex_extract(client, live_server, measure_mem...
function test_regex_error_handling (line 202) | def test_regex_error_handling(client, live_server, measure_memory_usage,...
FILE: changedetectionio/tests/test_filter_exist_changes.py
function set_response_without_filter (line 11) | def set_response_without_filter(datastore_path):
function set_response_with_filter (line 28) | def set_response_with_filter(datastore_path):
function test_filter_doesnt_exist_then_exists_should_get_notification (line 44) | def test_filter_doesnt_exist_then_exists_should_get_notification(client,...
FILE: changedetectionio/tests/test_filter_failure_notification.py
function set_response_with_filter (line 8) | def set_response_with_filter(datastore_path):
function run_filter_test (line 24) | def run_filter_test(client, live_server, content_filter, app_notificatio...
function test_check_include_filters_failure_notification (line 186) | def test_check_include_filters_failure_notification(client, live_server,...
function test_check_xpath_filter_failure_notification (line 193) | def test_check_xpath_filter_failure_notification(client, live_server, me...
function test_basic_markup_from_text (line 200) | def test_basic_markup_from_text(client, live_server, measure_memory_usag...
FILE: changedetectionio/tests/test_group.py
function set_original_response (line 14) | def set_original_response(datastore_path):
function set_modified_response (line 29) | def set_modified_response(datastore_path):
function test_setup_group_tag (line 44) | def test_setup_group_tag(client, live_server, measure_memory_usage, data...
function test_tag_import_singular (line 134) | def test_tag_import_singular(client, live_server, measure_memory_usage, ...
function test_tag_add_in_ui (line 154) | def test_tag_add_in_ui(client, live_server, measure_memory_usage, datast...
function test_group_tag_notification (line 170) | def test_group_tag_notification(client, live_server, measure_memory_usag...
function test_limit_tag_ui (line 240) | def test_limit_tag_ui(client, live_server, measure_memory_usage, datasto...
function test_clone_tag_on_import (line 278) | def test_clone_tag_on_import(client, live_server, measure_memory_usage, ...
function test_clone_tag_on_quickwatchform_add (line 303) | def test_clone_tag_on_quickwatchform_add(client, live_server, measure_me...
function test_order_of_filters_tag_filter_and_watch_filter (line 333) | def test_order_of_filters_tag_filter_and_watch_filter(client, live_serve...
function test_tag_json_persistence (line 481) | def test_tag_json_persistence(client, live_server, measure_memory_usage,...
function test_tag_json_migration_update_27 (line 566) | def test_tag_json_migration_update_27(client, live_server, measure_memor...
FILE: changedetectionio/tests/test_history_consistency.py
function test_consistent_history (line 13) | def test_consistent_history(client, live_server, measure_memory_usage, d...
function test_check_text_history_view (line 154) | def test_check_text_history_view(client, live_server, measure_memory_usa...
function test_history_trim_global_only (line 194) | def test_history_trim_global_only(client, live_server, measure_memory_us...
function test_history_trim_global_override_in_watch (line 226) | def test_history_trim_global_override_in_watch(client, live_server, meas...
FILE: changedetectionio/tests/test_html_to_text.py
function test_html_to_text_func (line 6) | def test_html_to_text_func():
FILE: changedetectionio/tests/test_i18n.py
function test_zh_TW (line 7) | def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
function test_zh_Hant_TW_timeago_integration (line 43) | def test_zh_Hant_TW_timeago_integration():
function test_language_switching (line 71) | def test_language_switching(client, live_server, measure_memory_usage, d...
function test_invalid_locale (line 125) | def test_invalid_locale(client, live_server, measure_memory_usage, datas...
function test_language_persistence_in_session (line 160) | def test_language_persistence_in_session(client, live_server, measure_me...
function test_set_language_with_redirect (line 216) | def test_set_language_with_redirect(client, live_server, measure_memory_...
function test_time_unit_translations (line 258) | def test_time_unit_translations(client, live_server, measure_memory_usag...
function test_accept_language_header_zh_tw (line 358) | def test_accept_language_header_zh_tw(client, live_server, measure_memor...
function test_accept_language_header_en_variants (line 407) | def test_accept_language_header_en_variants(client, live_server, measure...
function test_accept_language_header_zh_simplified (line 474) | def test_accept_language_header_zh_simplified(client, live_server, measu...
function test_session_locale_overrides_accept_language (line 546) | def test_session_locale_overrides_accept_language(client, live_server, m...
function test_clear_history_translated_confirmation (line 629) | def test_clear_history_translated_confirmation(client, live_server, meas...
FILE: changedetectionio/tests/test_ignore.py
function set_original_ignore_response (line 10) | def set_original_ignore_response(datastore_path):
function test_ignore (line 27) | def test_ignore(client, live_server, measure_memory_usage, datastore_path):
function test_strip_ignore_lines (line 59) | def test_strip_ignore_lines(client, live_server, measure_memory_usage, d...
FILE: changedetectionio/tests/test_ignore_regex_text.py
function test_strip_regex_text_func (line 10) | def test_strip_regex_text_func():
FILE: changedetectionio/tests/test_ignore_text.py
function test_strip_text_func (line 13) | def test_strip_text_func():
function set_original_ignore_response (line 35) | def set_original_ignore_response(datastore_path, ver_stamp="123"):
function set_modified_original_ignore_response (line 52) | def set_modified_original_ignore_response(datastore_path, ver_stamp="123"):
function set_modified_ignore_response (line 72) | def set_modified_ignore_response(datastore_path, ver_stamp="123"):
function test_check_ignore_text_functionality (line 92) | def test_check_ignore_text_functionality(client, live_server, measure_me...
function _run_test_global_ignore (line 173) | def _run_test_global_ignore(client, datastore_path, as_source=False, ext...
function test_check_global_ignore_text_functionality (line 255) | def test_check_global_ignore_text_functionality(client, live_server, mea...
function test_check_global_ignore_text_functionality_as_source (line 259) | def test_check_global_ignore_text_functionality_as_source(client, live_s...
FILE: changedetectionio/tests/test_ignorehyperlinks.py
function set_original_ignore_response (line 10) | def set_original_ignore_response(datastore_path):
function set_modified_ignore_response (line 27) | def set_modified_ignore_response(datastore_path):
function test_render_anchor_tag_content_true (line 41) | def test_render_anchor_tag_content_true(client, live_server, measure_mem...
FILE: changedetectionio/tests/test_ignorestatuscode.py
function set_original_response (line 12) | def set_original_response(datastore_path):
function set_some_changed_response (line 27) | def set_some_changed_response(datastore_path):
function test_normal_page_check_works_with_ignore_status_code (line 42) | def test_normal_page_check_works_with_ignore_status_code(client, live_se...
function test_403_page_check_works_with_ignore_status_code (line 102) | def test_403_page_check_works_with_ignore_status_code(client, live_serve...
FILE: changedetectionio/tests/test_ignorewhitespace.py
function set_original_ignore_response_but_with_whitespace (line 11) | def set_original_ignore_response_but_with_whitespace(datastore_path):
function set_original_ignore_response (line 33) | def set_original_ignore_response(datastore_path):
function test_check_ignore_whitespace (line 51) | def test_check_ignore_whitespace(client, live_server, measure_memory_usa...
FILE: changedetectionio/tests/test_import.py
function test_import (line 14) | def test_import(client, live_server, measure_memory_usage, datastore_path):
function xtest_import_skip_url (line 37) | def xtest_import_skip_url(client, live_server, measure_memory_usage, dat...
function test_import_distillio (line 60) | def test_import_distillio(client, live_server, measure_memory_usage, dat...
function test_import_custom_xlsx (line 126) | def test_import_custom_xlsx(client, live_server, measure_memory_usage, d...
function test_import_watchete_xlsx (line 174) | def test_import_watchete_xlsx(client, live_server, measure_memory_usage,...
FILE: changedetectionio/tests/test_jinja2.py
function test_jinja2_in_url_query (line 14) | def test_jinja2_in_url_query(client, live_server, measure_memory_usage, ...
function test_jinja2_time_offset_in_url_query (line 39) | def test_jinja2_time_offset_in_url_query(client, live_server, measure_me...
function test_jinja2_security_url_query (line 69) | def test_jinja2_security_url_query(client, live_server, measure_memory_u...
function test_timezone (line 83) | def test_timezone(mocker):
function test_format (line 94) | def test_format(mocker):
function test_add_time (line 106) | def test_add_time(environment):
function test_add_weekday (line 113) | def test_add_weekday(mocker):
function test_substract_time (line 125) | def test_substract_time(environment):
function test_offset_with_format (line 133) | def test_offset_with_format(environment):
function test_default_timezone_empty_string (line 142) | def test_default_timezone_empty_string(environment):
function test_default_timezone_with_offset (line 151) | def test_default_timezone_with_offset(environment):
function test_default_timezone_subtraction (line 159) | def test_default_timezone_subtraction(environment):
function test_regex_replace_basic (line 166) | def test_regex_replace_basic():
function test_regex_replace_with_groups (line 173) | def test_regex_replace_with_groups():
function test_regex_replace_multiple_matches (line 182) | def test_regex_replace_multiple_matches():
function test_regex_replace_count_parameter (line 188) | def test_regex_replace_count_parameter():
function test_regex_replace_empty_replacement (line 194) | def test_regex_replace_empty_replacement():
function test_regex_replace_no_match (line 200) | def test_regex_replace_no_match():
function test_regex_replace_invalid_regex (line 206) | def test_regex_replace_invalid_regex():
function test_regex_replace_special_characters (line 213) | def test_regex_replace_special_characters():
function test_regex_replace_multiline (line 219) | def test_regex_replace_multiline():
function test_regex_replace_with_notification_context (line 227) | def test_regex_replace_with_notification_context():
function test_regex_replace_security_large_input (line 247) | def test_regex_replace_security_large_input():
function test_regex_replace_security_long_pattern (line 264) | def test_regex_replace_security_long_pattern():
function test_regex_replace_security_dangerous_pattern (line 274) | def test_regex_replace_security_dangerous_pattern():
function test_regex_replace_security_timeout_protection (line 299) | def test_regex_replace_security_timeout_protection():
FILE: changedetectionio/tests/test_jsonpath_jq_selector.py
function test_jsonp_treated_as_plaintext (line 19) | def test_jsonp_treated_as_plaintext():
function test_jsonp_json_filter_extraction (line 42) | def test_jsonp_json_filter_extraction():
function test_unittest_inline_html_extract (line 64) | def test_unittest_inline_html_extract():
function test_unittest_inline_extract_body (line 124) | def test_unittest_inline_extract_body():
function set_original_ext_response (line 141) | def set_original_ext_response(datastore_path):
function set_modified_ext_response (line 162) | def set_modified_ext_response(datastore_path):
function set_original_response (line 177) | def set_original_response(datastore_path):
function set_json_response_with_html (line 203) | def set_json_response_with_html(datastore_path):
function set_modified_response (line 217) | def set_modified_response(datastore_path):
function test_check_json_without_filter (line 244) | def test_check_json_without_filter(client, live_server, measure_memory_u...
function check_json_filter (line 268) | def check_json_filter(json_filter, client, live_server, datastore_path):
function test_check_jsonpath_filter (line 310) | def test_check_jsonpath_filter(client, live_server, measure_memory_usage...
function test_check_jq_filter (line 313) | def test_check_jq_filter(client, live_server, measure_memory_usage, data...
function test_check_jqraw_filter (line 317) | def test_check_jqraw_filter(client, live_server, measure_memory_usage, d...
function check_json_filter_bool_val (line 321) | def check_json_filter_bool_val(json_filter, client, live_server, datasto...
function test_check_jsonpath_filter_bool_val (line 344) | def test_check_jsonpath_filter_bool_val(client, live_server, measure_mem...
function test_check_jq_filter_bool_val (line 348) | def test_check_jq_filter_bool_val(client, live_server, measure_memory_us...
function test_check_jqraw_filter_bool_val (line 353) | def test_check_jqraw_filter_bool_val(client, live_server, measure_memory...
function check_json_ext_filter (line 363) | def check_json_ext_filter(json_filter, client, live_server, datastore_pa...
function test_ignore_json_order (line 429) | def test_ignore_json_order(client, live_server, measure_memory_usage, da...
function test_correct_header_detect (line 466) | def test_correct_header_detect(client, live_server, measure_memory_usage...
function test_check_jsonpath_ext_filter (line 502) | def test_check_jsonpath_ext_filter(client, live_server, measure_memory_u...
function test_check_jq_ext_filter (line 506) | def test_check_jq_ext_filter(client, live_server, measure_memory_usage, ...
function test_check_jqraw_ext_filter (line 511) | def test_check_jqraw_ext_filter(client, live_server, measure_memory_usag...
function test_jsonpath_BOM_utf8 (line 516) | def test_jsonpath_BOM_utf8(client, live_server, measure_memory_usage, da...
FILE: changedetectionio/tests/test_live_preview.py
function set_response (line 8) | def set_response(datastore_path):
function test_content_filter_live_preview (line 22) | def test_content_filter_live_preview(client, live_server, measure_memory...
FILE: changedetectionio/tests/test_nonrenderable_pages.py
function set_nonrenderable_response (line 9) | def set_nonrenderable_response(datastore_path):
function set_zero_byte_response (line 23) | def set_zero_byte_response(datastore_path):
function test_check_basic_change_detection_functionality (line 29) | def test_check_basic_change_detection_functionality(client, live_server,...
FILE: changedetectionio/tests/test_notification.py
function test_check_notification (line 25) | def test_check_notification(client, live_server, measure_memory_usage, d...
function test_notification_urls_jinja2_apprise_integration (line 276) | def test_notification_urls_jinja2_apprise_integration(client, live_serve...
function test_notification_custom_endpoint_and_jinja2 (line 303) | def test_notification_custom_endpoint_and_jinja2(client, live_server, me...
function test_global_send_test_notification (line 389) | def test_global_send_test_notification(client, live_server, measure_memo...
function test_single_send_test_notification_on_watch (line 501) | def test_single_send_test_notification_on_watch(client, live_server, mea...
function _test_color_notifications (line 544) | def _test_color_notifications(client, notification_body_token, datastore...
function test_html_color_notifications (line 601) | def test_html_color_notifications(client, live_server, measure_memory_us...
FILE: changedetectionio/tests/test_notification_errors.py
function test_check_notification_error_handling (line 7) | def test_check_notification_error_handling(client, live_server, measure_...
FILE: changedetectionio/tests/test_obfuscations.py
function set_original_ignore_response (line 9) | def set_original_ignore_response(datastore_path):
function test_obfuscations (line 22) | def test_obfuscations(client, live_server, measure_memory_usage, datasto...
FILE: changedetectionio/tests/test_pdf.py
function test_fetch_pdf (line 10) | def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_...
FILE: changedetectionio/tests/test_preview_endpoints.py
function test_fetch_pdf (line 10) | def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_...
FILE: changedetectionio/tests/test_queue_handler.py
function test_queue_system (line 8) | def test_queue_system(client, live_server, measure_memory_usage, datasto...
FILE: changedetectionio/tests/test_request.py
function test_headers_in_request (line 11) | def test_headers_in_request(client, live_server, measure_memory_usage, d...
function test_body_in_request (line 80) | def test_body_in_request(client, live_server, measure_memory_usage, data...
function test_method_in_request (line 174) | def test_method_in_request(client, live_server, measure_memory_usage, da...
function test_ua_global_override (line 249) | def test_ua_global_override(client, live_server, measure_memory_usage, d...
function test_headers_textfile_in_request (line 300) | def test_headers_textfile_in_request(client, live_server, measure_memory...
function test_headers_validation (line 410) | def test_headers_validation(client, live_server, measure_memory_usage, d...
FILE: changedetectionio/tests/test_restock_itemprop.py
function set_original_response (line 24) | def set_original_response(datastore_path, props_markup='', price="121.95"):
function test_restock_itemprop_basic (line 43) | def test_restock_itemprop_basic(client, live_server, measure_memory_usag...
function test_itemprop_price_change (line 79) | def test_itemprop_price_change(client, live_server, measure_memory_usage...
function _run_test_minmax_limit (line 125) | def _run_test_minmax_limit(client, extra_watch_edit_form, datastore_path):
function test_restock_itemprop_minmax (line 204) | def test_restock_itemprop_minmax(client, live_server, measure_memory_usa...
function test_restock_itemprop_with_tag (line 213) | def test_restock_itemprop_with_tag(client, live_server, measure_memory_u...
function test_itemprop_percent_threshold (line 243) | def test_itemprop_percent_threshold(client, live_server, measure_memory_...
function test_change_with_notification_values (line 327) | def test_change_with_notification_values(client, live_server, measure_me...
function test_data_sanity (line 397) | def test_data_sanity(client, live_server, measure_memory_usage, datastor...
function test_special_prop_examples (line 445) | def test_special_prop_examples(client, live_server, measure_memory_usage...
function test_itemprop_as_str (line 472) | def test_itemprop_as_str(client, live_server, measure_memory_usage, data...
FILE: changedetectionio/tests/test_rss.py
function set_original_cdata_xml (line 11) | def set_original_cdata_xml(datastore_path):
function set_html_content (line 55) | def set_html_content(datastore_path, content):
function test_rss_feed_empty (line 71) | def test_rss_feed_empty(client, live_server, measure_memory_usage, datas...
function test_rss_and_token (line 82) | def test_rss_and_token(client, live_server, measure_memory_usage, datast...
function test_basic_cdata_rss_markup (line 112) | def test_basic_cdata_rss_markup(client, live_server, measure_memory_usag...
function test_rss_xpath_filtering (line 136) | def test_rss_xpath_filtering(client, live_server, measure_memory_usage, ...
function test_rss_bad_chars_breaking (line 185) | def test_rss_bad_chars_breaking(client, live_server, measure_memory_usag...
function test_rss_single_watch_feed (line 244) | def test_rss_single_watch_feed(client, live_server, measure_memory_usage...
FILE: changedetectionio/tests/test_rss_group.py
function set_original_response (line 9) | def set_original_response(datastore_path):
function set_modified_response (line 24) | def set_modified_response(datastore_path):
function test_rss_group (line 39) | def test_rss_group(client, live_server, measure_memory_usage, datastore_...
function test_rss_group_empty_tag (line 155) | def test_rss_group_empty_tag(client, live_server, measure_memory_usage, ...
function test_rss_group_only_unviewed (line 190) | def test_rss_group_only_unviewed(client, live_server, measure_memory_usa...
FILE: changedetectionio/tests/test_rss_reader_mode.py
function set_xmlns_purl_content (line 10) | def set_xmlns_purl_content(datastore_path, extra=""):
function set_original_cdata_xml (line 66) | def set_original_cdata_xml(datastore_path):
function test_rss_reader_mode (line 105) | def test_rss_reader_mode(client, live_server, measure_memory_usage, data...
function test_rss_reader_mode_with_css_filters (line 131) | def test_rss_reader_mode_with_css_filters(client, live_server, measure_m...
function test_xmlns_purl_content (line 157) | def test_xmlns_purl_content(client, live_server, measure_memory_usage, d...
FILE: changedetectionio/tests/test_rss_single_watch.py
function test_rss_feed_empty (line 14) | def test_rss_feed_empty(client, live_server, measure_memory_usage, datas...
function test_rss_single_watch_order (line 28) | def test_rss_single_watch_order(client, live_server, measure_memory_usag...
function test_rss_categories_from_tags (line 128) | def test_rss_categories_from_tags(client, live_server, measure_memory_us...
function test_rss_single_watch_follow_notification_body (line 269) | def test_rss_single_watch_follow_notification_body(client, live_server, ...
FILE: changedetectionio/tests/test_scheduler.py
function test_check_basic_scheduler_functionality (line 15) | def test_check_basic_scheduler_functionality(client, live_server, measur...
function test_check_basic_global_scheduler_functionality (line 93) | def test_check_basic_global_scheduler_functionality(client, live_server,...
function test_validation_time_interval_field (line 175) | def test_validation_time_interval_field(client, live_server, measure_mem...
FILE: changedetectionio/tests/test_search.py
function test_basic_search (line 7) | def test_basic_search(client, live_server, measure_memory_usage, datasto...
function test_search_in_tag_limit (line 40) | def test_search_in_tag_limit(client, live_server, measure_memory_usage, ...
FILE: changedetectionio/tests/test_security.py
function set_original_response (line 11) | def set_original_response(datastore_path):
function test_favicon (line 29) | def test_favicon(client, live_server, measure_memory_usage, datastore_pa...
function test_bad_access (line 53) | def test_bad_access(client, live_server, measure_memory_usage, datastore...
function _runner_test_various_file_slash (line 112) | def _runner_test_various_file_slash(client, file_uri):
function test_file_slash_access (line 140) | def test_file_slash_access(client, live_server, measure_memory_usage, da...
function test_xss (line 150) | def test_xss(client, live_server, measure_memory_usage, datastore_path):
function test_xss_watch_last_error (line 191) | def test_xss_watch_last_error(client, live_server, measure_memory_usage,...
function test_login_redirect_safe_urls (line 222) | def test_login_redirect_safe_urls(client, live_server, measure_memory_us...
function test_login_redirect_with_password (line 299) | def test_login_redirect_with_password(client, live_server, measure_memor...
function test_login_redirect_from_protected_page (line 377) | def test_login_redirect_from_protected_page(client, live_server, measure...
function test_logout_with_redirect (line 450) | def test_logout_with_redirect(client, live_server, measure_memory_usage,...
function test_static_directory_traversal (line 508) | def test_static_directory_traversal(client, live_server, measure_memory_...
function test_ssrf_private_ip_blocked (line 585) | def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measu...
function test_unresolvable_hostname_is_allowed (line 717) | def test_unresolvable_hostname_is_allowed(client, live_server, monkeypat...
FILE: changedetectionio/tests/test_settings_tag_force_reprocess.py
function test_settings_change_forces_reprocess (line 16) | def test_settings_change_forces_reprocess(client, live_server, measure_m...
function test_tag_change_forces_reprocess (line 82) | def test_tag_change_forces_reprocess(client, live_server, measure_memory...
function test_tag_change_via_api_forces_reprocess (line 152) | def test_tag_change_via_api_forces_reprocess(client, live_server, measur...
FILE: changedetectionio/tests/test_share_watch.py
function test_share_watch (line 9) | def test_share_watch(client, live_server, measure_memory_usage, datastor...
FILE: changedetectionio/tests/test_source.py
function test_check_basic_change_detection_functionality_source (line 10) | def test_check_basic_change_detection_functionality_source(client, live_...
function test_check_ignore_elements (line 54) | def test_check_ignore_elements(client, live_server, measure_memory_usage...
FILE: changedetectionio/tests/test_trigger.py
function set_original_ignore_response (line 9) | def set_original_ignore_response(datastore_path):
function set_modified_original_ignore_response (line 26) | def set_modified_original_ignore_response(datastore_path):
function set_modified_with_trigger_text_response (line 43) | def set_modified_with_trigger_text_response(datastore_path):
function test_trigger_functionality (line 62) | def test_trigger_functionality(client, live_server, measure_memory_usage...
FILE: changedetectionio/tests/test_trigger_regex.py
function set_original_ignore_response (line 9) | def set_original_ignore_response(datastore_path):
function test_trigger_regex_functionality (line 26) | def test_trigger_regex_functionality(client, live_server, measure_memory...
FILE: changedetectionio/tests/test_trigger_regex_with_filter.py
function set_original_ignore_response (line 10) | def set_original_ignore_response(datastore_path):
function test_trigger_regex_functionality_with_filter (line 27) | def test_trigger_regex_functionality_with_filter(client, live_server, me...
FILE: changedetectionio/tests/test_ui.py
function test_recheck_time_field_validation_global_settings (line 8) | def test_recheck_time_field_validation_global_settings(client, live_serv...
function test_recheck_time_field_validation_single_watch (line 31) | def test_recheck_time_field_validation_single_watch(client, live_server,...
function test_checkbox_open_diff_in_new_tab (line 100) | def test_checkbox_open_diff_in_new_tab(client, live_server, measure_memo...
function test_page_title_listing_behaviour (line 174) | def test_page_title_listing_behaviour(client, live_server, measure_memor...
function test_ui_viewed_unread_flag (line 250) | def test_ui_viewed_unread_flag(client, live_server, measure_memory_usage...
FILE: changedetectionio/tests/test_unique_lines.py
function set_original_ignore_response (line 9) | def set_original_ignore_response(datastore_path):
function set_modified_swapped_lines (line 26) | def set_modified_swapped_lines(datastore_path):
function set_modified_swapped_lines_with_extra_text_for_sorting (line 40) | def set_modified_swapped_lines_with_extra_text_for_sorting(datastore_path):
function set_modified_with_trigger_text_response (line 58) | def set_modified_with_trigger_text_response(datastore_path):
function test_unique_lines_functionality (line 75) | def test_unique_lines_functionality(client, live_server, measure_memory_...
function test_sort_lines_functionality (line 120) | def test_sort_lines_functionality(client, live_server, measure_memory_us...
function test_extra_filters (line 166) | def test_extra_filters(client, live_server, measure_memory_usage, datast...
FILE: changedetectionio/tests/test_watch_edited_flag.py
function set_test_content (line 16) | def set_test_content(datastore_path):
function test_watch_edited_flag_lifecycle (line 29) | def test_watch_edited_flag_lifecycle(client, live_server, measure_memory...
function test_watch_edited_flag_dict_methods (line 86) | def test_watch_edited_flag_dict_methods(client, live_server, measure_mem...
function test_watch_edited_flag_prevents_skip (line 146) | def test_watch_edited_flag_prevents_skip(client, live_server, measure_me...
function test_watch_edited_flag_system_fields (line 207) | def test_watch_edited_flag_system_fields(client, live_server, measure_me...
FILE: changedetectionio/tests/test_watch_fields_storage.py
function test_check_watch_field_storage (line 7) | def test_check_watch_field_storage(client, live_server, measure_memory_u...
FILE: changedetectionio/tests/test_xpath_default_namespace.py
class TestXPathDefaultNamespace (line 82) | class TestXPathDefaultNamespace:
method test_atom_feed_simple_xpath_with_xpath_filter (line 85) | def test_atom_feed_simple_xpath_with_xpath_filter(self):
method test_atom_feed_nested_xpath_with_xpath_filter (line 92) | def test_atom_feed_nested_xpath_with_xpath_filter(self):
method test_atom_feed_other_elements_with_xpath_filter (line 100) | def test_atom_feed_other_elements_with_xpath_filter(self):
method test_rss_feed_without_namespace (line 106) | def test_rss_feed_without_namespace(self):
method test_rss_feed_nested_xpath (line 113) | def test_rss_feed_nested_xpath(self):
method test_rss_feed_with_prefixed_namespaces (line 121) | def test_rss_feed_with_prefixed_namespaces(self):
method test_local_name_workaround_still_works (line 127) | def test_local_name_workaround_still_works(self):
method test_xpath1_filter_without_default_namespace (line 133) | def test_xpath1_filter_without_default_namespace(self):
method test_xpath1_filter_with_default_namespace_returns_empty (line 139) | def test_xpath1_filter_with_default_namespace_returns_empty(self):
method test_xpath1_filter_local_name_workaround (line 145) | def test_xpath1_filter_local_name_workaround(self):
FILE: changedetectionio/tests/test_xpath_selector.py
function set_rss_atom_feed_response (line 10) | def set_rss_atom_feed_response(datastore_path, header='', ):
function set_original_response (line 44) | def set_original_response(datastore_path):
function set_modified_response (line 62) | def set_modified_response(datastore_path):
function test_check_xpath_filter_utf8 (line 82) | def test_check_xpath_filter_utf8(client, live_server, measure_memory_usa...
function test_check_xpath_text_function_utf8 (line 133) | def test_check_xpath_text_function_utf8(client, live_server, measure_mem...
function test_check_markup_xpath_filter_restriction (line 191) | def test_check_markup_xpath_filter_restriction(client, live_server, meas...
function test_xpath_validation (line 232) | def test_xpath_validation(client, live_server, measure_memory_usage, dat...
function test_xpath23_prefix_validation (line 248) | def test_xpath23_prefix_validation(client, live_server, measure_memory_u...
function test_xpath1_lxml (line 263) | def test_xpath1_lxml(client, live_server, measure_memory_usage, datastor...
function test_xpath1_validation (line 323) | def test_xpath1_validation(client, live_server, measure_memory_usage, da...
function test_check_with_prefix_include_filters (line 340) | def test_check_with_prefix_include_filters(client, live_server, measure_...
function test_various_rules (line 372) | def test_various_rules(client, live_server, measure_memory_usage, datast...
function test_xpath_20 (line 416) | def test_xpath_20(client, live_server, measure_memory_usage, datastore_p...
function test_xpath_20_function_count (line 450) | def test_xpath_20_function_count(client, live_server, measure_memory_usa...
function test_xpath_20_function_count2 (line 483) | def test_xpath_20_function_count2(client, live_server, measure_memory_us...
function test_xpath_20_function_string_join_matches (line 518) | def test_xpath_20_function_string_join_matches(client, live_server, meas...
function _subtest_xpath_rss (line 552) | def _subtest_xpath_rss(client, datastore_path, content_type='text/html'):
function test_rss_xpath (line 590) | def test_rss_xpath(client, live_server, measure_memory_usage, datastore_...
function test_xpath_blocked_functions_unit (line 599) | def test_xpath_blocked_functions_unit():
function test_xpath_blocked_functions_form_validation (line 639) | def test_xpath_blocked_functions_form_validation(client, live_server, me...
FILE: changedetectionio/tests/test_xpath_selector_unit.py
function test_hotels (line 82) | def test_hotels(html_content, xpath, answer):
function test_branches_to_visit (line 145) | def test_branches_to_visit(html_content, xpath, answer):
function test_trips (line 202) | def test_trips(html_content, xpath, answer):
function test_xpath_utf8_encoding (line 256) | def test_xpath_utf8_encoding(html_content, xpath, expected_text):
function test_xpath1_utf8_encoding (line 278) | def test_xpath1_utf8_encoding(html_content, xpath, expected_text):
function test_wyborcza_real_world_example (line 308) | def test_wyborcza_real_world_example():
FILE: changedetectionio/tests/unit/test_conditions.py
class TestTriggerConditions (line 11) | class TestTriggerConditions(unittest.TestCase):
method setUp (line 12) | def setUp(self):
method tearDown (line 27) | def tearDown(self):
method test_conditions_execution_pass (line 33) | def test_conditions_execution_pass(self):
FILE: changedetectionio/tests/unit/test_html_to_text.py
class TestHtmlToText (line 14) | class TestHtmlToText(unittest.TestCase):
method test_basic_text_extraction (line 17) | def test_basic_text_extraction(self):
method test_empty_html (line 27) | def test_empty_html(self):
method test_nested_elements (line 35) | def test_nested_elements(self):
method test_anchor_tag_rendering (line 56) | def test_anchor_tag_rendering(self):
method test_rss_mode (line 70) | def test_rss_mode(self):
method test_special_characters (line 80) | def test_special_characters(self):
method test_whitespace_handling (line 89) | def test_whitespace_handling(self):
method test_deterministic_output (line 99) | def test_deterministic_output(self):
method test_thread_safety_determinism (line 109) | def test_thread_safety_determinism(self):
method test_thread_safety_basic (line 173) | def test_thread_safety_basic(self):
method test_large_html_with_bloated_head (line 202) | def test_large_html_with_bloated_head(self):
method test_body_display_none_spa_pattern (line 287) | def test_body_display_none_spa_pattern(self):
method test_style_tag_with_svg_data_uri (line 365) | def test_style_tag_with_svg_data_uri(self):
method test_style_tag_closes_correctly (line 414) | def test_style_tag_closes_correctly(self):
method test_script_with_closing_tag_in_string_does_not_eat_content (line 456) | def test_script_with_closing_tag_in_string_does_not_eat_content(self):
method test_content_sandwiched_between_multiple_body_scripts (line 480) | def test_content_sandwiched_between_multiple_body_scripts(self):
method test_unicode_and_international_content_preserved (line 501) | def test_unicode_and_international_content_preserved(self):
method test_style_with_type_attribute_is_stripped (line 519) | def test_style_with_type_attribute_is_stripped(self):
method test_ldjson_script_is_stripped (line 531) | def test_ldjson_script_is_stripped(self):
method test_inline_svg_is_stripped_entirely (line 545) | def test_inline_svg_is_stripped_entirely(self):
method test_tag_inside_json_data_attribute_does_not_eat_content (line 566) | def test_tag_inside_json_data_attribute_does_not_eat_content(self):
method test_script_inside_json_data_attribute_does_not_eat_content (line 601) | def test_script_inside_json_data_attribute_does_not_eat_content(self):
FILE: changedetectionio/tests/unit/test_jinja2_security.py
class TestJinja2SSTI (line 11) | class TestJinja2SSTI(unittest.TestCase):
method test_exception (line 13) | def test_exception(self):
method test_exception_debug_calls (line 32) | def test_exception_debug_calls(self):
method test_exception_empty_calls (line 44) | def test_exception_empty_calls(self):
method test_jinja2_escaped_html (line 54) | def test_jinja2_escaped_html(self):
FILE: changedetectionio/tests/unit/test_notification_diff.py
class TestDiffBuilder (line 23) | class TestDiffBuilder(unittest.TestCase):
method test_expected_diff_output (line 25) | def test_expected_diff_output(self):
method test_expected_diff_patch_output (line 72) | def test_expected_diff_patch_output(self):
method test_word_level_diff (line 94) | def test_word_level_diff(self):
method test_word_level_diff_html (line 116) | def test_word_level_diff_html(self):
method test_context_lines (line 132) | def test_context_lines(self):
method test_context_lines_with_include_equal (line 185) | def test_context_lines_with_include_equal(self):
method test_case_insensitive_comparison (line 207) | def test_case_insensitive_comparison(self):
method test_case_insensitive_with_real_changes (line 224) | def test_case_insensitive_with_real_changes(self):
method test_case_insensitive_html_output (line 241) | def test_case_insensitive_html_output(self):
method test_ignore_junk_word_diff_enabled (line 255) | def test_ignore_junk_word_diff_enabled(self):
method test_ignore_junk_word_diff_disabled (line 272) | def test_ignore_junk_word_diff_disabled(self):
method test_ignore_junk_with_real_changes (line 291) | def test_ignore_junk_with_real_changes(self):
method test_ignore_junk_tabs_vs_spaces (line 303) | def test_ignore_junk_tabs_vs_spaces(self):
method test_ignore_junk_html_output (line 319) | def test_ignore_junk_html_output(self):
method test_ignore_junk_case_insensitive_combination (line 331) | def test_ignore_junk_case_insensitive_combination(self):
method test_ignore_junk_multiline (line 363) | def test_ignore_junk_multiline(self):
FILE: changedetectionio/tests/unit/test_restock_logic.py
class TestDiffBuilder (line 12) | class TestDiffBuilder(unittest.TestCase):
method test_logic (line 14) | def test_logic(self):
FILE: changedetectionio/tests/unit/test_scheduler.py
class TestScheduler (line 9) | class TestScheduler(unittest.TestCase):
method test_timezone_basic_time_within_schedule (line 14) | def test_timezone_basic_time_within_schedule(self):
method test_timezone_basic_time_outside_schedule (line 32) | def test_timezone_basic_time_outside_schedule(self):
method test_timezone_utc_within_schedule (line 52) | def test_timezone_utc_within_schedule(self):
method test_timezone_extreme_ahead (line 69) | def test_timezone_extreme_ahead(self):
method test_timezone_extreme_behind (line 86) | def test_timezone_extreme_behind(self):
FILE: changedetectionio/tests/unit/test_semver.py
class TestSemver (line 16) | class TestSemver(unittest.TestCase):
method test_valid_versions (line 17) | def test_valid_versions(self):
method test_invalid_versions (line 35) | def test_invalid_versions(self):
method test_our_version (line 57) | def test_our_version(self):
FILE: changedetectionio/tests/unit/test_time_extension.py
function test_default_timezone_override_like_safe_jinja (line 13) | def test_default_timezone_override_like_safe_jinja(mocker):
function test_default_timezone_not_overridden (line 38) | def test_default_timezone_not_overridden(mocker):
function test_datetime_format_override_like_safe_jinja (line 60) | def test_datetime_format_override_like_safe_jinja(mocker):
function test_offset_with_overridden_timezone (line 82) | def test_offset_with_overridden_timezone(mocker):
function test_weekday_parameter_converted_to_int (line 104) | def test_weekday_parameter_converted_to_int(mocker):
function test_multiple_offset_parameters (line 123) | def test_multiple_offset_parameters(mocker):
FILE: changedetectionio/tests/unit/test_time_handler.py
class TestAmIInsideTime (line 16) | class TestAmIInsideTime(unittest.TestCase):
method test_current_time_within_schedule (line 19) | def test_current_time_within_schedule(self):
method test_current_time_outside_schedule (line 37) | def test_current_time_outside_schedule(self):
method test_timezone_pacific_within_schedule (line 55) | def test_timezone_pacific_within_schedule(self):
method test_timezone_tokyo_within_schedule (line 72) | def test_timezone_tokyo_within_schedule(self):
method test_schedule_crossing_midnight (line 89) | def test_schedule_crossing_midnight(self):
method test_invalid_day_of_week (line 111) | def test_invalid_day_of_week(self):
method test_invalid_time_format (line 122) | def test_invalid_time_format(self):
method test_invalid_time_format_non_numeric (line 133) | def test_invalid_time_format_non_numeric(self):
method test_invalid_timezone (line 144) | def test_invalid_timezone(self):
method test_short_duration (line 155) | def test_short_duration(self):
method test_long_duration (line 172) | def test_long_duration(self):
method test_case_insensitive_day (line 190) | def test_case_insensitive_day(self):
method test_edge_case_midnight (line 207) | def test_edge_case_midnight(self):
method test_edge_case_end_of_day (line 226) | def test_edge_case_end_of_day(self):
method test_24_hour_schedule_from_midnight (line 244) | def test_24_hour_schedule_from_midnight(self):
method test_24_hour_schedule_at_end_of_day (line 261) | def test_24_hour_schedule_at_end_of_day(self):
method test_24_hour_schedule_at_midnight_transition (line 277) | def test_24_hour_schedule_at_midnight_transition(self):
method test_schedule_crosses_midnight_before_midnight (line 293) | def test_schedule_crosses_midnight_before_midnight(self):
method test_schedule_crosses_midnight_after_midnight (line 309) | def test_schedule_crosses_midnight_after_midnight(self):
method test_schedule_crosses_midnight_at_exact_end (line 325) | def test_schedule_crosses_midnight_at_exact_end(self):
method test_duration_60_minutes (line 341) | def test_duration_60_minutes(self):
method test_duration_at_exact_end_minute (line 356) | def test_duration_at_exact_end_minute(self):
method test_one_second_after_schedule_ends (line 372) | def test_one_second_after_schedule_ends(self):
method test_multi_day_schedule (line 388) | def test_multi_day_schedule(self):
method test_schedule_one_minute_duration (line 404) | def test_schedule_one_minute_duration(self):
method test_schedule_at_exact_start_time (line 419) | def test_schedule_at_exact_start_time(self):
method test_schedule_one_microsecond_before_start (line 434) | def test_schedule_one_microsecond_before_start(self):
class TestIsWithinSchedule (line 450) | class TestIsWithinSchedule(unittest.TestCase):
method test_schedule_disabled (line 453) | def test_schedule_disabled(self):
method test_schedule_none (line 459) | def test_schedule_none(self):
method test_schedule_empty_dict (line 464) | def test_schedule_empty_dict(self):
method test_schedule_enabled_but_day_disabled (line 469) | def test_schedule_enabled_but_day_disabled(self):
method test_schedule_enabled_within_time (line 488) | def test_schedule_enabled_within_time(self):
method test_schedule_enabled_outside_time (line 508) | def test_schedule_enabled_outside_time(self):
method test_schedule_with_default_timezone (line 529) | def test_schedule_with_default_timezone(self):
method test_schedule_different_timezones (line 553) | def test_schedule_different_timezones(self):
method test_schedule_with_minutes_in_duration (line 574) | def test_schedule_with_minutes_in_duration(self):
method test_schedule_with_timezone_whitespace (line 594) | def test_schedule_with_timezone_whitespace(self):
method test_schedule_with_60_minutes (line 614) | def test_schedule_with_60_minutes(self):
method test_schedule_with_24_hours (line 634) | def test_schedule_with_24_hours(self):
method test_schedule_with_90_minutes (line 654) | def test_schedule_with_90_minutes(self):
method test_schedule_24_hours_from_midnight (line 674) | def test_schedule_24_hours_from_midnight(self):
method test_schedule_24_hours_at_end_of_day (line 694) | def test_schedule_24_hours_at_end_of_day(self):
method test_schedule_crosses_midnight_with_is_within_schedule (line 714) | def test_schedule_crosses_midnight_with_is_within_schedule(self):
method test_schedule_with_mixed_hours_minutes (line 743) | def test_schedule_with_mixed_hours_minutes(self):
method test_schedule_48_hours (line 763) | def test_schedule_48_hours(self):
class TestWeekdayEnum (line 784) | class TestWeekdayEnum(unittest.TestCase):
method test_weekday_values (line 787) | def test_weekday_values(self):
method test_weekday_string_access (line 797) | def test_weekday_string_access(self):
FILE: changedetectionio/tests/unit/test_watch_model.py
class TestDiffBuilder (line 14) | class TestDiffBuilder(unittest.TestCase):
method test_watch_get_suggested_from_diff_timestamp (line 16) | def test_watch_get_suggested_from_diff_timestamp(self):
method test_watch_deepcopy_doesnt_copy_datastore (line 73) | def test_watch_deepcopy_doesnt_copy_datastore(self):
method test_watch_pickle_doesnt_serialize_datastore (line 136) | def test_watch_pickle_doesnt_serialize_datastore(self):
method test_tag_deepcopy_works (line 173) | def test_tag_deepcopy_works(self):
method test_watch_copy_performance (line 215) | def test_watch_copy_performance(self):
FILE: changedetectionio/tests/util.py
function write_test_file_and_sync (line 16) | def write_test_file_and_sync(filepath, content, mode='w'):
function set_original_response (line 46) | def set_original_response(datastore_path, extra_title=''):
function set_modified_response (line 62) | def set_modified_response(datastore_path):
function set_longer_modified_response (line 76) | def set_longer_modified_response(datastore_path):
function set_more_modified_response (line 94) | def set_more_modified_response(datastore_path):
function set_empty_text_response (line 111) | def set_empty_text_response(datastore_path):
function wait_for_notification_endpoint_output (line 118) | def wait_for_notification_endpoint_output(datastore_path):
function get_UUID_for_tag_name (line 131) | def get_UUID_for_tag_name(client, name):
function extract_rss_token_from_UI (line 140) | def extract_rss_token_from_UI(client):
function extract_UUID_from_client (line 151) | def extract_UUID_from_client(client):
function delete_all_watches (line 162) | def delete_all_watches(client=None):
function wait_for_all_checks (line 201) | def wait_for_all_checks(client=None):
function wait_for_watch_history (line 211) | def wait_for_watch_history(client, min_history_count=2, timeout=10):
function live_server_setup (line 242) | def live_server_setup(live_server):
function new_live_server_setup (line 245) | def new_live_server_setup(live_server):
FILE: changedetectionio/tests/visualselector/test_fetch_data.py
function test_visual_selector_content_ready (line 12) | def test_visual_selector_content_ready(client, live_server, measure_memo...
function test_basic_browserstep (line 89) | def test_basic_browserstep(client, live_server, measure_memory_usage, da...
function test_non_200_errors_report_browsersteps (line 153) | def test_non_200_errors_report_browsersteps(client, live_server, measure...
function test_browsersteps_edit_UI_startsession (line 197) | def test_browsersteps_edit_UI_startsession(client, live_server, measure_...
FILE: changedetectionio/time_handler.py
class Weekday (line 7) | class Weekday(IntEnum):
function am_i_inside_time (line 17) | def am_i_inside_time(
function is_within_schedule (line 83) | def is_within_schedule(time_schedule_limit, default_tz="UTC"):
FILE: changedetectionio/validate_url.py
function normalize_url_encoding (line 8) | def normalize_url_encoding(url):
function is_private_hostname (line 61) | def is_private_hostname(hostname):
function is_safe_valid_url (line 83) | def is_safe_valid_url(test_url):
FILE: changedetectionio/widgets/ternary_boolean.py
class TernaryNoneBooleanWidget (line 5) | class TernaryNoneBooleanWidget:
method __call__ (line 10) | def __call__(self, field, **kwargs):
class TernaryNoneBooleanField (line 53) | class TernaryNoneBooleanField(Field):
method __init__ (line 66) | def __init__(self, label=None, validators=None, false_values=None, boo...
method process_formdata (line 80) | def process_formdata(self, valuelist):
method _value (line 94) | def _value(self):
FILE: changedetectionio/widgets/test_custom_text.py
class TestForm (line 13) | class TestForm(Form):
function test_custom_text (line 53) | def test_custom_text():
function test_data_processing (line 114) | def test_data_processing():
FILE: changedetectionio/worker.py
function async_update_worker (line 23) | async def async_update_worker(worker_id, q, notification_q, app, datasto...
function cleanup_error_artifacts (line 620) | def cleanup_error_artifacts(uuid, datastore):
function send_content_changed_notification (line 630) | async def send_content_changed_notification(watch_uuid, notification_q, ...
function send_filter_failure_notification (line 643) | async def send_filter_failure_notification(watch_uuid, notification_q, d...
function send_step_failure_notification (line 656) | async def send_step_failure_notification(watch_uuid, step_n, notificatio...
FILE: changedetectionio/worker_pool.py
class WorkerThread (line 37) | class WorkerThread:
method __init__ (line 39) | def __init__(self, worker_id, update_q, notification_q, app, datastore):
method run (line 49) | def run(self):
method start (line 87) | def start(self):
method stop (line 96) | def stop(self):
function start_async_workers (line 109) | def start_async_workers(n_workers, update_q, notification_q, app, datast...
function start_single_async_worker (line 129) | async def start_single_async_worker(worker_id, update_q, notification_q,...
function start_workers (line 167) | def start_workers(n_workers, update_q, notification_q, app, datastore):
function add_worker (line 172) | def add_worker(update_q, notification_q, app, datastore):
function remove_worker (line 193) | def remove_worker():
function get_worker_count (line 207) | def get_worker_count():
function get_running_uuids (line 212) | def get_running_uuids():
function claim_uuid_for_processing (line 218) | def claim_uuid_for_processing(uuid, worker_id):
function release_uuid_from_processing (line 243) | def release_uuid_from_processing(uuid, worker_id):
function set_uuid_processing (line 260) | def set_uuid_processing(uuid, worker_id=None, processing=True):
function is_watch_running (line 275) | def is_watch_running(watch_uuid):
function is_watch_running_by_another_worker (line 281) | def is_watch_running_by_another_worker(watch_uuid, current_worker_id):
function queue_item_async_safe (line 290) | def queue_item_async_safe(update_q, item, silent=False):
function shutdown_workers (line 342) | def shutdown_workers():
function adjust_async_worker_count (line 377) | def adjust_async_worker_count(new_count, update_q=None, notification_q=N...
function get_worker_status (line 439) | def get_worker_status():
function wait_for_all_checks (line 449) | def wait_for_all_checks(update_q, timeout=150):
function check_worker_health (line 498) | def check_worker_health(expected_count, update_q=None, notification_q=No...
FILE: setup.py
function read (line 14) | def read(*parts):
function find_version (line 18) | def find_version(*file_paths):
class BuildPyCommand (line 27) | class BuildPyCommand(build_py):
method run (line 29) | def run(self):
Condensed preview — 427 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (5,088K chars).
[
{
"path": ".dockerignore",
"chars": 692,
"preview": "# Git\n.git/\n.gitignore\n\n# GitHub\n.github/\n\n# Byte-compiled / optimized / DLL files\n**/__pycache__\n**/*.py[cod]\n\n# Caches"
},
{
"path": ".github/FUNDING.yml",
"chars": 64,
"preview": "# These are supported funding model platforms\n\ngithub: dgtlmoon\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 1764,
"preview": "---\nname: Bug report\nabout: Create a bug report, if you don't follow this template, your report will be DELETED\ntitle: '"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 682,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: '[feature]'\nlabels: 'enhancement'\nassignees: ''"
},
{
"path": ".github/actions/extract-memory-report/action.yml",
"chars": 2153,
"preview": "name: 'Extract Memory Test Report'\ndescription: 'Extracts and displays memory test report from a container'\ninputs:\n co"
},
{
"path": ".github/dependabot.yml",
"chars": 252,
"preview": "version: 2\nupdates:\n - package-ecosystem: github-actions\n directory: /\n schedule:\n interval: \"weekly\"\n gr"
},
{
"path": ".github/nginx-reverse-proxy-test.conf",
"chars": 1101,
"preview": "server {\n listen 80;\n server_name localhost;\n\n # Test basic reverse proxy to changedetection.io\n location / "
},
{
"path": ".github/test/Dockerfile-alpine",
"chars": 1444,
"preview": "# Taken from https://github.com/linuxserver/docker-changedetection.io/blob/main/Dockerfile\n# Test that we can still buil"
},
{
"path": ".github/workflows/codeql-analysis.yml",
"chars": 2225,
"preview": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# Y"
},
{
"path": ".github/workflows/containers.yml",
"chars": 7067,
"preview": "name: Build and push containers\n\non:\n # Automatically triggered by a testing workflow passing, but this is only checked"
},
{
"path": ".github/workflows/pypi-release.yml",
"chars": 3031,
"preview": "name: Publish Python 🐍distribution 📦 to PyPI and TestPyPI\n\non: push\njobs:\n build:\n name: Build distribution 📦\n ru"
},
{
"path": ".github/workflows/test-container-build.yml",
"chars": 2763,
"preview": "name: ChangeDetection.io Container Build Test\n\n# Triggers the workflow on push or pull request events\n\n# This line doesn"
},
{
"path": ".github/workflows/test-only.yml",
"chars": 2024,
"preview": "name: ChangeDetection.io App Test\n\n# Triggers the workflow on push or pull request events\non: [push, pull_request]\n\njobs"
},
{
"path": ".github/workflows/test-stack-reusable-workflow.yml",
"chars": 34706,
"preview": "name: ChangeDetection.io App Test\n\non:\n workflow_call:\n inputs:\n python-version:\n description: 'Python v"
},
{
"path": ".gitignore",
"chars": 368,
"preview": "# Byte-compiled / optimized / DLL files\n**/__pycache__\n**/*.py[cod]\n\n# Caches\n.mypy_cache/\n.pytest_cache/\n.ruff_cache/\n\n"
},
{
"path": ".pre-commit-config.yaml",
"chars": 204,
"preview": "repos:\n - repo: https://github.com/astral-sh/ruff-pre-commit\n rev: v0.11.2\n hooks:\n # Lint (and apply safe f"
},
{
"path": ".ruff.toml",
"chars": 958,
"preview": "# Minimum supported version\ntarget-version = \"py310\"\n\n# Formatting options\nline-length = 100\nindent-width = 4\n\nexclude ="
},
{
"path": "COMMERCIAL_LICENCE.md",
"chars": 5288,
"preview": "# Generally\n\nIn any commercial activity involving 'Hosting' (as defined herein), whether in part or in full, this licens"
},
{
"path": "CONTRIBUTING.md",
"chars": 383,
"preview": "Contributing is always welcome!\n\nI am no professional flask developer, if you know a better way that something can be do"
},
{
"path": "Dockerfile",
"chars": 5138,
"preview": "# pip dependencies install stage\n\nARG PYTHON_VERSION=3.11\n\nFROM python:${PYTHON_VERSION}-slim-bookworm AS builder\n\n# See"
},
{
"path": "LICENSE",
"chars": 11353,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "MANIFEST.in",
"chars": 1226,
"preview": "recursive-include changedetectionio/api *\ninclude docs/api-spec.yaml\nrecursive-include changedetectionio/blueprint *\nrec"
},
{
"path": "README-pip.md",
"chars": 6281,
"preview": "# Monitor website changes\n\nDetect WebPage Changes Automatically — Monitor Web Page Changes in Real Time\n\nMonitor website"
},
{
"path": "README.md",
"chars": 18106,
"preview": "# Detect Website Changes Automatically — Monitor Web Page Changes in Real Time\n\nMonitor websites for updates — get notif"
},
{
"path": "babel.cfg",
"chars": 95,
"preview": "[python: **.py]\nkeywords = _:1,_l:1,gettext:1\n\n[jinja2: **/templates/**.html]\nencoding = utf-8\n"
},
{
"path": "changedetection.py",
"chars": 142,
"preview": "#!/usr/bin/env python3\n\n# Only exists for direct CLI usage\n\nimport changedetectionio\n\nif __name__ == '__main__':\n cha"
},
{
"path": "changedetectionio/.gitignore",
"chars": 33,
"preview": "test-datastore\npackage-lock.json\n"
},
{
"path": "changedetectionio/PLUGIN_README.md",
"chars": 2750,
"preview": "# Creating Plugins for changedetection.io\n\nThis document describes how to create plugins for changedetection.io. Plugins"
},
{
"path": "changedetectionio/__init__.py",
"chars": 31283,
"preview": "#!/usr/bin/env python3\n\n# Read more https://github.com/dgtlmoon/changedetection.io/wiki\n# Semver means never use .01, or"
},
{
"path": "changedetectionio/api/Import.py",
"chars": 8921,
"preview": "from changedetectionio.strtobool import strtobool\nfrom flask_restful import abort, Resource\nfrom flask import request\nfr"
},
{
"path": "changedetectionio/api/Notifications.py",
"chars": 3558,
"preview": "from flask_restful import Resource, abort\nfrom flask import request\nfrom . import auth, validate_openapi_request\n\nclass "
},
{
"path": "changedetectionio/api/Search.py",
"chars": 1491,
"preview": "from flask_restful import Resource, abort\nfrom flask import request\nfrom . import auth, validate_openapi_request\n\nclass "
},
{
"path": "changedetectionio/api/Spec.py",
"chars": 661,
"preview": "import functools\nfrom flask import make_response\nfrom flask_restful import Resource\n\n\n@functools.cache\ndef _get_spec_yam"
},
{
"path": "changedetectionio/api/SystemInfo.py",
"chars": 1617,
"preview": "from flask_restful import Resource\nfrom . import auth, validate_openapi_request\n\n\nclass SystemInfo(Resource):\n def __"
},
{
"path": "changedetectionio/api/Tags.py",
"chars": 9039,
"preview": "from changedetectionio import queuedWatchMetaData\nfrom changedetectionio import worker_pool\nfrom flask_restful import ab"
},
{
"path": "changedetectionio/api/Watch.py",
"chars": 24974,
"preview": "import os\nimport threading\n\nfrom changedetectionio.validate_url import is_safe_valid_url\nfrom changedetectionio.favicon_"
},
{
"path": "changedetectionio/api/__init__.py",
"chars": 8783,
"preview": "import functools\nfrom flask import request, abort\nfrom loguru import logger\n\n@functools.cache\ndef build_merged_spec_dict"
},
{
"path": "changedetectionio/api/auth.py",
"chars": 862,
"preview": "from flask import request, make_response, jsonify\nfrom functools import wraps\n\n\n# Simple API auth key comparison\n# @todo"
},
{
"path": "changedetectionio/auth_decorator.py",
"chars": 1417,
"preview": "import os\nfrom functools import wraps\nfrom flask import current_app, redirect, request\nfrom loguru import logger\n\ndef lo"
},
{
"path": "changedetectionio/blueprint/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "changedetectionio/blueprint/backups/__init__.py",
"chars": 7955,
"preview": "import datetime\nimport glob\nimport threading\n\nfrom flask import Blueprint, render_template, send_from_directory, flash, "
},
{
"path": "changedetectionio/blueprint/backups/restore.py",
"chars": 11324,
"preview": "import io\nimport json\nimport os\nimport re\nimport shutil\nimport tempfile\nimport threading\nimport zipfile\n\nfrom flask impo"
},
{
"path": "changedetectionio/blueprint/backups/templates/backup_create.html",
"chars": 2025,
"preview": "{% extends 'base.html' %}\n{% block content %}\n {% from '_helpers.html' import render_simple_field, render_field %}\n\n "
},
{
"path": "changedetectionio/blueprint/backups/templates/backup_restore.html",
"chars": 3144,
"preview": "{% extends 'base.html' %}\n{% block content %}\n {% from '_helpers.html' import render_field, render_checkbox_field %}\n"
},
{
"path": "changedetectionio/blueprint/browser_steps/TODO.txt",
"chars": 373,
"preview": "- This needs an abstraction to directly handle the puppeteer connection methods\n- Then remove the playwright stuff\n- Rem"
},
{
"path": "changedetectionio/blueprint/browser_steps/__init__.py",
"chars": 17232,
"preview": "\n# HORRIBLE HACK BUT WORKS :-) PR anyone?\n#\n# Why?\n# `browsersteps_playwright_browser_interface.chromium.connect_over_cd"
},
{
"path": "changedetectionio/blueprint/check_proxies/__init__.py",
"chars": 5084,
"preview": "import importlib\nfrom concurrent.futures import ThreadPoolExecutor\n\nfrom changedetectionio.processors.text_json_diff.pro"
},
{
"path": "changedetectionio/blueprint/imports/__init__.py",
"chars": 4424,
"preview": "from flask import Blueprint, request, redirect, url_for, flash, render_template\nfrom loguru import logger\n\nfrom changede"
},
{
"path": "changedetectionio/blueprint/imports/importer.py",
"chars": 11460,
"preview": "from abc import abstractmethod\nimport time\nfrom wtforms import ValidationError\nfrom loguru import logger\nfrom flask_babe"
},
{
"path": "changedetectionio/blueprint/imports/templates/import.html",
"chars": 6963,
"preview": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_field %}\n<script src=\"{{url_for('sta"
},
{
"path": "changedetectionio/blueprint/price_data_follower/__init__.py",
"chars": 1505,
"preview": "\nfrom changedetectionio.strtobool import strtobool\nfrom flask import Blueprint, flash, redirect, url_for\nfrom flask_logi"
},
{
"path": "changedetectionio/blueprint/rss/__init__.py",
"chars": 1284,
"preview": "from copy import deepcopy\nfrom loguru import logger\n\nfrom changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION"
},
{
"path": "changedetectionio/blueprint/rss/_util.py",
"chars": 5094,
"preview": "\"\"\"\nUtility functions for RSS feed generation.\n\"\"\"\n\nfrom changedetectionio.notification.handler import process_notificat"
},
{
"path": "changedetectionio/blueprint/rss/blueprint.py",
"chars": 733,
"preview": "\nfrom changedetectionio.store import ChangeDetectionStore\nfrom flask import Blueprint\n\nfrom . import tag as tag_routes\nf"
},
{
"path": "changedetectionio/blueprint/rss/main_feed.py",
"chars": 4853,
"preview": "from flask import make_response, request, url_for, redirect\n\n\n\ndef construct_main_feed_routes(rss_blueprint, datastore):"
},
{
"path": "changedetectionio/blueprint/rss/single_watch.py",
"chars": 5149,
"preview": "\n\ndef construct_single_watch_routes(rss_blueprint, datastore):\n \"\"\"\n Construct RSS feed routes for single watches."
},
{
"path": "changedetectionio/blueprint/rss/tag.py",
"chars": 4310,
"preview": "def construct_tag_routes(rss_blueprint, datastore):\n \"\"\"\n Construct RSS feed routes for tags.\n\n Args:\n r"
},
{
"path": "changedetectionio/blueprint/settings/__init__.py",
"chars": 11429,
"preview": "import os\nfrom copy import deepcopy\nfrom datetime import datetime, timedelta\nfrom zoneinfo import ZoneInfo, available_ti"
},
{
"path": "changedetectionio/blueprint/settings/templates/notification-log.html",
"chars": 493,
"preview": "{% extends 'base.html' %}\n\n{% block content %}\n<div class=\"edit-form\">\n <div class=\"inner\">\n\n <h4 style=\"mar"
},
{
"path": "changedetectionio/blueprint/settings/templates/settings.html",
"chars": 29226,
"preview": "{% extends 'base.html' %}\n\n{% block content %}\n{% from '_helpers.html' import render_field, render_checkbox_field, rende"
},
{
"path": "changedetectionio/blueprint/tags/README.md",
"chars": 265,
"preview": "# Groups tags\n\n## How it works\n\nWatch has a list() of tag UUID's, which relate to a config under application.settings.ta"
},
{
"path": "changedetectionio/blueprint/tags/__init__.py",
"chars": 11541,
"preview": "import threading\nfrom flask import Blueprint, request, render_template, flash, url_for, redirect\nfrom flask_babel import"
},
{
"path": "changedetectionio/blueprint/tags/form.py",
"chars": 627,
"preview": "from wtforms import (\n Form,\n StringField,\n SubmitField,\n validators,\n)\nfrom wtforms.fields.simple import Bo"
},
{
"path": "changedetectionio/blueprint/tags/templates/edit-tag.html",
"chars": 4929,
"preview": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render"
},
{
"path": "changedetectionio/blueprint/tags/templates/groups-overview.html",
"chars": 4667,
"preview": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_simple_field, render_field %}\n<scrip"
},
{
"path": "changedetectionio/blueprint/ui/__init__.py",
"chars": 19746,
"preview": "import time\nimport threading\nfrom flask import Blueprint, request, redirect, url_for, flash, render_template, session, c"
},
{
"path": "changedetectionio/blueprint/ui/diff.py",
"chars": 12655,
"preview": "from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory\nfrom"
},
{
"path": "changedetectionio/blueprint/ui/edit.py",
"chars": 23284,
"preview": "from copy import deepcopy\nimport os\nimport importlib.resources\nfrom flask import Blueprint, request, redirect, url_for, "
},
{
"path": "changedetectionio/blueprint/ui/notification.py",
"chars": 7439,
"preview": "from flask import Blueprint, request, make_response\nimport random\nfrom loguru import logger\n\nfrom changedetectionio.stor"
},
{
"path": "changedetectionio/blueprint/ui/preview.py",
"chars": 8890,
"preview": "from flask import Blueprint, request, url_for, flash, render_template, redirect\nfrom flask_babel import gettext\nimport t"
},
{
"path": "changedetectionio/blueprint/ui/templates/clear_all_history.html",
"chars": 1579,
"preview": "{% extends 'base.html' %} {% block content %}\n<div class=\"edit-form\">\n <div class=\"box-wrap inner\">\n <form\n cla"
},
{
"path": "changedetectionio/blueprint/ui/templates/diff-offscreen-options.html",
"chars": 549,
"preview": "<ul id=\"highlightSnippetActions\">\n <li>\n <button class=\"pure-button pure-button-primary\" onclick=\"diffToJpeg()"
},
{
"path": "changedetectionio/blueprint/ui/templates/diff.html",
"chars": 9138,
"preview": "{% extends 'base.html' %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n{% block "
},
{
"path": "changedetectionio/blueprint/ui/templates/edit.html",
"chars": 36077,
"preview": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render"
},
{
"path": "changedetectionio/blueprint/ui/templates/preview.html",
"chars": 5259,
"preview": "{% extends 'base.html' %}\n{% from '_helpers.html' import highlight_trigger_ignored_explainer %}\n{% block content %}\n "
},
{
"path": "changedetectionio/blueprint/ui/views.py",
"chars": 2017,
"preview": "from flask import Blueprint, request, redirect, url_for, flash\nfrom flask_babel import gettext\nfrom changedetectionio.st"
},
{
"path": "changedetectionio/blueprint/watchlist/__init__.py",
"chars": 6203,
"preview": "import os\nimport time\n\nfrom flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, s"
},
{
"path": "changedetectionio/blueprint/watchlist/templates/watch-overview.html",
"chars": 27866,
"preview": "{%- extends 'base.html' -%}\n{%- block content -%}\n{%- set tips = [\n _(\"Changedetection.io can monitor more than just "
},
{
"path": "changedetectionio/browser_steps/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "changedetectionio/browser_steps/browser_steps.py",
"chars": 21234,
"preview": "import os\nimport time\nimport re\nfrom random import randint\nfrom loguru import logger\n\nfrom changedetectionio.content_fet"
},
{
"path": "changedetectionio/conditions/__init__.py",
"chars": 6510,
"preview": "from json_logic.builtins import BUILTINS\n\nfrom .exceptions import EmptyConditionRuleRowNotUsable\nfrom .pluggy_interface "
},
{
"path": "changedetectionio/conditions/blueprint.py",
"chars": 3373,
"preview": "# Flask Blueprint Definition\nimport json\n\nfrom flask import Blueprint\n\nfrom changedetectionio.conditions import execute_"
},
{
"path": "changedetectionio/conditions/default_plugin.py",
"chars": 2842,
"preview": "import re\n\nimport pluggy\nfrom price_parser import Price\nfrom loguru import logger\n\nhookimpl = pluggy.HookimplMarker(\"cha"
},
{
"path": "changedetectionio/conditions/exceptions.py",
"chars": 212,
"preview": "class EmptyConditionRuleRowNotUsable(Exception):\n def __init__(self):\n super().__init__(\"One of the 'condition"
},
{
"path": "changedetectionio/conditions/form.py",
"chars": 1651,
"preview": "# Condition Rule Form (for each rule row)\nfrom wtforms import Form, SelectField, StringField, validators\nfrom wtforms im"
},
{
"path": "changedetectionio/conditions/pluggy_interface.py",
"chars": 2362,
"preview": "import pluggy\nimport os\nimport importlib\nimport sys\nfrom . import default_plugin\n\n# ✅ Ensure that the namespace in Hooks"
},
{
"path": "changedetectionio/conditions/plugins/__init__.py",
"chars": 50,
"preview": "# Import plugins package to make them discoverable"
},
{
"path": "changedetectionio/conditions/plugins/levenshtein_plugin.py",
"chars": 4555,
"preview": "\"\"\"\nLevenshtein distance and similarity plugin for text change detection.\nProvides metrics for measuring text similarity"
},
{
"path": "changedetectionio/conditions/plugins/wordcount_plugin.py",
"chars": 2740,
"preview": "\"\"\"\nWord count plugin for content analysis.\nProvides word count metrics for snapshot content.\n\"\"\"\nimport pluggy\nfrom log"
},
{
"path": "changedetectionio/content_fetchers/__init__.py",
"chars": 5127,
"preview": "import sys\nfrom changedetectionio.strtobool import strtobool\nfrom loguru import logger\nfrom changedetectionio.content_fe"
},
{
"path": "changedetectionio/content_fetchers/base.py",
"chars": 8485,
"preview": "import os\nfrom abc import abstractmethod\nfrom loguru import logger\n\nfrom changedetectionio.content_fetchers import Brows"
},
{
"path": "changedetectionio/content_fetchers/exceptions/__init__.py",
"chars": 3110,
"preview": "from loguru import logger\n\nclass Non200ErrorCodeReceived(Exception):\n def __init__(self, status_code, url, screenshot"
},
{
"path": "changedetectionio/content_fetchers/playwright.py",
"chars": 22347,
"preview": "import asyncio\nimport gc\nimport json\nimport os\nfrom urllib.parse import urlparse\n\nfrom loguru import logger\n\nfrom change"
},
{
"path": "changedetectionio/content_fetchers/puppeteer.py",
"chars": 27034,
"preview": "import asyncio\nimport gc\nimport json\nimport os\nimport websockets.exceptions\nfrom urllib.parse import urlparse\n\nfrom logu"
},
{
"path": "changedetectionio/content_fetchers/requests.py",
"chars": 12807,
"preview": "from loguru import logger\nfrom urllib.parse import urljoin, urlparse\nimport hashlib\nimport os\nimport re\nimport asyncio\n\n"
},
{
"path": "changedetectionio/content_fetchers/res/__init__.py",
"chars": 43,
"preview": "# resources for browser injection/scraping\n"
},
{
"path": "changedetectionio/content_fetchers/res/favicon-fetcher.js",
"chars": 2640,
"preview": "(async () => {\n // Define the function inside the IIFE for console testing\n window.getFaviconAsBlob = async function()"
},
{
"path": "changedetectionio/content_fetchers/res/lock-elements-sizing.js",
"chars": 4696,
"preview": "/**\n * Lock Element Dimensions for Screenshot Capture (First Viewport Only)\n *\n * THE PROBLEM:\n * When taking full-page "
},
{
"path": "changedetectionio/content_fetchers/res/stock-not-in-stock.js",
"chars": 9853,
"preview": "async () => {\n\n function isItemInStock() {\n // @todo Pass these in so the same list can be used in non-JS fetc"
},
{
"path": "changedetectionio/content_fetchers/res/unlock-elements-sizing.js",
"chars": 1927,
"preview": "/**\n * Unlock Element Dimensions After Screenshot Capture\n *\n * This script removes the inline !important styles that we"
},
{
"path": "changedetectionio/content_fetchers/res/xpath_element_scraper.js",
"chars": 12167,
"preview": "async (options) => {\n\n let visualselector_xpath_selectors = options.visualselector_xpath_selectors\n let max_height"
},
{
"path": "changedetectionio/content_fetchers/screenshot_handler.py",
"chars": 3703,
"preview": "# Pages with a vertical height longer than this will use the 'stitch together' method.\n\n# - Many GPUs have a max texture"
},
{
"path": "changedetectionio/content_fetchers/webdriver_selenium.py",
"chars": 8275,
"preview": "import os\nimport time\n\nfrom loguru import logger\nfrom changedetectionio.content_fetchers.base import Fetcher\n\n\nclass fet"
},
{
"path": "changedetectionio/custom_queue.py",
"chars": 20739,
"preview": "import queue\nimport asyncio\nfrom blinker import signal\nfrom loguru import logger\n\n\nclass NotificationQueue(queue.Queue):"
},
{
"path": "changedetectionio/diff/__init__.py",
"chars": 20510,
"preview": "\"\"\"\nDiff rendering module for change detection.\n\nThis module provides functions for rendering differences between text c"
},
{
"path": "changedetectionio/diff/tokenizers/__init__.py",
"chars": 604,
"preview": "\"\"\"\nTokenizers for diff operations.\n\nThis module provides various tokenization strategies for use with the diff system.\n"
},
{
"path": "changedetectionio/diff/tokenizers/natural_text.py",
"chars": 1048,
"preview": "\"\"\"\nSimple word tokenizer using whitespace boundaries.\n\nThis is a simpler tokenizer that treats all whitespace as token "
},
{
"path": "changedetectionio/diff/tokenizers/words_and_html.py",
"chars": 1743,
"preview": "\"\"\"\nTokenizer that preserves HTML tags as atomic units while splitting on whitespace.\n\nThis tokenizer is specifically de"
},
{
"path": "changedetectionio/favicon_utils.py",
"chars": 1075,
"preview": "\"\"\"\nFavicon utilities for changedetection.io\nHandles favicon MIME type detection with caching\n\"\"\"\n\nfrom functools import"
},
{
"path": "changedetectionio/flask_app.py",
"chars": 53692,
"preview": "#!/usr/bin/env python3\n\nimport flask_login\nimport locale\nimport os\nimport queue\nimport re\nimport sys\nimport threading\nim"
},
{
"path": "changedetectionio/forms.py",
"chars": 49178,
"preview": "import os\nimport re\nfrom loguru import logger\nfrom wtforms.widgets.core import TimeInput\nfrom flask_babel import lazy_ge"
},
{
"path": "changedetectionio/gc_cleanup.py",
"chars": 6871,
"preview": "#!/usr/bin/env python3\n\nimport ctypes\nimport gc\nimport re\nimport psutil\nimport sys\nimport threading\nimport importlib\nfro"
},
{
"path": "changedetectionio/html_tools.py",
"chars": 31339,
"preview": "from functools import lru_cache\n\nfrom loguru import logger\nfrom typing import List\nimport html\nimport json\nimport re\n\n# "
},
{
"path": "changedetectionio/is_safe_url.py",
"chars": 4478,
"preview": "\"\"\"\nURL redirect validation module for preventing open redirect vulnerabilities.\n\nThis module provides functionality to "
},
{
"path": "changedetectionio/jinja2_custom/__init__.py",
"chars": 505,
"preview": "\"\"\"\nJinja2 custom extensions and safe rendering utilities.\n\"\"\"\nfrom .extensions.TimeExtension import TimeExtension\nfrom "
},
{
"path": "changedetectionio/jinja2_custom/extensions/TimeExtension.py",
"chars": 8274,
"preview": "\"\"\"\nJinja2 TimeExtension - Custom date/time handling for templates.\n\nThis extension provides the {% now %} tag for Jinja"
},
{
"path": "changedetectionio/jinja2_custom/extensions/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "changedetectionio/jinja2_custom/plugins/__init__.py",
"chars": 122,
"preview": "\"\"\"\nJinja2 custom filter plugins for changedetection.io\n\"\"\"\nfrom .regex import regex_replace\n\n__all__ = ['regex_replace'"
},
{
"path": "changedetectionio/jinja2_custom/plugins/regex.py",
"chars": 3638,
"preview": "\"\"\"\nRegex filter plugin for Jinja2 templates.\n\nProvides regex_replace filter for pattern-based string replacements in te"
},
{
"path": "changedetectionio/jinja2_custom/safe_jinja.py",
"chars": 2149,
"preview": "\"\"\"\nSafe Jinja2 render with max payload sizes\n\nSee https://jinja.palletsprojects.com/en/3.1.x/sandbox/#security-consider"
},
{
"path": "changedetectionio/languages.py",
"chars": 4627,
"preview": "\"\"\"\nLanguage configuration for i18n support\nAutomatically discovers available languages from translations directory\n\"\"\"\n"
},
{
"path": "changedetectionio/model/App.py",
"chars": 5385,
"preview": "from os import getenv\nfrom copy import deepcopy\n\nfrom changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTE"
},
{
"path": "changedetectionio/model/Tag.py",
"chars": 2179,
"preview": "\"\"\"\nTag/Group domain model for organizing and overriding watch settings.\n\nARCHITECTURE NOTE: Configuration Override Hier"
},
{
"path": "changedetectionio/model/Tags.py",
"chars": 1374,
"preview": "import os\nimport shutil\nfrom pathlib import Path\nfrom loguru import logger\n\n_SENTINEL = object()\n\n\nclass TagsDict(dict):"
},
{
"path": "changedetectionio/model/Watch.py",
"chars": 48576,
"preview": "\"\"\"\nWatch domain model for change detection monitoring.\n\nARCHITECTURE NOTE: Configuration Override Hierarchy\n==========="
},
{
"path": "changedetectionio/model/__init__.py",
"chars": 28724,
"preview": "import os\nimport uuid\n\nfrom changedetectionio import strtobool\nfrom .persistence import EntityPersistenceMixin, _determi"
},
{
"path": "changedetectionio/model/persistence.py",
"chars": 2605,
"preview": "\"\"\"\nEntity persistence mixin for Watch and Tag models.\n\nProvides file-based persistence using atomic writes.\n\"\"\"\n\nimport"
},
{
"path": "changedetectionio/model/schema_utils.py",
"chars": 3028,
"preview": "\"\"\"\nSchema utilities for Watch and Tag models.\n\nProvides functions to extract readonly fields and properties from OpenAP"
},
{
"path": "changedetectionio/notification/__init__.py",
"chars": 729,
"preview": "from changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n\ndefault_notification_format = 'htm"
},
{
"path": "changedetectionio/notification/apprise_plugin/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "changedetectionio/notification/apprise_plugin/assets.py",
"chars": 614,
"preview": "from apprise import AppriseAsset\n\n# Refer to:\n# https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset"
},
{
"path": "changedetectionio/notification/apprise_plugin/custom_handlers.py",
"chars": 7488,
"preview": "\"\"\"\nCustom Apprise HTTP Handlers with format= Parameter Support\n\nIMPORTANT: This module works around a limitation in App"
},
{
"path": "changedetectionio/notification/apprise_plugin/discord.py",
"chars": 11587,
"preview": "\"\"\"\nCustom Discord plugin for changedetection.io\nExtends Apprise's Discord plugin to support custom colored embeds for r"
},
{
"path": "changedetectionio/notification/email_helpers.py",
"chars": 1487,
"preview": "def as_monospaced_html_email(content: str, title: str) -> str:\n \"\"\"\n Wraps `content` in a minimal, email-safe HTML"
},
{
"path": "changedetectionio/notification/handler.py",
"chars": 25651,
"preview": "\nimport time\nimport re\nimport apprise\nfrom apprise import NotifyFormat\nfrom loguru import logger\nfrom urllib.parse impor"
},
{
"path": "changedetectionio/notification_service.py",
"chars": 22127,
"preview": "#!/usr/bin/env python3\n\n\"\"\"\nNotification Service Module\nExtracted from update_worker.py to provide standalone notificati"
},
{
"path": "changedetectionio/pluggy_interface.py",
"chars": 23165,
"preview": "import pluggy\nimport os\nimport importlib\nimport sys\nfrom loguru import logger\n\n# Global plugin namespace for changedetec"
},
{
"path": "changedetectionio/processors/README.md",
"chars": 1037,
"preview": "# Change detection post-processors\n\nThe concept here is to be able to switch between different domain specific problems "
},
{
"path": "changedetectionio/processors/__init__.py",
"chars": 18735,
"preview": "from functools import lru_cache\nfrom loguru import logger\nfrom flask_babel import gettext, get_locale\nimport importlib\ni"
},
{
"path": "changedetectionio/processors/base.py",
"chars": 16106,
"preview": "import asyncio\nimport re\nimport hashlib\n\nfrom changedetectionio.browser_steps.browser_steps import browser_steps_get_val"
},
{
"path": "changedetectionio/processors/exceptions.py",
"chars": 435,
"preview": "class ProcessorException(Exception):\n def __init__(self, message=None, status_code=None, url=None, screenshot=None, h"
},
{
"path": "changedetectionio/processors/extract.py",
"chars": 4812,
"preview": "\"\"\"\nBase data extraction module for all processors.\n\nThis module handles extracting data from watch history using regex "
},
{
"path": "changedetectionio/processors/image_ssim_diff/README.md",
"chars": 8041,
"preview": "# Fast Screenshot Comparison Processor\n\nVisual/screenshot change detection using ultra-fast image comparison algorithms."
},
{
"path": "changedetectionio/processors/image_ssim_diff/__init__.py",
"chars": 1378,
"preview": "\"\"\"\nVisual/screenshot change detection using fast image comparison algorithms.\n\nThis processor compares screenshots usin"
},
{
"path": "changedetectionio/processors/image_ssim_diff/difference.py",
"chars": 18167,
"preview": "\"\"\"\nScreenshot diff visualization for fast image comparison processor.\n\nAll image operations now use ImageDiffHandler ab"
},
{
"path": "changedetectionio/processors/image_ssim_diff/edit_hook.py",
"chars": 5904,
"preview": "\"\"\"\nOptional hook called when processor settings are saved in edit page.\n\nThis hook analyzes the selected region to dete"
},
{
"path": "changedetectionio/processors/image_ssim_diff/forms.py",
"chars": 5035,
"preview": "\"\"\"\nConfiguration forms for fast screenshot comparison processor.\n\"\"\"\n\nfrom wtforms import SelectField, StringField, val"
},
{
"path": "changedetectionio/processors/image_ssim_diff/image_handler/__init__.py",
"chars": 6272,
"preview": "\"\"\"\nAbstract base class for image processing operations.\n\nAll image operations for the image_ssim_diff processor must be"
},
{
"path": "changedetectionio/processors/image_ssim_diff/image_handler/isolated_libvips.py",
"chars": 12993,
"preview": "\"\"\"\nSubprocess-isolated image operations for memory leak prevention.\n\nLibVIPS accumulates C-level memory in long-running"
},
{
"path": "changedetectionio/processors/image_ssim_diff/image_handler/isolated_opencv.py",
"chars": 27437,
"preview": "\"\"\"\nOpenCV-based subprocess isolation for image comparison.\n\nOpenCV is much more stable in multiprocessing contexts than"
},
{
"path": "changedetectionio/processors/image_ssim_diff/image_handler/libvips_handler.py",
"chars": 11767,
"preview": "\"\"\"\nLibVIPS implementation of ImageDiffHandler.\n\nUses pyvips for high-performance image processing with streaming archit"
},
{
"path": "changedetectionio/processors/image_ssim_diff/preview.py",
"chars": 3721,
"preview": "\"\"\"\nPreview rendering for SSIM screenshot processor.\n\nRenders images properly in the browser instead of showing raw byte"
},
{
"path": "changedetectionio/processors/image_ssim_diff/processor.py",
"chars": 12241,
"preview": "\"\"\"\nCore fast screenshot comparison processor.\n\nUses OpenCV with subprocess isolation for high-performance, low-memory\ni"
},
{
"path": "changedetectionio/processors/image_ssim_diff/templates/image_ssim_diff/diff.html",
"chars": 10252,
"preview": "{% extends 'base.html' %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n\n{% block"
},
{
"path": "changedetectionio/processors/image_ssim_diff/templates/image_ssim_diff/preview.html",
"chars": 1941,
"preview": "{% extends 'base.html' %}\n\n{% block content %}\n<script src=\"{{ url_for('static_content', group='js', filename='preview.j"
},
{
"path": "changedetectionio/processors/image_ssim_diff/util.py",
"chars": 924,
"preview": "\"\"\"\nDEPRECATED: All multiprocessing functions have been removed.\n\nThe image_ssim_diff processor now uses LibVIPS via Ima"
},
{
"path": "changedetectionio/processors/magic.py",
"chars": 6322,
"preview": "\"\"\"\nContent Type Detection and Stream Classification\n\nThis module provides intelligent content-type detection for change"
},
{
"path": "changedetectionio/processors/restock_diff/__init__.py",
"chars": 3177,
"preview": "\nfrom babel.numbers import parse_decimal\nfrom changedetectionio.model.Watch import model as BaseWatch\nfrom typing import"
},
{
"path": "changedetectionio/processors/restock_diff/api.yaml",
"chars": 5485,
"preview": "components:\n schemas:\n processor_config_restock_diff:\n type: object\n description: Configuration for the re"
},
{
"path": "changedetectionio/processors/restock_diff/forms.py",
"chars": 4569,
"preview": "from wtforms import (\n BooleanField,\n validators,\n FloatField\n)\nfrom wtforms.fields.choices import RadioField\nf"
},
{
"path": "changedetectionio/processors/restock_diff/processor.py",
"chars": 31557,
"preview": "from ..base import difference_detection_processor\nfrom ..exceptions import ProcessorException\nfrom . import Restock\nfrom"
},
{
"path": "changedetectionio/processors/restock_diff/pure_python_extractor.py",
"chars": 10470,
"preview": "\"\"\"\nPure Python metadata extractor - no lxml, no memory leaks.\n\nThis module provides a fast, memory-efficient alternativ"
},
{
"path": "changedetectionio/processors/templates/extract.html",
"chars": 2631,
"preview": "{% extends 'base.html' %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n{% block "
},
{
"path": "changedetectionio/processors/text_json_diff/__init__.py",
"chars": 6533,
"preview": "from loguru import logger\n\n# Processor capabilities\nsupports_visual_selector = True\nsupports_browser_steps = True\nsuppor"
},
{
"path": "changedetectionio/processors/text_json_diff/difference.py",
"chars": 9592,
"preview": "\"\"\"\nHistory/diff rendering for text_json_diff processor.\n\nThis module handles the visualization of text/HTML/JSON change"
},
{
"path": "changedetectionio/processors/text_json_diff/processor.py",
"chars": 25974,
"preview": "# HTML to TEXT/JSON DIFFERENCE self.fetcher\n\nimport hashlib\nimport json\nimport os\nimport re\nimport urllib3\n\nfrom changed"
},
{
"path": "changedetectionio/pytest.ini",
"chars": 304,
"preview": "[pytest]\naddopts = --no-start-live-server --live-server-port=0\n#testpaths = tests pytest_invenio\n#live_server_scope = fu"
},
{
"path": "changedetectionio/queue_handlers.py",
"chars": 24278,
"preview": "from blinker import signal\nfrom loguru import logger\nfrom typing import Dict, List, Any, Optional\nimport heapq\nimport qu"
},
{
"path": "changedetectionio/queuedWatchMetaData.py",
"chars": 280,
"preview": "from dataclasses import dataclass, field\nfrom typing import Any\n\n# So that we can queue some metadata in `item`\n# https:"
},
{
"path": "changedetectionio/realtime/README.md",
"chars": 4693,
"preview": "# Real-time Socket.IO Implementation\n\nThis directory contains the Socket.IO implementation for changedetection.io's real"
},
{
"path": "changedetectionio/realtime/__init__.py",
"chars": 64,
"preview": "\"\"\"\nSocket.IO realtime updates module for changedetection.io\n\"\"\""
},
{
"path": "changedetectionio/realtime/events.py",
"chars": 2584,
"preview": "from flask_socketio import emit\nfrom loguru import logger\nfrom blinker import signal\n\n\ndef register_watch_operation_hand"
},
{
"path": "changedetectionio/realtime/socket_server.py",
"chars": 16429,
"preview": "import timeago\nfrom flask_socketio import SocketIO\nfrom flask_babel import gettext, get_locale\n\nimport time\nimport os\nfr"
},
{
"path": "changedetectionio/rss_tools.py",
"chars": 7755,
"preview": "\"\"\"\nRSS/Atom feed processing tools for changedetection.io\n\"\"\"\n\nfrom loguru import logger\nimport re\n\n\ndef cdata_in_docume"
},
{
"path": "changedetectionio/run_basic_tests.sh",
"chars": 3502,
"preview": "#!/bin/bash\n\n\n# live_server will throw errors even with live_server_scope=function if I have the live_server setup in di"
},
{
"path": "changedetectionio/run_custom_browser_url_tests.sh",
"chars": 2143,
"preview": "#!/bin/bash\n\n# run some tests and look if the 'custom-browser-search-string=1' connect string appeared in the correct co"
},
{
"path": "changedetectionio/run_proxy_tests.sh",
"chars": 4330,
"preview": "#!/bin/bash\n\n# exit when any command fails\nset -e\n# enable debug\nset -x\n\n# Test proxy list handling, starting two squids"
},
{
"path": "changedetectionio/run_socks_proxy_tests.sh",
"chars": 2244,
"preview": "#!/bin/bash\n\n# exit when any command fails\nset -e\n# enable debug\nset -x\n\ndocker network inspect changedet-network >/dev/"
},
{
"path": "changedetectionio/static/favicons/browserconfig.xml",
"chars": 254,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n <msapplication>\n <tile>\n <square150x150logo"
},
{
"path": "changedetectionio/static/favicons/site.webmanifest",
"chars": 703,
"preview": "{\n \"name\": \"ChangeDetection.io\",\n \"short_name\": \"ChangeDetect\",\n \"description\": \"Self-hosted website change det"
},
{
"path": "changedetectionio/static/js/browser-steps.js",
"chars": 20215,
"preview": "$(document).ready(function () {\n\n var browsersteps_session_id;\n var browser_interface_seconds_remaining = 0;\n v"
},
{
"path": "changedetectionio/static/js/comparison-slider.js",
"chars": 6119,
"preview": "/**\n * Interactive Image Comparison Slider\n *\n * Allows users to drag a vertical slider to reveal differences between\n *"
},
{
"path": "changedetectionio/static/js/conditions.js",
"chars": 6136,
"preview": "$(document).ready(function () {\n // Function to set up button event handlers\n function setupButtonHandlers() {\n "
},
{
"path": "changedetectionio/static/js/csrf.js",
"chars": 287,
"preview": "$(document).ready(function () {\n $.ajaxSetup({\n beforeSend: function (xhr, settings) {\n if (!/^(GET"
},
{
"path": "changedetectionio/static/js/diff-overview.js",
"chars": 7174,
"preview": "function setupDiffNavigation() {\n var $fromSelect = $('#diff-from-version');\n var $toSelect = $('#diff-to-version'"
},
{
"path": "changedetectionio/static/js/diff-render.js",
"chars": 5846,
"preview": "$(document).ready(function () {\n\n // Find all <span> elements inside pre#difference\n var inputs = $('#difference s"
},
{
"path": "changedetectionio/static/js/flask-toast-bridge.js",
"chars": 2371,
"preview": "/**\n * Flask Toast Bridge\n * Automatically converts Flask flash messages to toast notifications\n *\n * Maps Flask message"
},
{
"path": "changedetectionio/static/js/global-settings.js",
"chars": 1814,
"preview": "$(document).ready(function () {\n $(\"#api-key\").hover(\n function () {\n $(\"#api-key-copy\").html('copy"
},
{
"path": "changedetectionio/static/js/hamburger-menu.js",
"chars": 2114,
"preview": "// Hamburger menu toggle functionality\n(function() {\n 'use strict';\n\n document.addEventListener('DOMContentLoaded', fu"
},
{
"path": "changedetectionio/static/js/language-selector.js",
"chars": 2158,
"preview": "/**\n * Language selector modal functionality\n * Allows users to select their preferred language\n */\n\n$(document).ready(f"
},
{
"path": "changedetectionio/static/js/modal.js",
"chars": 6274,
"preview": "/**\n * Modern modal dialog system using HTML5 <dialog> element\n * Provides accessible, animated confirmation dialogs\n */"
},
{
"path": "changedetectionio/static/js/notifications.js",
"chars": 1989,
"preview": "$(document).ready(function () {\n\n $('#add-email-helper').click(function (e) {\n e.preventDefault();\n ema"
},
{
"path": "changedetectionio/static/js/plugins.js",
"chars": 6915,
"preview": "(function ($) {\n /**\n * debounce\n * @param {integer} milliseconds This param indicates the number of millisec"
},
{
"path": "changedetectionio/static/js/preview.js",
"chars": 2476,
"preview": "function redirectToVersion(version) {\n var currentUrl = window.location.href.split('?')[0]; // Base URL without query"
},
{
"path": "changedetectionio/static/js/realtime.js",
"chars": 12483,
"preview": "// Socket.IO client-side integration for changedetection.io\n\n$(document).ready(function () {\n\n function reapplyTableS"
},
{
"path": "changedetectionio/static/js/recheck-proxy.js",
"chars": 3287,
"preview": "$(function () {\n /* add container before each proxy location to show status */\n var isActive = false;\n\n functio"
},
{
"path": "changedetectionio/static/js/scheduler.js",
"chars": 4697,
"preview": "function getTimeInTimezone(timezone) {\n const now = new Date();\n const options = {\n timeZone: timezone,\n "
},
{
"path": "changedetectionio/static/js/search-modal.js",
"chars": 3274,
"preview": "// Search modal functionality\n(function() {\n 'use strict';\n\n document.addEventListener('DOMContentLoaded', function() "
},
{
"path": "changedetectionio/static/js/snippet-to-image.js",
"chars": 27320,
"preview": "/**\n * snippet-to-image.js\n * Converts selected diff content to a shareable JPEG image with metadata\n */\n\n// Constants\nc"
},
{
"path": "changedetectionio/static/js/stepper.js",
"chars": 861,
"preview": "$(document).ready(function(){\n checkUserVal();\n $('#fetch_backend input').on('change', checkUserVal);\n});\n\nvar check"
}
]
// ... and 227 more files (download for full content)
About this extraction
This page contains the full source code of the dgtlmoon/changedetection.io GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 427 files (4.6 MB), approximately 1.2M tokens, and a symbol index with 1666 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.